Compare commits
1 Commits
hermes-bac
...
hermes-sav
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b5c3290b2 |
@@ -1,12 +0,0 @@
|
||||
**/node_modules
|
||||
**/dist
|
||||
**/target
|
||||
**/.turbo
|
||||
**/.vite
|
||||
.git
|
||||
.github
|
||||
packages/desktop/src-tauri/target
|
||||
e2e
|
||||
playwright-report
|
||||
test-results
|
||||
*.log
|
||||
105
.github/dependabot.yml
vendored
@@ -1,105 +0,0 @@
|
||||
# Dependabot configuration
|
||||
# Docs: https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
# ─── JavaScript / pnpm workspace ──────────────────────────────────────────
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
time: "06:00"
|
||||
timezone: "Europe/London"
|
||||
open-pull-requests-limit: 5
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
include: "scope"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "javascript"
|
||||
groups:
|
||||
# Keep TanStack libs in lockstep — they release as a family
|
||||
tanstack:
|
||||
patterns:
|
||||
- "@tanstack/*"
|
||||
# Tauri JS bindings
|
||||
tauri:
|
||||
patterns:
|
||||
- "@tauri-apps/*"
|
||||
# Test stack
|
||||
testing:
|
||||
patterns:
|
||||
- "vitest"
|
||||
- "@vitest/*"
|
||||
- "@testing-library/*"
|
||||
- "jsdom"
|
||||
- "@playwright/*"
|
||||
# Build/lint tooling
|
||||
tooling:
|
||||
patterns:
|
||||
- "@biomejs/*"
|
||||
- "turbo"
|
||||
- "vite"
|
||||
- "@vitejs/*"
|
||||
- "typescript"
|
||||
# React core
|
||||
react:
|
||||
patterns:
|
||||
- "react"
|
||||
- "react-dom"
|
||||
- "@types/react"
|
||||
- "@types/react-dom"
|
||||
# Semantic-release ecosystem
|
||||
semantic-release:
|
||||
patterns:
|
||||
- "semantic-release"
|
||||
- "@semantic-release/*"
|
||||
# Commitlint + husky
|
||||
commit-tooling:
|
||||
patterns:
|
||||
- "@commitlint/*"
|
||||
- "husky"
|
||||
|
||||
# ─── Rust / Cargo (Tauri desktop shell) ───────────────────────────────────
|
||||
- package-ecosystem: "cargo"
|
||||
directory: "/packages/desktop/src-tauri"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
time: "06:00"
|
||||
timezone: "Europe/London"
|
||||
open-pull-requests-limit: 3
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
include: "scope"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "rust"
|
||||
groups:
|
||||
tauri-core:
|
||||
patterns:
|
||||
- "tauri"
|
||||
- "tauri-*"
|
||||
tokio:
|
||||
patterns:
|
||||
- "tokio"
|
||||
- "tokio-*"
|
||||
- "futures"
|
||||
- "futures-*"
|
||||
|
||||
# ─── GitHub Actions workflow pins ─────────────────────────────────────────
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
time: "06:00"
|
||||
timezone: "Europe/London"
|
||||
open-pull-requests-limit: 3
|
||||
commit-message:
|
||||
prefix: "chore(ci)"
|
||||
include: "scope"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "github-actions"
|
||||
5
.github/workflows/release.yml
vendored
@@ -1,8 +1,9 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
|
||||
6
.gitignore
vendored
@@ -41,9 +41,3 @@ dist-ssr
|
||||
# Tauri
|
||||
packages/desktop/src-tauri/target/
|
||||
packages/desktop/src-tauri/gen/
|
||||
|
||||
# Drafts from scripts/pr-evidence.sh
|
||||
PR_BODY.md
|
||||
|
||||
# Test coverage
|
||||
coverage
|
||||
|
||||
@@ -1,14 +1,4 @@
|
||||
#!/bin/sh
|
||||
# Pre-commit gates — must be fast (<2s typical). Anything slow goes in pre-push.
|
||||
#
|
||||
# Order:
|
||||
# 1. Secret scan (must run first; blocks commit if a secret leaks in)
|
||||
# 2. Biome format/lint on staged TS/JS/JSON/CSS files
|
||||
|
||||
# 1. Secret scan over staged additions
|
||||
./scripts/secret-scan.sh
|
||||
|
||||
# 2. Biome on staged files (auto-fixes, re-stages)
|
||||
STAGED=$(git diff --cached --name-only --diff-filter=ACMR | grep -E "\.(ts|tsx|js|jsx|css|json)$" || true)
|
||||
[ -z "$STAGED" ] && exit 0
|
||||
pnpm exec biome check --write --staged
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Pre-push gate — runs the full quality check before the branch leaves the
|
||||
# laptop. Mirrors the `check` job in .github/workflows/ci.yml.
|
||||
#
|
||||
# Bypass for genuine emergencies with: git push --no-verify
|
||||
|
||||
echo "→ Running pre-push checks (lint + typecheck + test)…"
|
||||
pnpm check
|
||||
47
CHANGELOG.md
@@ -1,50 +1,3 @@
|
||||
## [0.12.1](https://github.com/offendingcommit/openconcho/compare/v0.12.0...v0.12.1) (2026-05-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ci:** trigger release build on release publish, not tag push ([52a7f09](https://github.com/offendingcommit/openconcho/commit/52a7f09ce6c3202d4929e1d6282d1d6b12578b40))
|
||||
|
||||
# [0.12.0](https://github.com/offendingcommit/openconcho/compare/v0.11.0...v0.12.0) (2026-05-28)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **desktop:** auto-discover Honcho instances on localhost ([#1](https://github.com/offendingcommit/openconcho/issues/1)) ([7355884](https://github.com/offendingcommit/openconcho/commit/735588405106af59eb76bfd60a681253c76f9c1e))
|
||||
* **web:** add dialectic reasoning playground ([2340e65](https://github.com/offendingcommit/openconcho/commit/2340e65028b8e1c47ee081de16210b85408cc380))
|
||||
* **web:** add Dream Output Viewer ([f318555](https://github.com/offendingcommit/openconcho/commit/f318555c82b37a66fcd2c8c744f19350f06acee9))
|
||||
* **web:** add live dream progress panel with adaptive polling ([17f8a5a](https://github.com/offendingcommit/openconcho/commit/17f8a5a7bfa965c4643616fa9b39ce7b6b36e007))
|
||||
* **web:** add Peer Card Seed Kits ([650bfd7](https://github.com/offendingcommit/openconcho/commit/650bfd7b280edb8c5e85d1cccb5171fdf40ecb8e))
|
||||
* **web:** show sidebar last updated status ([7cf94aa](https://github.com/offendingcommit/openconcho/commit/7cf94aa03f31fc1738ba9e9a19e965f279bc4e7c))
|
||||
|
||||
# [0.11.0](https://github.com/offendingcommit/openconcho/compare/v0.10.0...v0.11.0) (2026-05-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* harden token and URL handling ([5cfbae6](https://github.com/offendingcommit/openconcho/commit/5cfbae62489c9ec4cc85f30a370b45db269defbc))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **docker:** add containerized web build with CORS guidance ([cfe07f1](https://github.com/offendingcommit/openconcho/commit/cfe07f190002c0a6bc9a4433d9cb352147e10c7e))
|
||||
|
||||
# [0.10.0](https://github.com/offendingcommit/openconcho/compare/v0.9.0...v0.10.0) (2026-05-28)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **api:** add scoped multi-instance query client ([de8db4b](https://github.com/offendingcommit/openconcho/commit/de8db4b7aaccd3337507aacfb93b1ec2f2ac42db))
|
||||
|
||||
# [0.9.0](https://github.com/offendingcommit/openconcho/compare/v0.8.0...v0.9.0) (2026-05-28)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **web:** add global metadata visibility toggle ([e490d91](https://github.com/offendingcommit/openconcho/commit/e490d911fcb27ee193558fd9a28856cde2057665))
|
||||
* **web:** add shared Breadcrumb component for workspace pages ([c6afc80](https://github.com/offendingcommit/openconcho/commit/c6afc80fda4ec76b84382f959ecdf9f5a4cf6556))
|
||||
* **web:** add workspace contextual sub-nav to sidebar ([62cae68](https://github.com/offendingcommit/openconcho/commit/62cae68d05cb0e61594f22611f7d73f13ecd2704))
|
||||
|
||||
# [0.8.0](https://github.com/offendingcommit/openconcho/compare/v0.7.1...v0.8.0) (2026-05-15)
|
||||
|
||||
|
||||
|
||||
38
Dockerfile
@@ -1,38 +0,0 @@
|
||||
# OpenConcho web SPA — self-hosted Honcho dashboard.
|
||||
#
|
||||
# Multi-stage build:
|
||||
# 1. node:22-alpine + pnpm builds the @openconcho/web SPA to packages/web/dist
|
||||
# 2. nginx-unprivileged serves the static bundle as non-root (UID 101) on
|
||||
# port 8080 — runs cleanly under read-only filesystem + cap_drop ALL.
|
||||
|
||||
# ---------- Builder stage ----------
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
RUN corepack enable \
|
||||
&& corepack prepare pnpm@10.33.2 --activate
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy workspace/lockfile/manifests first for layer-cache efficiency.
|
||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json .npmrc .pnpmfile.cjs ./
|
||||
COPY packages/web/package.json packages/web/
|
||||
COPY packages/desktop/package.json packages/desktop/
|
||||
|
||||
# Install only the web filter's transitive deps (skips the Tauri Rust toolchain).
|
||||
RUN pnpm install --frozen-lockfile --filter @openconcho/web...
|
||||
|
||||
# Copy remaining sources + build.
|
||||
COPY . .
|
||||
RUN pnpm --filter @openconcho/web build
|
||||
|
||||
# ---------- Runtime stage ----------
|
||||
# Unprivileged variant runs as UID 101 with no root setup steps, so it works
|
||||
# under a read-only filesystem with cap_drop ALL.
|
||||
FROM nginxinc/nginx-unprivileged:alpine
|
||||
|
||||
COPY --chown=101:101 --from=builder /app/packages/web/dist /usr/share/nginx/html
|
||||
COPY --chown=101:101 docker/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
# Base image CMD runs nginx in the foreground as UID 101.
|
||||
@@ -1,54 +0,0 @@
|
||||
# OpenConcho — nginx site config for the runtime container.
|
||||
# Serves the React SPA from /usr/share/nginx/html with client-side routing.
|
||||
|
||||
server {
|
||||
listen 8080;
|
||||
listen [::]:8080;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Don't leak the nginx version.
|
||||
server_tokens off;
|
||||
|
||||
# Long-cache static assets — Vite hashes filenames so they're safely immutable.
|
||||
location ~* \.(?:js|mjs|css|woff2?|ttf|otf|eot|svg|png|jpg|jpeg|gif|ico|webp|avif|wasm)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
access_log off;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# Healthcheck (no logging spam).
|
||||
location = /healthz {
|
||||
access_log off;
|
||||
default_type text/plain;
|
||||
return 200 "ok\n";
|
||||
}
|
||||
|
||||
# --- Optional: same-origin Honcho reverse proxy (eliminates browser CORS) ---
|
||||
# The SPA's fetches are subject to browser CORS only on the web build (the
|
||||
# desktop app routes through Rust and bypasses CORS). To avoid configuring
|
||||
# CORS on Honcho itself, proxy the API under this origin and point the UI's
|
||||
# base URL at it. See docs/docker.md for the full tradeoff — note the UI
|
||||
# currently requires an absolute base URL, so this block is opt-in.
|
||||
#
|
||||
# location /honcho/ {
|
||||
# proxy_pass http://your-honcho-host:8000/;
|
||||
# proxy_set_header Host $host;
|
||||
# proxy_set_header X-Real-IP $remote_addr;
|
||||
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
# proxy_set_header X-Forwarded-Proto $scheme;
|
||||
# }
|
||||
|
||||
# SPA fallback: any unknown path returns index.html so the router resolves it.
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
}
|
||||
|
||||
# gzip text responses (Vite pre-compresses CSS/JS, but HTML still benefits).
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
|
||||
gzip_min_length 1024;
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
# Running OpenConcho in Docker
|
||||
|
||||
The `@openconcho/web` SPA can be served from a container. The image is a
|
||||
two-stage build: Node + pnpm builds the static bundle, then
|
||||
`nginx-unprivileged` serves it on port `8080` as a non-root user.
|
||||
|
||||
## Build and run
|
||||
|
||||
```bash
|
||||
docker build -t openconcho-web .
|
||||
docker run --rm -p 8080:8080 openconcho-web
|
||||
# → http://localhost:8080
|
||||
```
|
||||
|
||||
Hardened run (read-only filesystem, no added capabilities):
|
||||
|
||||
```bash
|
||||
docker run --rm -p 8080:8080 \
|
||||
--read-only \
|
||||
--tmpfs /tmp \
|
||||
--tmpfs /var/cache/nginx \
|
||||
--cap-drop ALL \
|
||||
--security-opt no-new-privileges \
|
||||
openconcho-web
|
||||
```
|
||||
|
||||
`GET /healthz` returns `200 ok` for container health checks.
|
||||
|
||||
## CORS
|
||||
|
||||
The desktop app routes HTTP through Rust (`reqwest`), so it is **not** subject
|
||||
to browser CORS. The **web build is**: it uses the browser's `fetch`, so every
|
||||
request to your Honcho API is cross-origin. Honcho calls are `POST` +
|
||||
`application/json` + `Authorization: Bearer`, which the browser always
|
||||
**preflights** (`OPTIONS`). You must handle this one of two ways.
|
||||
|
||||
### Option 1 — configure Honcho's CORS (recommended)
|
||||
|
||||
Honcho is a FastAPI service. Allow the UI's origin via its
|
||||
`CORSMiddleware` so preflight and actual requests succeed:
|
||||
|
||||
```python
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:8080"], # the OpenConcho origin
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
```
|
||||
|
||||
This fits OpenConcho's model directly — the UI keeps using the absolute Honcho
|
||||
URL you enter in Settings (stored in `localStorage`). Since you self-host
|
||||
Honcho, you control this.
|
||||
|
||||
### Option 2 — same-origin reverse proxy (advanced)
|
||||
|
||||
Proxy the Honcho API under the same origin that serves the SPA, so the browser
|
||||
sees same-origin requests and CORS never applies (the token also never crosses
|
||||
origins). Uncomment the `location /honcho/` block in
|
||||
[`docker/nginx.conf`](../docker/nginx.conf) and set `proxy_pass` to your Honcho
|
||||
host.
|
||||
|
||||
Caveat: the Settings form currently validates the base URL as an **absolute**
|
||||
URL (`z.string().url()`), so pointing the UI at a relative same-origin path
|
||||
(`/honcho`) isn't wired yet. Until that lands, Option 1 is the supported path.
|
||||
|
Before Width: | Height: | Size: 352 KiB |
|
Before Width: | Height: | Size: 217 KiB |
|
Before Width: | Height: | Size: 338 KiB |
|
Before Width: | Height: | Size: 205 KiB |
|
Before Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 322 KiB |
|
Before Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 224 KiB |
|
Before Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 301 KiB |
|
Before Width: | Height: | Size: 173 KiB |
|
Before Width: | Height: | Size: 264 KiB |
|
Before Width: | Height: | Size: 291 KiB |
|
Before Width: | Height: | Size: 163 KiB |
|
Before Width: | Height: | Size: 247 KiB |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "openconcho",
|
||||
"private": true,
|
||||
"version": "0.12.1",
|
||||
"version": "0.8.0",
|
||||
"packageManager": "pnpm@10.33.2",
|
||||
"engines": {
|
||||
"node": ">=22",
|
||||
@@ -22,7 +22,6 @@
|
||||
"check": "turbo run lint typecheck test",
|
||||
"ci:web": "turbo run lint typecheck test build --filter=@openconcho/web",
|
||||
"ci:desktop": "turbo run cargo-check --filter=@openconcho/desktop",
|
||||
"pr:evidence": "./scripts/pr-evidence.sh",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
21
packages/desktop/src-tauri/Cargo.lock
generated
@@ -923,21 +923,6 @@ dependencies = [
|
||||
"new_debug_unreachable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
"futures-io",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.32"
|
||||
@@ -945,7 +930,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1000,7 +984,6 @@ version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-macro",
|
||||
@@ -2283,9 +2266,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openconcho"
|
||||
version = "0.12.1"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
@@ -2293,7 +2275,6 @@ dependencies = [
|
||||
"tauri-plugin-deep-link",
|
||||
"tauri-plugin-http",
|
||||
"tauri-plugin-shell",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "openconcho"
|
||||
version = "0.12.1"
|
||||
version = "0.8.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
@@ -17,5 +17,3 @@ tauri-plugin-shell = "2"
|
||||
tauri-plugin-deep-link = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["net", "io-util", "time", "rt", "macros"] }
|
||||
futures = "0.3"
|
||||
|
||||
@@ -8,10 +8,8 @@
|
||||
{
|
||||
"identifier": "http:default",
|
||||
"allow": [
|
||||
{ "url": "http://localhost" },
|
||||
{ "url": "http://localhost:*" },
|
||||
{ "url": "http://127.0.0.1" },
|
||||
{ "url": "http://127.0.0.1:*" },
|
||||
{ "url": "http://*" },
|
||||
{ "url": "http://*:*" },
|
||||
{ "url": "https://*" },
|
||||
{ "url": "https://*:*" }
|
||||
]
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
//! Localhost Honcho instance discovery.
|
||||
//!
|
||||
//! Probes a range of ports on 127.0.0.1 for a Honcho `/health` endpoint
|
||||
//! that returns `{"status":"ok"}`. Desktop-only feature: the browser
|
||||
//! can't port-scan due to CORS, so this lives in the Tauri Rust shell.
|
||||
|
||||
use serde::Serialize;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpStream;
|
||||
|
||||
const DEFAULT_START_PORT: u16 = 8000;
|
||||
const DEFAULT_END_PORT: u16 = 8100;
|
||||
const CONNECT_TIMEOUT_MS: u64 = 150;
|
||||
const REQUEST_TIMEOUT_MS: u64 = 250;
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
pub struct DiscoveredInstance {
|
||||
pub port: u16,
|
||||
pub base_url: String,
|
||||
}
|
||||
|
||||
async fn probe_port(port: u16) -> Option<DiscoveredInstance> {
|
||||
let addr = format!("127.0.0.1:{}", port);
|
||||
|
||||
let connect = TcpStream::connect(&addr);
|
||||
let stream = tokio::time::timeout(Duration::from_millis(CONNECT_TIMEOUT_MS), connect)
|
||||
.await
|
||||
.ok()?
|
||||
.ok()?;
|
||||
|
||||
let mut stream = stream;
|
||||
let req = b"GET /health HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n";
|
||||
|
||||
let io = async {
|
||||
stream.write_all(req).await.ok()?;
|
||||
let mut buf = Vec::with_capacity(512);
|
||||
stream.read_to_end(&mut buf).await.ok()?;
|
||||
Some(buf)
|
||||
};
|
||||
let buf = tokio::time::timeout(Duration::from_millis(REQUEST_TIMEOUT_MS), io)
|
||||
.await
|
||||
.ok()??;
|
||||
|
||||
let body = String::from_utf8_lossy(&buf);
|
||||
if body.contains("\"status\":\"ok\"") {
|
||||
Some(DiscoveredInstance {
|
||||
port,
|
||||
base_url: format!("http://127.0.0.1:{}", port),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn discover_honcho_instances(
|
||||
start_port: Option<u16>,
|
||||
end_port: Option<u16>,
|
||||
) -> Vec<DiscoveredInstance> {
|
||||
let start = start_port.unwrap_or(DEFAULT_START_PORT);
|
||||
let end = end_port.unwrap_or(DEFAULT_END_PORT);
|
||||
if end < start {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let probes: Vec<_> = (start..=end).map(probe_port).collect();
|
||||
let results = futures::future::join_all(probes).await;
|
||||
let mut found: Vec<DiscoveredInstance> = results.into_iter().flatten().collect();
|
||||
found.sort_by_key(|d| d.port);
|
||||
found
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_inverted_port_range() {
|
||||
let found = discover_honcho_instances(Some(9000), Some(8000)).await;
|
||||
assert!(found.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ignores_ports_with_no_listener() {
|
||||
// Port 1 should reliably have no listener — connect fails fast.
|
||||
let result = probe_port(1).await;
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires live Honcho stacks on 8001-8005"]
|
||||
async fn finds_live_hermes_stacks() {
|
||||
let found = discover_honcho_instances(Some(8000), Some(8010)).await;
|
||||
let ports: Vec<u16> = found.iter().map(|d| d.port).collect();
|
||||
assert_eq!(ports, vec![8001, 8002, 8003, 8004, 8005]);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,9 @@
|
||||
mod discover;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_http::init())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_deep_link::init())
|
||||
.invoke_handler(tauri::generate_handler![discover::discover_honcho_instances])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' http://localhost:* http://127.0.0.1:* https:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'"
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
const STORE_KEY = "openconcho:instances";
|
||||
const STORE_VALUE = JSON.stringify({
|
||||
instances: [{ id: "i1", name: "Local", baseUrl: "http://localhost:9999", token: "" }],
|
||||
activeId: "i1",
|
||||
});
|
||||
|
||||
test.describe("Dreams route", () => {
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await context.addInitScript(
|
||||
([key, value]) => {
|
||||
window.localStorage.setItem(key, value);
|
||||
},
|
||||
[STORE_KEY, STORE_VALUE],
|
||||
);
|
||||
// Stub the conclusions/list endpoint so the route can render real dreams.
|
||||
// :9999 is unreachable; this intercept replaces the network call entirely.
|
||||
// Use a function matcher so the trailing query string (?page=&page_size=) doesn't
|
||||
// break a glob.
|
||||
await context.route(
|
||||
(url) => url.pathname.endsWith("/conclusions/list"),
|
||||
async (route) => {
|
||||
const now = Date.now();
|
||||
const iso = (offsetMs: number) => new Date(now - offsetMs).toISOString();
|
||||
const items = [
|
||||
// Dream A — burst
|
||||
{
|
||||
id: "ind-1",
|
||||
content: "Alice prefers asynchronous communication",
|
||||
observer_id: "alice",
|
||||
observed_id: "bob",
|
||||
session_id: "sess-1",
|
||||
created_at: iso(1000),
|
||||
conclusion_type: "inductive",
|
||||
reasoning_tree: {
|
||||
conclusion_id: "ind-1",
|
||||
premises: [{ conclusion_id: "ded-1" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "ded-1",
|
||||
content: "Alice mentioned email twice and declined two meetings",
|
||||
observer_id: "alice",
|
||||
observed_id: "bob",
|
||||
session_id: "sess-1",
|
||||
created_at: iso(2000),
|
||||
conclusion_type: "deductive",
|
||||
reasoning_tree: {
|
||||
conclusion_id: "ded-1",
|
||||
premises: [{ conclusion_id: "exp-1" }, { conclusion_id: "exp-2" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "exp-1",
|
||||
content: "Alice said 'just email me'",
|
||||
observer_id: "alice",
|
||||
observed_id: "bob",
|
||||
session_id: "sess-1",
|
||||
created_at: iso(3000),
|
||||
conclusion_type: "explicit",
|
||||
},
|
||||
{
|
||||
id: "exp-2",
|
||||
content: "Alice declined the Tuesday standup",
|
||||
observer_id: "alice",
|
||||
observed_id: "bob",
|
||||
session_id: "sess-1",
|
||||
created_at: iso(4000),
|
||||
conclusion_type: "explicit",
|
||||
},
|
||||
// Dream B — 30 minutes ago, different pair → clusters separately
|
||||
{
|
||||
id: "ded-2",
|
||||
content: "Carol responds in the evenings",
|
||||
observer_id: "carol",
|
||||
observed_id: "dan",
|
||||
session_id: "sess-2",
|
||||
created_at: iso(30 * 60_000),
|
||||
conclusion_type: "deductive",
|
||||
},
|
||||
];
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
items,
|
||||
total: items.length,
|
||||
pages: 1,
|
||||
page: 1,
|
||||
size: items.length,
|
||||
}),
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("shows a Dreams entry in the workspace sub-nav", async ({ page }) => {
|
||||
await page.goto("/workspaces/ws-test/dreams");
|
||||
// Sidebar link with the Dreams label
|
||||
const dreamsLink = page.getByRole("link", { name: /^Dreams$/ });
|
||||
await expect(dreamsLink.first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("renders heading and breadcrumb on the dreams route", async ({ page }) => {
|
||||
await page.goto("/workspaces/ws-test/dreams");
|
||||
await expect(page.getByRole("heading", { name: /^Dreams$/ })).toBeVisible();
|
||||
// Breadcrumb specifically — the sidebar has a "Workspaces" link too, so scope.
|
||||
await expect(
|
||||
page.getByLabel("Breadcrumb").getByRole("link", { name: "Workspaces" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("clusters mocked conclusions into dreams and opens detail on click", async ({ page }) => {
|
||||
await page.goto("/workspaces/ws-test/dreams");
|
||||
|
||||
// Two dreams: alice→bob burst, and the older carol→dan
|
||||
const rows = page.locator('button[aria-pressed]');
|
||||
await expect(rows).toHaveCount(2);
|
||||
|
||||
// Alice→bob row should show count chips
|
||||
await expect(rows.first()).toContainText("alice");
|
||||
await expect(rows.first()).toContainText("bob");
|
||||
await expect(rows.first()).toContainText("2 explicit");
|
||||
await expect(rows.first()).toContainText("1 deductive");
|
||||
await expect(rows.first()).toContainText("1 inductive");
|
||||
|
||||
// Click → detail panel renders three columns
|
||||
await rows.first().click();
|
||||
await expect(page.getByText("Dream detail")).toBeVisible();
|
||||
await expect(page.getByText("Explicit", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Deductive", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Inductive", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("expands premise tree for an inductive conclusion", async ({ page }) => {
|
||||
await page.goto("/workspaces/ws-test/dreams");
|
||||
await page.locator('button[aria-pressed]').first().click();
|
||||
|
||||
const showPremises = page.getByRole("button", { name: /^Show premises$/i });
|
||||
await expect(showPremises).toBeVisible();
|
||||
await showPremises.click();
|
||||
|
||||
// The reasoning chain renders with the deductive premise (ded-1)
|
||||
await expect(page.getByText("Reasoning chain")).toBeVisible();
|
||||
await expect(page.getByLabel("Premise tree")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,131 +0,0 @@
|
||||
/**
|
||||
* One-off screenshot script for the dialectic playground.
|
||||
* Run with: pnpm exec playwright test packages/web/e2e/playground.screenshots.ts
|
||||
* Outputs are written to docs/screenshots/.
|
||||
*/
|
||||
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { test } from "@playwright/test";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const OUT_DIR = resolve(__dirname, "../../../docs/screenshots");
|
||||
|
||||
const STORE_KEY = "openconcho:instances";
|
||||
const STORE_VALUE = JSON.stringify({
|
||||
instances: [
|
||||
{
|
||||
id: "demo-inst",
|
||||
name: "Demo Honcho",
|
||||
baseUrl: "http://localhost:8001",
|
||||
token: "",
|
||||
},
|
||||
],
|
||||
activeId: "demo-inst",
|
||||
});
|
||||
|
||||
const WORKSPACE = "demo-workspace";
|
||||
const PEER = "alice@example.com";
|
||||
|
||||
// Per-level mocked latency (ms) and answer.
|
||||
const FIXTURES: Record<string, { delayMs: number; content: string }> = {
|
||||
minimal: {
|
||||
delayMs: 140,
|
||||
content:
|
||||
"Quick gist: Alice prefers async standups, dislikes meetings on Mondays, and tracks priorities in Linear.",
|
||||
},
|
||||
low: {
|
||||
delayMs: 410,
|
||||
content:
|
||||
"Alice runs the platform team. She prefers async standups, batches code review in the afternoons, and pushes back on meetings before 10am. Linear is her source of truth for priorities.",
|
||||
},
|
||||
medium: {
|
||||
delayMs: 1180,
|
||||
content:
|
||||
"Alice leads the platform team and operates on async-by-default. Three recurring patterns:\n\n• Async over sync — she explicitly skips standups in favor of written status posts on Wednesdays.\n• Deep-work mornings — meetings before 10am are pushed back; she protects 9–11am for coding.\n• Single-source-of-truth in Linear — anything not tracked there is treated as not happening.",
|
||||
},
|
||||
high: {
|
||||
delayMs: 2410,
|
||||
content:
|
||||
"Alice's working model has stayed remarkably stable over the last three months. She leads platform, treats async writing as the default communication mode, and resists synchronous coordination unless a decision is actively blocked. Three concrete patterns recur:\n\n1. Async-first standups — Wednesday written status, no daily sync.\n2. Morning deep work — calendar protected 9–11am, meetings pushed past 10.\n3. Linear as system-of-record — verbal commitments she hasn't written into Linear are treated as not real.\n\nShe also pushes back hard on cross-team meetings without a clear decision owner.",
|
||||
},
|
||||
max: {
|
||||
delayMs: 3920,
|
||||
content:
|
||||
"Across her recent sessions Alice consistently surfaces three reinforcing patterns and one tension worth flagging.\n\nPatterns:\n1. Async-first communication — explicit preference for written status (Wednesday Linear updates) over standups; she's said \"if it's not in Linear it isn't real\" in three separate threads.\n2. Protected morning deep-work — calendar is blocked 9–11am every weekday; she'll move meetings rather than break the block.\n3. Decision-owner gating — she refuses cross-team meetings without a named decision owner; this has come up six times since March.\n\nTension to flag: Alice's async-default occasionally collides with newer hires who prefer synchronous onboarding. She's aware of this — last month she experimented with a weekly 30-min office hour — but the data is too thin to call it resolved.",
|
||||
},
|
||||
};
|
||||
|
||||
// Default baseURL comes from playwright.config.ts (localhost:5173); override
|
||||
// with PLAYWRIGHT_BASE_URL=http://localhost:5184 if regenerating screenshots
|
||||
// against a worktree dev server on a different port.
|
||||
const BASE_URL = process.env.PLAYWRIGHT_BASE_URL;
|
||||
|
||||
test.use({
|
||||
viewport: { width: 1600, height: 1000 },
|
||||
...(BASE_URL ? { baseURL: BASE_URL } : {}),
|
||||
});
|
||||
|
||||
test("playground screenshots", async ({ page }) => {
|
||||
mkdirSync(OUT_DIR, { recursive: true });
|
||||
|
||||
await page.addInitScript(
|
||||
([key, value]) => {
|
||||
window.localStorage.setItem(key, value);
|
||||
},
|
||||
[STORE_KEY, STORE_VALUE],
|
||||
);
|
||||
|
||||
// Mock the Honcho health probe so the SPA doesn't show a disconnected banner.
|
||||
await page.route("**/v3/health*", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ status: "ok" }),
|
||||
}),
|
||||
);
|
||||
|
||||
// Mock the chat POST with per-level fixtures.
|
||||
await page.route("**/v3/workspaces/*/peers/*/chat", async (route) => {
|
||||
const body = JSON.parse(route.request().postData() ?? "{}") as {
|
||||
reasoning_level?: keyof typeof FIXTURES;
|
||||
};
|
||||
const level = body.reasoning_level ?? "low";
|
||||
const fx = FIXTURES[level];
|
||||
await new Promise((r) => setTimeout(r, fx.delayMs));
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ content: fx.content }),
|
||||
});
|
||||
});
|
||||
|
||||
// 1. Idle: empty playground.
|
||||
await page.goto(`/workspaces/${WORKSPACE}/peers/${encodeURIComponent(PEER)}/playground`);
|
||||
await page.waitForSelector('[data-testid="column-minimal"]');
|
||||
await page.screenshot({
|
||||
path: `${OUT_DIR}/playground-idle.png`,
|
||||
fullPage: false,
|
||||
});
|
||||
|
||||
// 2. Mid-flight: type a query, fire, capture while columns are still pending.
|
||||
await page.getByLabel("Query").fill("What patterns does Alice show across her recent sessions?");
|
||||
await page.getByLabel("Run selected levels").click();
|
||||
await page.waitForSelector('[data-testid="column-minimal"][data-status="success"]');
|
||||
// minimal returns at ~140ms; capture now so medium/high/max are still pending.
|
||||
await page.screenshot({
|
||||
path: `${OUT_DIR}/playground-running.png`,
|
||||
fullPage: false,
|
||||
});
|
||||
|
||||
// 3. Settled: wait for max to finish.
|
||||
await page.waitForSelector('[data-testid="column-max"][data-status="success"]', {
|
||||
timeout: 10_000,
|
||||
});
|
||||
await page.screenshot({
|
||||
path: `${OUT_DIR}/playground-results.png`,
|
||||
fullPage: false,
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,6 @@
|
||||
"lint": "biome check src/",
|
||||
"lint:fix": "biome check --write src/",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:e2e": "playwright test",
|
||||
"generate:api": "openapi-typescript openapi.json -o src/api/schema.d.ts"
|
||||
},
|
||||
@@ -55,7 +54,6 @@
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"@vitejs/plugin-react": "catalog:",
|
||||
"@vitest/coverage-v8": "catalog:",
|
||||
"jsdom": "catalog:",
|
||||
"openapi-typescript": "^7.8.0",
|
||||
"typescript": "catalog:",
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Capture documentation screenshots for the Dream Progress panel.
|
||||
*
|
||||
* The dev server must already be running at the URL passed in via PREVIEW_URL
|
||||
* (defaults to http://localhost:5178). The /dream-progress showcase route is
|
||||
* DEV-only and renders three variants of the panel against mock data.
|
||||
*
|
||||
* Usage:
|
||||
* PREVIEW_URL=http://localhost:5178 OUT_DIR=../../docs/screenshots/live-dream-progress \
|
||||
* node scripts/screenshot-dream-progress.mjs
|
||||
*/
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { chromium } from "@playwright/test";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const PREVIEW_URL = process.env.PREVIEW_URL ?? "http://localhost:5178";
|
||||
const OUT_DIR = path.resolve(
|
||||
__dirname,
|
||||
process.env.OUT_DIR ?? "../../../docs/screenshots/live-dream-progress",
|
||||
);
|
||||
|
||||
async function main() {
|
||||
await mkdir(OUT_DIR, { recursive: true });
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1440, height: 900 },
|
||||
deviceScaleFactor: 2,
|
||||
colorScheme: "dark",
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
// Seed localStorage with a fake instance so the root redirect doesn't kick
|
||||
// us to the settings page. The showcase doesn't actually make any network
|
||||
// requests — it renders against in-memory mock data.
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"openconcho:instances",
|
||||
JSON.stringify({
|
||||
instances: [
|
||||
{
|
||||
id: "inst_dev_demo",
|
||||
name: "Demo (mock)",
|
||||
baseUrl: "http://localhost:9999",
|
||||
token: "",
|
||||
},
|
||||
],
|
||||
activeId: "inst_dev_demo",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await page.goto(`${PREVIEW_URL}/dream-progress`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector('[data-testid="dream-progress-panel"]');
|
||||
// Let framer-motion entrance animations settle.
|
||||
await page.waitForTimeout(600);
|
||||
|
||||
// Full showcase — top-to-bottom view of all three variants.
|
||||
await page.screenshot({
|
||||
path: path.join(OUT_DIR, "overview.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
|
||||
// Variant: idle
|
||||
{
|
||||
const handle = await page.locator("section").nth(0);
|
||||
await handle.scrollIntoViewIfNeeded();
|
||||
await page.waitForTimeout(150);
|
||||
await handle.screenshot({ path: path.join(OUT_DIR, "idle.png") });
|
||||
}
|
||||
|
||||
// Variant: active (with per-session breakdown)
|
||||
{
|
||||
const handle = await page.locator("section").nth(1);
|
||||
await handle.scrollIntoViewIfNeeded();
|
||||
await page.waitForTimeout(150);
|
||||
await handle.screenshot({ path: path.join(OUT_DIR, "active.png") });
|
||||
}
|
||||
|
||||
// Variant: stalled (>30m without forward progress)
|
||||
{
|
||||
const handle = await page.locator("section").nth(2);
|
||||
await handle.scrollIntoViewIfNeeded();
|
||||
await page.waitForTimeout(150);
|
||||
await handle.screenshot({ path: path.join(OUT_DIR, "stalled.png") });
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
console.log(`Saved screenshots to ${OUT_DIR}`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,104 +0,0 @@
|
||||
// One-off screenshot capture for PR documentation.
|
||||
// Usage: BASE_URL=http://localhost:5177 node scripts/screenshot-seed-kits.mjs
|
||||
//
|
||||
// Produces both light- and dark-mode variants for each panel into
|
||||
// docs/seed-kits/{light,dark}/.
|
||||
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { chromium } from "@playwright/test";
|
||||
|
||||
const BASE_URL = process.env.BASE_URL ?? "http://localhost:5177";
|
||||
const OUT_ROOT = resolve(process.cwd(), "docs/seed-kits");
|
||||
|
||||
const SEED_INSTANCES = {
|
||||
instances: [
|
||||
{ id: "neo", name: "Neo (personal)", baseUrl: "http://localhost:8001", token: "" },
|
||||
{ id: "jeeves", name: "Jeeves (CodeWalnut)", baseUrl: "http://localhost:8002", token: "" },
|
||||
],
|
||||
activeId: "neo",
|
||||
};
|
||||
|
||||
const SEED_KITS = [
|
||||
{
|
||||
id: "kit_ben_personal",
|
||||
name: "Ben — personal core",
|
||||
description: "Identity facts Ben wants every personal-tier agent to know.",
|
||||
lines: [
|
||||
"Name: Ben Sheridan-Edwards",
|
||||
"Preferred address: Chief",
|
||||
"Email: ben@codewalnut.com",
|
||||
"Role: Founder",
|
||||
"Github: BenSheridanEdwards",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "kit_codewalnut_context",
|
||||
name: "CodeWalnut work context",
|
||||
description: "Work-tier identity for Jeeves and any future CodeWalnut agents.",
|
||||
lines: ["Employer: CodeWalnut", "Role: Founder", "Reports to: (self)"],
|
||||
},
|
||||
];
|
||||
|
||||
async function captureTheme(browser, theme) {
|
||||
const outDir = resolve(OUT_ROOT, theme);
|
||||
await mkdir(outDir, { recursive: true });
|
||||
|
||||
const ctx = await browser.newContext({
|
||||
viewport: { width: 1440, height: 900 },
|
||||
deviceScaleFactor: 2,
|
||||
colorScheme: theme === "dark" ? "dark" : "light",
|
||||
});
|
||||
|
||||
await ctx.addInitScript(
|
||||
([instances, kits, themeValue]) => {
|
||||
window.localStorage.setItem("openconcho:instances", instances);
|
||||
window.localStorage.setItem("openconcho:seed-kits", kits);
|
||||
window.localStorage.setItem("openconcho:theme", themeValue);
|
||||
},
|
||||
[JSON.stringify(SEED_INSTANCES), JSON.stringify(SEED_KITS), theme],
|
||||
);
|
||||
|
||||
const page = await ctx.newPage();
|
||||
|
||||
async function shot(name) {
|
||||
const file = resolve(outDir, `${name}.png`);
|
||||
await page.screenshot({ path: file, fullPage: false });
|
||||
console.log("wrote", file);
|
||||
}
|
||||
|
||||
// 1. List view with built-ins + user kits
|
||||
await page.goto(`${BASE_URL}/seed-kits`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector("text=Seed Kits");
|
||||
await page.waitForTimeout(400); // settle animations
|
||||
await shot("01-list");
|
||||
|
||||
// 2. Create form (use the "New kit" button)
|
||||
await page
|
||||
.getByRole("button", { name: /^New kit$/ })
|
||||
.first()
|
||||
.click();
|
||||
await page.waitForSelector("text=New seed kit");
|
||||
await page.waitForTimeout(300);
|
||||
await shot("02-create-form");
|
||||
|
||||
// 3. Back to list, then open apply dialog on the first user kit
|
||||
await page.goto(`${BASE_URL}/seed-kits`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector("text=Ben — personal core");
|
||||
await page.waitForTimeout(400);
|
||||
const applyButtons = page.getByRole("button", { name: /^Apply$/ });
|
||||
// User kits render after the 3 built-ins, so index 3 = first user kit.
|
||||
await applyButtons.nth(3).click();
|
||||
await page.waitForSelector("text=Apply seed kit");
|
||||
await page.waitForTimeout(500);
|
||||
await shot("03-apply-dialog");
|
||||
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
const browser = await chromium.launch();
|
||||
for (const theme of ["light", "dark"]) {
|
||||
await captureTheme(browser, theme);
|
||||
}
|
||||
await browser.close();
|
||||
console.log("done");
|
||||
@@ -1,85 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { Instance } from "@/lib/config";
|
||||
import { createScopedClient } from "./scopedClient";
|
||||
|
||||
function err(e: unknown): never {
|
||||
throw new Error(typeof e === "object" ? JSON.stringify(e) : String(e));
|
||||
}
|
||||
|
||||
// Query keys are scoped by instance.id so caches never collide across columns.
|
||||
const CK = {
|
||||
workspaces: (instId: string, page: number, size: number) =>
|
||||
["compare", instId, "workspaces", page, size] as const,
|
||||
peers: (instId: string, wsId: string, page: number, size: number) =>
|
||||
["compare", instId, "peers", wsId, page, size] as const,
|
||||
peerRepresentation: (instId: string, wsId: string, pId: string) =>
|
||||
["compare", instId, "peer-representation", wsId, pId] as const,
|
||||
peerCard: (instId: string, wsId: string, pId: string) =>
|
||||
["compare", instId, "peer-card", wsId, pId] as const,
|
||||
};
|
||||
|
||||
export function useScopedWorkspaces(instance: Instance, page = 1, pageSize = 20) {
|
||||
return useQuery({
|
||||
queryKey: CK.workspaces(instance.id, page, pageSize),
|
||||
queryFn: async () => {
|
||||
const client = createScopedClient(instance);
|
||||
const { data, error } = await client.POST("/v3/workspaces/list", {
|
||||
params: { query: { page, page_size: pageSize } },
|
||||
body: {},
|
||||
});
|
||||
return data ?? err(error);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useScopedPeers(instance: Instance, workspaceId: string, page = 1, pageSize = 20) {
|
||||
return useQuery({
|
||||
queryKey: CK.peers(instance.id, workspaceId, page, pageSize),
|
||||
queryFn: async () => {
|
||||
const client = createScopedClient(instance);
|
||||
const { data, error } = await client.POST("/v3/workspaces/{workspace_id}/peers/list", {
|
||||
params: { path: { workspace_id: workspaceId }, query: { page, page_size: pageSize } },
|
||||
body: {},
|
||||
});
|
||||
return data ?? err(error);
|
||||
},
|
||||
enabled: Boolean(workspaceId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useScopedPeerRepresentation(
|
||||
instance: Instance,
|
||||
workspaceId: string,
|
||||
peerId: string,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: CK.peerRepresentation(instance.id, workspaceId, peerId),
|
||||
queryFn: async () => {
|
||||
const client = createScopedClient(instance);
|
||||
const { data, error } = await client.POST(
|
||||
"/v3/workspaces/{workspace_id}/peers/{peer_id}/representation",
|
||||
{
|
||||
params: { path: { workspace_id: workspaceId, peer_id: peerId } },
|
||||
body: { max_conclusions: 20 },
|
||||
},
|
||||
);
|
||||
return data ?? err(error);
|
||||
},
|
||||
enabled: Boolean(workspaceId) && Boolean(peerId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useScopedPeerCard(instance: Instance, workspaceId: string, peerId: string) {
|
||||
return useQuery({
|
||||
queryKey: CK.peerCard(instance.id, workspaceId, peerId),
|
||||
queryFn: async () => {
|
||||
const client = createScopedClient(instance);
|
||||
const { data, error } = await client.GET(
|
||||
"/v3/workspaces/{workspace_id}/peers/{peer_id}/card",
|
||||
{ params: { path: { workspace_id: workspaceId, peer_id: peerId } } },
|
||||
);
|
||||
return data ?? err(error);
|
||||
},
|
||||
enabled: Boolean(workspaceId) && Boolean(peerId),
|
||||
});
|
||||
}
|
||||
@@ -32,8 +32,5 @@ export const QK = {
|
||||
conclusionsQuery: (wsId: string, q: string, filters: Record<string, unknown>) =>
|
||||
["conclusions-query", wsId, q, filters] as const,
|
||||
|
||||
dreams: (wsId: string, filters: Record<string, unknown>, limit: number) =>
|
||||
["dreams", wsId, filters, limit] as const,
|
||||
|
||||
webhooks: (wsId: string) => ["webhooks", wsId] as const,
|
||||
};
|
||||
|
||||
@@ -85,22 +85,6 @@ export function useScheduleDream(workspaceId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
import type { components } from "./schema.d.ts";
|
||||
|
||||
type QueueStatusBody = components["schemas"]["QueueStatus"];
|
||||
|
||||
// Poll faster while work is in flight so users can watch dreams/representations
|
||||
// progress; back off to a slow heartbeat when idle. The TanStack Query callback
|
||||
// form re-reads the cached value each cycle, so the interval adapts on its own.
|
||||
export const QUEUE_REFETCH_ACTIVE_MS = 2500;
|
||||
export const QUEUE_REFETCH_IDLE_MS = 10_000;
|
||||
|
||||
export function pickQueueRefetchInterval(data: QueueStatusBody | undefined): number {
|
||||
if (!data) return QUEUE_REFETCH_IDLE_MS;
|
||||
const active = (data.in_progress_work_units ?? 0) + (data.pending_work_units ?? 0);
|
||||
return active > 0 ? QUEUE_REFETCH_ACTIVE_MS : QUEUE_REFETCH_IDLE_MS;
|
||||
}
|
||||
|
||||
export function useQueueStatus(workspaceId: string) {
|
||||
return useQuery({
|
||||
queryKey: QK.queueStatus(workspaceId),
|
||||
@@ -112,7 +96,7 @@ export function useQueueStatus(workspaceId: string) {
|
||||
return data ?? err(error);
|
||||
},
|
||||
enabled: Boolean(workspaceId),
|
||||
refetchInterval: (query) => pickQueueRefetchInterval(query.state.data),
|
||||
refetchInterval: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -279,21 +263,7 @@ export function useSearchPeer(workspaceId: string, peerId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export type ReasoningLevel = "minimal" | "low" | "medium" | "high" | "max";
|
||||
|
||||
export const REASONING_LEVELS: readonly ReasoningLevel[] = [
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"max",
|
||||
] as const;
|
||||
|
||||
export function useChat(
|
||||
workspaceId: string,
|
||||
peerId: string,
|
||||
reasoningLevel: ReasoningLevel = "low",
|
||||
) {
|
||||
export function useChat(workspaceId: string, peerId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (message: string) => {
|
||||
@@ -301,7 +271,7 @@ export function useChat(
|
||||
"/v3/workspaces/{workspace_id}/peers/{peer_id}/chat",
|
||||
{
|
||||
params: { path: { workspace_id: workspaceId, peer_id: peerId } },
|
||||
body: { query: message, stream: false, reasoning_level: reasoningLevel },
|
||||
body: { query: message, stream: false, reasoning_level: "low" },
|
||||
},
|
||||
);
|
||||
return data ?? err(error);
|
||||
@@ -325,7 +295,7 @@ export function useSessions(workspaceId: string, page = 1, pageSize = 20) {
|
||||
path: { workspace_id: workspaceId },
|
||||
query: { page, page_size: pageSize },
|
||||
},
|
||||
body: {},
|
||||
body: { reverse: true },
|
||||
},
|
||||
);
|
||||
return data ?? err(error);
|
||||
@@ -720,50 +690,6 @@ export function useDeleteConclusion(workspaceId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Dreams ───────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Dreams are synthetic groupings of conclusions: bursts produced by a single
|
||||
// dream run for one (observer, observed) pair. We fetch a generous batch of
|
||||
// conclusions and let the UI cluster them via `clusterConclusionsIntoDreams`.
|
||||
|
||||
const DREAM_FETCH_PAGE_SIZE = 100;
|
||||
const DREAM_MAX_PAGES = 4;
|
||||
|
||||
export function useDreams(
|
||||
workspaceId: string,
|
||||
filters: Record<string, unknown> = {},
|
||||
limit = DREAM_FETCH_PAGE_SIZE * DREAM_MAX_PAGES,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: QK.dreams(workspaceId, filters, limit),
|
||||
queryFn: async () => {
|
||||
const collected: unknown[] = [];
|
||||
const pageSize = Math.min(DREAM_FETCH_PAGE_SIZE, limit);
|
||||
let page = 1;
|
||||
while (collected.length < limit) {
|
||||
const { data, error } = await client.current.POST(
|
||||
"/v3/workspaces/{workspace_id}/conclusions/list",
|
||||
{
|
||||
params: {
|
||||
path: { workspace_id: workspaceId },
|
||||
query: { page, page_size: pageSize, reverse: false },
|
||||
},
|
||||
body: filters,
|
||||
},
|
||||
);
|
||||
if (error) err(error);
|
||||
const items = (data as { items?: unknown[] } | undefined)?.items ?? [];
|
||||
const totalPages = (data as { pages?: number } | undefined)?.pages ?? 1;
|
||||
collected.push(...items);
|
||||
if (items.length === 0 || page >= totalPages || page >= DREAM_MAX_PAGES) break;
|
||||
page++;
|
||||
}
|
||||
return collected.slice(0, limit);
|
||||
},
|
||||
enabled: Boolean(workspaceId),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Webhooks ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useWebhooks(workspaceId: string) {
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import createClient from "openapi-fetch";
|
||||
import type { Instance } from "@/lib/config";
|
||||
import { httpFetch } from "@/lib/http";
|
||||
import type { paths } from "./schema.d.ts";
|
||||
|
||||
export type ScopedClient = ReturnType<typeof createClient<paths>>;
|
||||
|
||||
/**
|
||||
* Create an openapi-fetch client bound to a specific instance. Use for views
|
||||
* that need to query non-active instances (e.g. side-by-side comparison).
|
||||
* For single-instance access, prefer `client.current` which tracks the active
|
||||
* instance via localStorage.
|
||||
*/
|
||||
export function createScopedClient(instance: Instance): ScopedClient {
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (instance.token) headers.Authorization = `Bearer ${instance.token}`;
|
||||
return createClient<paths>({ baseUrl: instance.baseUrl, headers, fetch: httpFetch });
|
||||
}
|
||||
@@ -209,7 +209,7 @@ export function Dashboard() {
|
||||
<Activity className="w-4 h-4" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
||||
<SectionHeading className="mb-0">Queue Status</SectionHeading>
|
||||
<span className="text-xs ml-1" style={{ color: "var(--text-4)" }}>
|
||||
all workspaces · live polling
|
||||
all workspaces · updates every 10s
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { ChevronRight, Eye, Lightbulb, X } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { TimestampChip } from "@/components/shared/TimestampChip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Body, Caption, MonoCaption, Muted, SectionHeading } from "@/components/ui/typography";
|
||||
import { useDemo } from "@/hooks/useDemo";
|
||||
import { COLOR } from "@/lib/constants";
|
||||
import {
|
||||
buildPremiseIndex,
|
||||
type ConclusionType,
|
||||
type Dream,
|
||||
dreamCounts,
|
||||
type ExtendedConclusion,
|
||||
expandPremiseTree,
|
||||
inferConclusionType,
|
||||
type PremiseNode,
|
||||
} from "@/lib/dreams";
|
||||
import { ConclusionTypeBadge, PremiseTree } from "./PremiseTree";
|
||||
|
||||
const COLUMNS: Array<{ type: ConclusionType; label: string; description: string }> = [
|
||||
{
|
||||
type: "explicit",
|
||||
label: "Explicit",
|
||||
description: "Surface observations pulled directly from messages",
|
||||
},
|
||||
{
|
||||
type: "deductive",
|
||||
label: "Deductive",
|
||||
description: "Logical consequences of explicit observations",
|
||||
},
|
||||
{
|
||||
type: "inductive",
|
||||
label: "Inductive",
|
||||
description: "Generalized patterns inferred from deductives",
|
||||
},
|
||||
];
|
||||
|
||||
interface DreamDetailProps {
|
||||
dream: Dream;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function DreamDetail({ dream, onClose }: DreamDetailProps) {
|
||||
const { mask } = useDemo();
|
||||
const counts = useMemo(() => dreamCounts(dream), [dream]);
|
||||
const index = useMemo(() => buildPremiseIndex(dream.conclusions), [dream]);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const buckets: Record<ConclusionType, ExtendedConclusion[]> = {
|
||||
explicit: [],
|
||||
deductive: [],
|
||||
inductive: [],
|
||||
};
|
||||
for (const c of dream.conclusions) {
|
||||
buckets[inferConclusionType(c)].push(c);
|
||||
}
|
||||
return buckets;
|
||||
}, [dream]);
|
||||
|
||||
return (
|
||||
<motion.section
|
||||
key={dream.id}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={{ duration: 0.18 }}
|
||||
className="rounded-2xl p-5"
|
||||
style={{
|
||||
background: "var(--bg-2)",
|
||||
border: `1px solid ${COLOR.accentBorder}`,
|
||||
}}
|
||||
>
|
||||
<header className="flex items-start gap-3 mb-5">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap mb-1">
|
||||
<Lightbulb className="w-4 h-4" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
||||
<SectionHeading className="mb-0">Dream detail</SectionHeading>
|
||||
<TimestampChip value={dream.latestIso.replace("T", " ").replace(/\.\d+Z?$/, "")} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap text-xs">
|
||||
<Eye className="w-3 h-3" style={{ color: "var(--text-4)" }} strokeWidth={1.5} />
|
||||
<MonoCaption>{mask(dream.observer_id)}</MonoCaption>
|
||||
{dream.observed_id && (
|
||||
<>
|
||||
<ChevronRight
|
||||
className="w-3 h-3"
|
||||
style={{ color: "var(--text-4)" }}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<MonoCaption>{mask(dream.observed_id)}</MonoCaption>
|
||||
</>
|
||||
)}
|
||||
<span className="mx-1.5" style={{ color: "var(--text-4)" }}>
|
||||
·
|
||||
</span>
|
||||
<Caption>
|
||||
{counts.total} conclusion{counts.total === 1 ? "" : "s"}
|
||||
</Caption>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={onClose} aria-label="Close dream detail">
|
||||
<X className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
{COLUMNS.map((col) => (
|
||||
<ColumnPanel
|
||||
key={col.type}
|
||||
type={col.type}
|
||||
label={col.label}
|
||||
description={col.description}
|
||||
conclusions={grouped[col.type]}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</motion.section>
|
||||
);
|
||||
}
|
||||
|
||||
interface ColumnPanelProps {
|
||||
type: ConclusionType;
|
||||
label: string;
|
||||
description: string;
|
||||
conclusions: ExtendedConclusion[];
|
||||
index: Map<string, ExtendedConclusion>;
|
||||
}
|
||||
|
||||
function ColumnPanel({ type, label, description, conclusions, index }: ColumnPanelProps) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl p-4 flex flex-col"
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--border)",
|
||||
minHeight: "8rem",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<ConclusionTypeBadge type={type} />
|
||||
<span className="text-sm font-semibold" style={{ color: "var(--text-1)" }}>
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
className="ml-auto text-xs font-mono px-1.5 py-0.5 rounded-full"
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
color: "var(--text-3)",
|
||||
border: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
{conclusions.length}
|
||||
</span>
|
||||
</div>
|
||||
<Muted className="text-[11px] mb-3">{description}</Muted>
|
||||
|
||||
{conclusions.length === 0 ? (
|
||||
<Caption className="italic">No {label.toLowerCase()} conclusions in this dream.</Caption>
|
||||
) : (
|
||||
<ul className="space-y-2.5">
|
||||
{conclusions.map((c) => (
|
||||
<li key={c.id}>
|
||||
<ConclusionCard conclusion={c} index={index} expandable={type === "inductive"} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ConclusionCardProps {
|
||||
conclusion: ExtendedConclusion;
|
||||
index: Map<string, ExtendedConclusion>;
|
||||
expandable: boolean;
|
||||
}
|
||||
|
||||
function ConclusionCard({ conclusion, index, expandable }: ConclusionCardProps) {
|
||||
const { mask } = useDemo();
|
||||
const [open, setOpen] = useState(false);
|
||||
const tree = useMemo<PremiseNode | null>(
|
||||
() => (open ? expandPremiseTree(conclusion.id, index) : null),
|
||||
[open, conclusion.id, index],
|
||||
);
|
||||
const hasPremises = Boolean(
|
||||
(conclusion.reasoning_tree?.premises?.length ?? 0) > 0 ||
|
||||
(conclusion.premises?.length ?? 0) > 0,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg p-3 text-xs"
|
||||
style={{ background: "var(--bg-2)", border: "1px solid var(--border)" }}
|
||||
>
|
||||
<Body className="text-xs whitespace-pre-wrap leading-snug mb-2">
|
||||
{mask(conclusion.content)}
|
||||
</Body>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<MonoCaption className="truncate">{mask(conclusion.id)}</MonoCaption>
|
||||
{expandable && hasPremises && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex items-center gap-1 text-[11px] font-medium px-2 py-0.5 rounded transition-colors"
|
||||
style={{
|
||||
background: open ? COLOR.accentDim : "transparent",
|
||||
color: open ? "var(--accent-text)" : "var(--text-3)",
|
||||
border: `1px solid ${open ? COLOR.accentBorder : "var(--border)"}`,
|
||||
}}
|
||||
aria-expanded={open}
|
||||
>
|
||||
<ChevronRight
|
||||
className="w-3 h-3 transition-transform"
|
||||
style={{ transform: open ? "rotate(90deg)" : undefined }}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
{open ? "Hide" : "Show"} premises
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && tree && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.18 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="mt-3 pt-3" style={{ borderTop: `1px solid ${COLOR.accentBorder}` }}>
|
||||
<Caption className="mb-2 block">Reasoning chain</Caption>
|
||||
<PremiseTree root={tree} />
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
import { useParams } from "@tanstack/react-router";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { ChevronRight, Eye, Moon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useDreams } from "@/api/queries";
|
||||
import { Breadcrumb } from "@/components/layout/Breadcrumb";
|
||||
import { EmptyState } from "@/components/shared/EmptyState";
|
||||
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
||||
import { Skeleton } from "@/components/shared/Skeleton";
|
||||
import { TimestampChip } from "@/components/shared/TimestampChip";
|
||||
import { Caption, MonoCaption, Muted, PageTitle } from "@/components/ui/typography";
|
||||
import { useDemo } from "@/hooks/useDemo";
|
||||
import { COLOR } from "@/lib/constants";
|
||||
import {
|
||||
clusterConclusionsIntoDreams,
|
||||
type Dream,
|
||||
dreamCounts,
|
||||
type ExtendedConclusion,
|
||||
} from "@/lib/dreams";
|
||||
import { DreamDetail } from "./DreamDetail";
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 8 },
|
||||
show: (i: number) => ({
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { delay: i * 0.03, type: "spring" as const, stiffness: 300, damping: 25 },
|
||||
}),
|
||||
};
|
||||
|
||||
export function DreamList() {
|
||||
const { workspaceId } = useParams({ strict: false }) as { workspaceId: string };
|
||||
const { data, isLoading, error } = useDreams(workspaceId);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
const dreams = useMemo<Dream[]>(() => {
|
||||
const conclusions = (data as ExtendedConclusion[] | undefined) ?? [];
|
||||
return clusterConclusionsIntoDreams(conclusions);
|
||||
}, [data]);
|
||||
|
||||
const selected = useMemo(
|
||||
() => (selectedId ? (dreams.find((d) => d.id === selectedId) ?? null) : null),
|
||||
[dreams, selectedId],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }} className="mb-8">
|
||||
<Breadcrumb />
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Moon className="w-5 h-5" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
||||
<PageTitle>Dreams</PageTitle>
|
||||
{dreams.length > 0 && (
|
||||
<span
|
||||
className="ml-1 text-xs font-mono px-2 py-0.5 rounded-full"
|
||||
style={{
|
||||
background: COLOR.accentSubtle,
|
||||
color: COLOR.accentText,
|
||||
border: `1px solid ${COLOR.accentBorder}`,
|
||||
}}
|
||||
>
|
||||
{dreams.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Muted className="mt-0.5">
|
||||
Each run produces explicit, deductive, and inductive conclusions for one peer pair.
|
||||
</Muted>
|
||||
</motion.div>
|
||||
|
||||
<ErrorAlert error={error instanceof Error ? error : null} />
|
||||
|
||||
{isLoading && <DreamsSkeleton />}
|
||||
|
||||
{!isLoading && dreams.length === 0 && !error && (
|
||||
<EmptyState
|
||||
icon={Moon}
|
||||
title="No dream runs yet"
|
||||
description="Trigger a dream from a workspace to see its conclusion stream here."
|
||||
/>
|
||||
)}
|
||||
|
||||
<AnimatePresence>
|
||||
{selected && (
|
||||
<motion.div
|
||||
key="detail"
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.22, ease: "easeInOut" }}
|
||||
className="overflow-hidden mb-6"
|
||||
>
|
||||
<DreamDetail dream={selected} onClose={() => setSelectedId(null)} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{dreams.length > 0 && (
|
||||
<ul className="space-y-2.5">
|
||||
{dreams.map((d, i) => (
|
||||
<motion.li
|
||||
key={d.id}
|
||||
custom={i}
|
||||
variants={itemVariants}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
>
|
||||
<DreamRow
|
||||
dream={d}
|
||||
active={d.id === selectedId}
|
||||
onSelect={() => setSelectedId(d.id === selectedId ? null : d.id)}
|
||||
/>
|
||||
</motion.li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DreamRowProps {
|
||||
dream: Dream;
|
||||
active: boolean;
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
function DreamRow({ dream, active, onSelect }: DreamRowProps) {
|
||||
const { mask } = useDemo();
|
||||
const counts = useMemo(() => dreamCounts(dream), [dream]);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
aria-pressed={active}
|
||||
className="group w-full text-left rounded-xl p-4 transition-colors"
|
||||
style={{
|
||||
background: active ? COLOR.accentDim : "var(--surface)",
|
||||
border: `1px solid ${active ? COLOR.accentBorder : "var(--border)"}`,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<TimestampChip value={dream.latestIso.replace("T", " ").replace(/\.\d+Z?$/, "")} />
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Eye className="w-3 h-3" style={{ color: "var(--text-4)" }} strokeWidth={1.5} />
|
||||
<MonoCaption>{mask(dream.observer_id)}</MonoCaption>
|
||||
</div>
|
||||
{dream.observed_id && (
|
||||
<div className="flex items-center gap-1">
|
||||
<ChevronRight className="w-3 h-3" style={{ color: "var(--text-4)" }} strokeWidth={2} />
|
||||
<MonoCaption>{mask(dream.observed_id)}</MonoCaption>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<CountChip label="explicit" value={counts.explicit} kind="neutral" />
|
||||
<CountChip label="deductive" value={counts.deductive} kind="accent" />
|
||||
<CountChip label="inductive" value={counts.inductive} kind="warning" />
|
||||
<ChevronRight
|
||||
className="w-4 h-4 ml-1 transition-transform"
|
||||
style={{
|
||||
color: active ? "var(--accent-text)" : "var(--text-4)",
|
||||
transform: active ? "rotate(90deg)" : undefined,
|
||||
}}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{dream.earliestIso !== dream.latestIso && (
|
||||
<Caption className="mt-2 block">
|
||||
Span: {formatSpan(dream.latestMs - dream.earliestMs)}
|
||||
</Caption>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
type ChipKind = "neutral" | "accent" | "warning";
|
||||
|
||||
function CountChip({ label, value, kind }: { label: string; value: number; kind: ChipKind }) {
|
||||
const palette: Record<ChipKind, { bg: string; fg: string; border: string }> = {
|
||||
neutral: {
|
||||
bg: "rgba(148,163,184,0.10)",
|
||||
fg: "var(--text-2)",
|
||||
border: "rgba(148,163,184,0.25)",
|
||||
},
|
||||
accent: { bg: COLOR.accentSubtle, fg: COLOR.accentText, border: COLOR.accentBorder },
|
||||
warning: { bg: "rgba(245,158,11,0.10)", fg: COLOR.warning, border: COLOR.warningBorder },
|
||||
};
|
||||
const cfg = palette[kind];
|
||||
const dim = value === 0;
|
||||
return (
|
||||
<span
|
||||
title={`${value} ${label}`}
|
||||
className="inline-flex items-center gap-1 text-[11px] font-mono px-1.5 py-0.5 rounded"
|
||||
style={{
|
||||
background: dim ? "transparent" : cfg.bg,
|
||||
color: dim ? "var(--text-4)" : cfg.fg,
|
||||
border: `1px solid ${dim ? "var(--border)" : cfg.border}`,
|
||||
opacity: dim ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<span>{value}</span>
|
||||
<span className="hidden sm:inline"> {label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function formatSpan(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
const s = Math.round(ms / 1000);
|
||||
if (s < 60) return `${s}s`;
|
||||
const m = Math.floor(s / 60);
|
||||
const rem = s % 60;
|
||||
return rem === 0 ? `${m}m` : `${m}m ${rem}s`;
|
||||
}
|
||||
|
||||
function DreamsSkeleton() {
|
||||
return (
|
||||
<div className="space-y-2.5" aria-hidden="true">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded-xl p-4"
|
||||
style={{ background: "var(--surface)", border: "1px solid var(--border)" }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-6 w-28 rounded-full" />
|
||||
<Skeleton className="h-3 w-20 rounded" />
|
||||
<Skeleton className="h-3 w-20 rounded" />
|
||||
<Skeleton className="ml-auto h-5 w-12 rounded" />
|
||||
<Skeleton className="h-5 w-12 rounded" />
|
||||
<Skeleton className="h-5 w-12 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
import { ChevronRight, CornerDownRight, RefreshCcw } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Caption, MonoCaption, Muted } from "@/components/ui/typography";
|
||||
import { useDemo } from "@/hooks/useDemo";
|
||||
import { COLOR } from "@/lib/constants";
|
||||
import { type ConclusionType, inferConclusionType, type PremiseNode } from "@/lib/dreams";
|
||||
|
||||
const TYPE_BADGE: Record<
|
||||
ConclusionType,
|
||||
{ label: string; bg: string; fg: string; border: string }
|
||||
> = {
|
||||
explicit: {
|
||||
label: "explicit",
|
||||
bg: "rgba(148,163,184,0.10)",
|
||||
fg: "var(--text-2)",
|
||||
border: "rgba(148,163,184,0.25)",
|
||||
},
|
||||
deductive: {
|
||||
label: "deductive",
|
||||
bg: COLOR.accentSubtle,
|
||||
fg: "var(--accent-text)",
|
||||
border: COLOR.accentBorder,
|
||||
},
|
||||
inductive: {
|
||||
label: "inductive",
|
||||
bg: "rgba(245,158,11,0.10)",
|
||||
fg: COLOR.warning,
|
||||
border: COLOR.warningBorder,
|
||||
},
|
||||
};
|
||||
|
||||
export function ConclusionTypeBadge({ type }: { type: ConclusionType }) {
|
||||
const cfg = TYPE_BADGE[type];
|
||||
return (
|
||||
<span
|
||||
className="text-[10px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wide"
|
||||
style={{ background: cfg.bg, color: cfg.fg, border: `1px solid ${cfg.border}` }}
|
||||
>
|
||||
{cfg.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface PremiseTreeProps {
|
||||
root: PremiseNode;
|
||||
}
|
||||
|
||||
export function PremiseTree({ root }: PremiseTreeProps) {
|
||||
if (root.children.length === 0) {
|
||||
return <Muted className="italic">No upstream premises recorded for this conclusion.</Muted>;
|
||||
}
|
||||
return (
|
||||
<ul className="space-y-1.5" aria-label="Premise tree">
|
||||
{root.children.map((child, i) => (
|
||||
<PremiseTreeNode key={`${child.conclusionId}-${i}`} node={child} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function PremiseTreeNode({ node }: { node: PremiseNode }) {
|
||||
const { mask } = useDemo();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const hasChildren = node.children.length > 0;
|
||||
const conclusion = node.conclusion;
|
||||
const type: ConclusionType | null = conclusion ? inferConclusionType(conclusion) : null;
|
||||
|
||||
const indent = Math.min(node.depth, 4) * 12;
|
||||
|
||||
return (
|
||||
<li style={{ marginLeft: `${indent}px` }}>
|
||||
<div
|
||||
className="rounded-lg p-2.5 text-xs"
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="mt-0.5 p-0.5 rounded transition-colors"
|
||||
style={{ color: "var(--text-3)" }}
|
||||
aria-expanded={expanded}
|
||||
aria-label={expanded ? "Collapse premises" : "Expand premises"}
|
||||
>
|
||||
<ChevronRight
|
||||
className="w-3 h-3 transition-transform"
|
||||
style={{ transform: expanded ? "rotate(90deg)" : undefined }}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<CornerDownRight
|
||||
className="w-3 h-3 mt-1 shrink-0"
|
||||
style={{ color: "var(--text-4)" }}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 mb-1 flex-wrap">
|
||||
{type && <ConclusionTypeBadge type={type} />}
|
||||
{node.cycle && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-[10px] font-mono px-1.5 py-0.5 rounded"
|
||||
style={{
|
||||
background: COLOR.warningDim,
|
||||
color: COLOR.warning,
|
||||
border: `1px solid ${COLOR.warningBorder}`,
|
||||
}}
|
||||
title="Cycle in reasoning tree — already shown upstream"
|
||||
>
|
||||
<RefreshCcw className="w-2.5 h-2.5" strokeWidth={1.5} />
|
||||
cycle
|
||||
</span>
|
||||
)}
|
||||
<MonoCaption className="truncate">{mask(node.conclusionId)}</MonoCaption>
|
||||
</div>
|
||||
{conclusion ? (
|
||||
<p className="leading-snug" style={{ color: "var(--text-2)" }}>
|
||||
{mask(conclusion.content)}
|
||||
</p>
|
||||
) : (
|
||||
<Caption className="italic">
|
||||
Premise not in current page — fetch more conclusions to expand.
|
||||
</Caption>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{expanded && hasChildren && (
|
||||
<ul className="mt-1.5 space-y-1.5">
|
||||
{node.children.map((child, i) => (
|
||||
<PremiseTreeNode key={`${child.conclusionId}-${i}`} node={child} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,6 @@ const SECTION_LABELS: Record<string, string> = {
|
||||
peers: "Peers",
|
||||
sessions: "Sessions",
|
||||
conclusions: "Conclusions",
|
||||
dreams: "Dreams",
|
||||
webhooks: "Webhooks",
|
||||
chat: "Chat",
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Link, useMatchRoute } from "@tanstack/react-router";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import {
|
||||
@@ -9,12 +8,10 @@ import {
|
||||
ChevronsUpDown,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Layers,
|
||||
LayoutDashboard,
|
||||
Lightbulb,
|
||||
MessageSquare,
|
||||
Moon,
|
||||
MoonStar,
|
||||
Settings,
|
||||
Sun,
|
||||
Users,
|
||||
@@ -32,7 +29,6 @@ import { COLOR } from "@/lib/constants";
|
||||
const TOP_NAV = [
|
||||
{ to: "/" as const, label: "Dashboard", icon: LayoutDashboard, exact: true },
|
||||
{ to: "/workspaces" as const, label: "Workspaces", icon: Boxes, exact: false },
|
||||
{ to: "/seed-kits" as const, label: "Seed Kits", icon: Layers, exact: false },
|
||||
{ to: "/settings" as const, label: "Settings", icon: Settings, exact: false },
|
||||
];
|
||||
|
||||
@@ -40,46 +36,9 @@ const WORKSPACE_SECTIONS = [
|
||||
{ label: "Peers", icon: Users, section: "peers" },
|
||||
{ label: "Sessions", icon: MessageSquare, section: "sessions" },
|
||||
{ label: "Conclusions", icon: Lightbulb, section: "conclusions" },
|
||||
{ label: "Dreams", icon: MoonStar, section: "dreams" },
|
||||
{ label: "Webhooks", icon: Webhook, section: "webhooks" },
|
||||
] as const;
|
||||
|
||||
function formatLastUpdated(value: number | null, now = Date.now()): string {
|
||||
if (!value) return "Not updated yet";
|
||||
const elapsed = Math.max(0, now - value);
|
||||
if (elapsed < 10_000) return "Updated just now";
|
||||
if (elapsed < 60_000) return `Updated ${Math.floor(elapsed / 1000)}s ago`;
|
||||
if (elapsed < 3_600_000) return `Updated ${Math.floor(elapsed / 60_000)}m ago`;
|
||||
return `Updated ${Math.floor(elapsed / 3_600_000)}h ago`;
|
||||
}
|
||||
|
||||
function useLastDataUpdate(): string {
|
||||
const queryClient = useQueryClient();
|
||||
const [updatedAt, setUpdatedAt] = useState<number | null>(null);
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
function refresh() {
|
||||
setNow(Date.now());
|
||||
const latest = queryClient
|
||||
.getQueryCache()
|
||||
.getAll()
|
||||
.reduce((max, query) => Math.max(max, query.state.dataUpdatedAt || 0), 0);
|
||||
setUpdatedAt(latest || null);
|
||||
}
|
||||
|
||||
refresh();
|
||||
const unsubscribe = queryClient.getQueryCache().subscribe(refresh);
|
||||
const interval = window.setInterval(refresh, 30_000);
|
||||
return () => {
|
||||
unsubscribe();
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [queryClient]);
|
||||
|
||||
return formatLastUpdated(updatedAt, now);
|
||||
}
|
||||
|
||||
export function Sidebar() {
|
||||
const matchRoute = useMatchRoute();
|
||||
const { instances, active, activate } = useInstances();
|
||||
@@ -87,7 +46,6 @@ export function Sidebar() {
|
||||
const { demo, toggle: toggleDemo, mask } = useDemo();
|
||||
const { showMetadata, toggle: toggleMeta } = useMetadata();
|
||||
const { data: health } = useHealthStatus();
|
||||
const lastUpdated = useLastDataUpdate();
|
||||
const [switcherOpen, setSwitcherOpen] = useState(false);
|
||||
const switcherRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
@@ -163,9 +121,6 @@ export function Sidebar() {
|
||||
<p className="text-xs font-mono truncate" style={{ color: "var(--text-4)" }}>
|
||||
{mask(active.baseUrl.replace(/^https?:\/\//, ""))}
|
||||
</p>
|
||||
<p className="text-[10px] font-mono truncate" style={{ color: "var(--text-4)" }}>
|
||||
{lastUpdated}
|
||||
</p>
|
||||
</div>
|
||||
{instances.length > 1 && (
|
||||
<ChevronsUpDown
|
||||
|
||||
@@ -1,16 +1,6 @@
|
||||
import { useNavigate, useParams } from "@tanstack/react-router";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import {
|
||||
Eye,
|
||||
EyeOff,
|
||||
FlaskConical,
|
||||
MessageCircle,
|
||||
Save,
|
||||
Search,
|
||||
User,
|
||||
Users,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Eye, EyeOff, MessageCircle, Save, Search, User, Users, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
usePeer,
|
||||
@@ -108,35 +98,19 @@ export function PeerDetail() {
|
||||
</div>
|
||||
<Body className="leading-none">Peer identity & memory</Body>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
variant="surface"
|
||||
onClick={() =>
|
||||
navigate({
|
||||
to: "/workspaces/$workspaceId/peers/$peerId/playground",
|
||||
params: { workspaceId, peerId } as never,
|
||||
})
|
||||
}
|
||||
className="rounded-xl"
|
||||
title="Compare reasoning levels side-by-side"
|
||||
>
|
||||
<FlaskConical className="w-4 h-4" strokeWidth={1.5} />
|
||||
Playground
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() =>
|
||||
navigate({
|
||||
to: "/workspaces/$workspaceId/peers/$peerId/chat",
|
||||
params: { workspaceId, peerId } as never,
|
||||
})
|
||||
}
|
||||
className="rounded-xl"
|
||||
>
|
||||
<MessageCircle className="w-4 h-4" strokeWidth={1.5} />
|
||||
Chat
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() =>
|
||||
navigate({
|
||||
to: "/workspaces/$workspaceId/peers/$peerId/chat",
|
||||
params: { workspaceId, peerId } as never,
|
||||
})
|
||||
}
|
||||
className="shrink-0 rounded-xl"
|
||||
>
|
||||
<MessageCircle className="w-4 h-4" strokeWidth={1.5} />
|
||||
Chat
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
|
||||
@@ -1,376 +0,0 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Link, useParams } from "@tanstack/react-router";
|
||||
import { motion } from "framer-motion";
|
||||
import { FlaskConical, Play } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { client } from "@/api/client";
|
||||
import { REASONING_LEVELS, type ReasoningLevel } from "@/api/queries";
|
||||
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/input";
|
||||
import { SectionHeading } from "@/components/ui/typography";
|
||||
import { useDemo } from "@/hooks/useDemo";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ColumnState {
|
||||
status: "idle" | "pending" | "success" | "error";
|
||||
content: string | null;
|
||||
error: string | null;
|
||||
startedAt: number | null;
|
||||
endedAt: number | null;
|
||||
}
|
||||
|
||||
type RunResult = { ok: true; content: string | null } | { ok: false; error: string };
|
||||
|
||||
const IDLE: ColumnState = {
|
||||
status: "idle",
|
||||
content: null,
|
||||
error: null,
|
||||
startedAt: null,
|
||||
endedAt: null,
|
||||
};
|
||||
|
||||
// ─── Pure helpers (exported for testing) ─────────────────────────────────────
|
||||
|
||||
export function buildInitialColumns(): Record<ReasoningLevel, ColumnState> {
|
||||
return Object.fromEntries(REASONING_LEVELS.map((l) => [l, { ...IDLE }])) as Record<
|
||||
ReasoningLevel,
|
||||
ColumnState
|
||||
>;
|
||||
}
|
||||
|
||||
export function latencyMs(col: ColumnState): number | null {
|
||||
if (col.startedAt == null || col.endedAt == null) return null;
|
||||
return col.endedAt - col.startedAt;
|
||||
}
|
||||
|
||||
// ─── Run-fanout (exported for testing) ───────────────────────────────────────
|
||||
|
||||
export interface FanoutDeps {
|
||||
now: () => number;
|
||||
runOne: (level: ReasoningLevel, query: string) => Promise<RunResult>;
|
||||
onStart: (level: ReasoningLevel, startedAt: number) => void;
|
||||
onEnd: (level: ReasoningLevel, endedAt: number, result: RunResult) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires the same query at every selected level concurrently. Returns when all
|
||||
* settle. Per-column timing is captured via onStart/onEnd so React state updates
|
||||
* stay reflective of the network race, not the await order.
|
||||
*/
|
||||
export async function fanoutQuery(
|
||||
levels: readonly ReasoningLevel[],
|
||||
query: string,
|
||||
deps: FanoutDeps,
|
||||
): Promise<void> {
|
||||
await Promise.all(
|
||||
levels.map(async (level) => {
|
||||
const startedAt = deps.now();
|
||||
deps.onStart(level, startedAt);
|
||||
try {
|
||||
const result = await deps.runOne(level, query);
|
||||
deps.onEnd(level, deps.now(), result);
|
||||
} catch (e) {
|
||||
deps.onEnd(level, deps.now(), {
|
||||
ok: false,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function DialecticPlayground() {
|
||||
const { mask } = useDemo();
|
||||
const { workspaceId, peerId } = useParams({ strict: false }) as {
|
||||
workspaceId: string;
|
||||
peerId: string;
|
||||
};
|
||||
const qc = useQueryClient();
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [selected, setSelected] = useState<Record<ReasoningLevel, boolean>>(
|
||||
() =>
|
||||
Object.fromEntries(REASONING_LEVELS.map((l) => [l, true])) as Record<ReasoningLevel, boolean>,
|
||||
);
|
||||
const [columns, setColumns] = useState<Record<ReasoningLevel, ColumnState>>(buildInitialColumns);
|
||||
|
||||
const anyPending = useMemo(
|
||||
() => REASONING_LEVELS.some((l) => columns[l].status === "pending"),
|
||||
[columns],
|
||||
);
|
||||
const selectedLevels = useMemo(() => REASONING_LEVELS.filter((l) => selected[l]), [selected]);
|
||||
|
||||
const runOne = useCallback(
|
||||
async (level: ReasoningLevel, q: string): Promise<RunResult> => {
|
||||
const { data, error } = await client.current.POST(
|
||||
"/v3/workspaces/{workspace_id}/peers/{peer_id}/chat",
|
||||
{
|
||||
params: { path: { workspace_id: workspaceId, peer_id: peerId } },
|
||||
body: { query: q, stream: false, reasoning_level: level },
|
||||
},
|
||||
);
|
||||
if (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: typeof error === "object" ? JSON.stringify(error) : String(error),
|
||||
};
|
||||
}
|
||||
const content = (data as { content?: string | null } | undefined)?.content ?? null;
|
||||
return { ok: true, content };
|
||||
},
|
||||
[workspaceId, peerId],
|
||||
);
|
||||
|
||||
const handleRun = useCallback(async () => {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed || anyPending || selectedLevels.length === 0) return;
|
||||
|
||||
setColumns((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const l of selectedLevels) next[l] = { ...IDLE, status: "pending" };
|
||||
return next;
|
||||
});
|
||||
|
||||
await fanoutQuery(selectedLevels, trimmed, {
|
||||
now: () => performance.now(),
|
||||
runOne,
|
||||
onStart: (level, startedAt) => {
|
||||
setColumns((prev) => ({
|
||||
...prev,
|
||||
[level]: { ...prev[level], status: "pending", startedAt, endedAt: null },
|
||||
}));
|
||||
},
|
||||
onEnd: (level, endedAt, result) => {
|
||||
setColumns((prev) => ({
|
||||
...prev,
|
||||
[level]: {
|
||||
...prev[level],
|
||||
endedAt,
|
||||
status: result.ok ? "success" : "error",
|
||||
content: result.ok ? result.content : null,
|
||||
error: result.ok ? null : result.error,
|
||||
},
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
qc.invalidateQueries({ queryKey: ["peer-context", workspaceId, peerId] });
|
||||
}, [anyPending, peerId, qc, query, runOne, selectedLevels, workspaceId]);
|
||||
|
||||
function toggleLevel(level: ReasoningLevel) {
|
||||
setSelected((prev) => ({ ...prev, [level]: !prev[level] }));
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if ((e.key === "Enter" && (e.metaKey || e.ctrlKey)) || (e.key === "Enter" && !e.shiftKey)) {
|
||||
e.preventDefault();
|
||||
handleRun();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen" style={{ background: "var(--bg)" }}>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="shrink-0 px-6 py-4"
|
||||
style={{ borderBottom: "1px solid var(--border)", background: "var(--bg-2)" }}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-xs mb-1" style={{ color: "var(--text-3)" }}>
|
||||
<Link
|
||||
to="/workspaces/$workspaceId/peers/$peerId"
|
||||
params={{ workspaceId, peerId } as never}
|
||||
className="hover:underline font-mono"
|
||||
>
|
||||
{mask(peerId)}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span>Playground</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<FlaskConical className="w-4 h-4" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
||||
<SectionHeading as="h1" className="mb-0">
|
||||
Dialectic reasoning playground
|
||||
</SectionHeading>
|
||||
</div>
|
||||
<p className="text-xs mt-0.5" style={{ color: "var(--text-3)" }}>
|
||||
Fire the same query at every reasoning level in parallel — compare answers and latency.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Query input */}
|
||||
<div
|
||||
className="shrink-0 px-4 sm:px-6 py-4"
|
||||
style={{ borderBottom: "1px solid var(--border)", background: "var(--bg-2)" }}
|
||||
>
|
||||
<div className="flex gap-3 max-w-5xl mx-auto items-end">
|
||||
<Textarea
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask once, compare five answers… (Enter to run, Shift+Enter for newline)"
|
||||
rows={2}
|
||||
className="flex-1 resize-none"
|
||||
aria-label="Query"
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleRun}
|
||||
disabled={!query.trim() || anyPending || selectedLevels.length === 0}
|
||||
className="self-end mb-0.5"
|
||||
aria-label="Run selected levels"
|
||||
>
|
||||
{anyPending ? (
|
||||
<LoadingSpinner size="sm" />
|
||||
) : (
|
||||
<Play className="w-4 h-4" strokeWidth={1.5} />
|
||||
)}
|
||||
<span className="hidden sm:block">
|
||||
{selectedLevels.length === REASONING_LEVELS.length
|
||||
? "Run all levels"
|
||||
: `Run ${selectedLevels.length}`}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-w-5xl mx-auto mt-3 flex flex-wrap gap-2 items-center">
|
||||
<span className="text-xs" style={{ color: "var(--text-3)" }}>
|
||||
Levels:
|
||||
</span>
|
||||
{REASONING_LEVELS.map((level) => (
|
||||
<label
|
||||
key={level}
|
||||
className="inline-flex items-center gap-1.5 text-xs px-2 py-1 rounded-md cursor-pointer transition-colors"
|
||||
style={{
|
||||
background: selected[level] ? "var(--accent-dim)" : "var(--surface)",
|
||||
color: selected[level] ? "var(--accent-text)" : "var(--text-3)",
|
||||
border: `1px solid ${selected[level] ? "var(--accent-border)" : "var(--border)"}`,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected[level]}
|
||||
onChange={() => toggleLevel(level)}
|
||||
className="accent-current"
|
||||
aria-label={`Include ${level}`}
|
||||
/>
|
||||
<span className="font-mono">{level}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 5-column grid */}
|
||||
<div className="flex-1 overflow-auto px-4 sm:px-6 py-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-3 max-w-[1600px] mx-auto">
|
||||
{REASONING_LEVELS.map((level) => (
|
||||
<LevelColumn
|
||||
key={level}
|
||||
level={level}
|
||||
state={columns[level]}
|
||||
included={selected[level]}
|
||||
maskFn={mask}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Per-column subcomponent ─────────────────────────────────────────────────
|
||||
|
||||
interface LevelColumnProps {
|
||||
level: ReasoningLevel;
|
||||
state: ColumnState;
|
||||
included: boolean;
|
||||
maskFn: (s: string) => string;
|
||||
}
|
||||
|
||||
function LevelColumn({ level, state, included, maskFn }: LevelColumnProps) {
|
||||
const ms = latencyMs(state);
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: included ? 1 : 0.45, y: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="flex flex-col rounded-xl min-h-[280px]"
|
||||
style={{
|
||||
background: "var(--bg-2)",
|
||||
border: "1px solid var(--border)",
|
||||
}}
|
||||
data-level={level}
|
||||
data-status={state.status}
|
||||
data-testid={`column-${level}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="px-3 py-2 flex items-center justify-between"
|
||||
style={{ borderBottom: "1px solid var(--border)" }}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="text-xs font-mono font-medium uppercase tracking-wide"
|
||||
style={{ color: "var(--text-1)" }}
|
||||
>
|
||||
{level}
|
||||
</span>
|
||||
</div>
|
||||
{!included && (
|
||||
<span className="text-xs" style={{ color: "var(--text-4)" }}>
|
||||
skipped
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 px-3 py-3 text-sm" style={{ color: "var(--text-2)" }}>
|
||||
{state.status === "idle" && (
|
||||
<p className="text-xs" style={{ color: "var(--text-4)" }}>
|
||||
Awaiting query…
|
||||
</p>
|
||||
)}
|
||||
{state.status === "pending" && (
|
||||
<div className="flex items-center gap-2">
|
||||
<LoadingSpinner size="sm" />
|
||||
<span className="text-xs" style={{ color: "var(--text-3)" }}>
|
||||
Thinking…
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{state.status === "error" && (
|
||||
<p
|
||||
className="text-xs whitespace-pre-wrap"
|
||||
style={{ color: "var(--destructive, #f87171)" }}
|
||||
data-testid={`column-${level}-error`}
|
||||
>
|
||||
Error: {state.error}
|
||||
</p>
|
||||
)}
|
||||
{state.status === "success" && (
|
||||
<p
|
||||
className="whitespace-pre-wrap leading-relaxed"
|
||||
data-testid={`column-${level}-content`}
|
||||
>
|
||||
{maskFn(state.content ?? "(empty response)")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div
|
||||
className="px-3 py-2 flex items-center justify-between text-xs"
|
||||
style={{ borderTop: "1px solid var(--border)", color: "var(--text-4)" }}
|
||||
>
|
||||
<span data-testid={`column-${level}-latency`}>
|
||||
{ms != null ? `${Math.round(ms)} ms` : "—"}
|
||||
</span>
|
||||
<span>
|
||||
{state.content != null ? `${state.content.length.toLocaleString()} chars` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1,311 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Check, Loader2, Sparkles, X } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useScopedPeerCard, useScopedPeers } from "@/api/compareQueries";
|
||||
import { createScopedClient } from "@/api/scopedClient";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Body, Caption, Muted } from "@/components/ui/typography";
|
||||
import { useInstances } from "@/hooks/useInstances";
|
||||
import type { Instance } from "@/lib/config";
|
||||
import { mergeCardLines, type SeedKit } from "@/lib/seedKits";
|
||||
|
||||
interface ApplyKitDialogProps {
|
||||
kit: SeedKit | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function err(e: unknown): never {
|
||||
throw new Error(typeof e === "object" ? JSON.stringify(e) : String(e));
|
||||
}
|
||||
|
||||
function useScopedWorkspacesAll(instance: Instance | null) {
|
||||
return useQuery({
|
||||
queryKey: ["seed-kits-workspaces", instance?.id ?? "none"],
|
||||
queryFn: async () => {
|
||||
if (!instance) return [] as Array<{ id: string }>;
|
||||
const client = createScopedClient(instance);
|
||||
const { data, error } = await client.POST("/v3/workspaces/list", {
|
||||
params: { query: { page: 1, page_size: 100 } },
|
||||
body: {},
|
||||
});
|
||||
const payload = data ?? err(error);
|
||||
return ((payload as { items?: Array<{ id: string }> }).items ?? []) as Array<{ id: string }>;
|
||||
},
|
||||
enabled: Boolean(instance),
|
||||
});
|
||||
}
|
||||
|
||||
export function ApplyKitDialog({ kit, open, onClose }: ApplyKitDialogProps) {
|
||||
const { instances, activeId } = useInstances();
|
||||
const [instanceId, setInstanceId] = useState<string | null>(activeId);
|
||||
const [workspaceId, setWorkspaceId] = useState<string | null>(null);
|
||||
const [peerId, setPeerId] = useState<string | null>(null);
|
||||
const [submitState, setSubmitState] = useState<
|
||||
{ kind: "idle" } | { kind: "pending" } | { kind: "ok" } | { kind: "error"; message: string }
|
||||
>({ kind: "idle" });
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setInstanceId(activeId);
|
||||
setWorkspaceId(null);
|
||||
setPeerId(null);
|
||||
setSubmitState({ kind: "idle" });
|
||||
}
|
||||
}, [open, activeId]);
|
||||
|
||||
const instance = instances.find((i) => i.id === instanceId) ?? null;
|
||||
|
||||
const workspaces = useScopedWorkspacesAll(instance);
|
||||
const peers = useScopedPeers(instance as Instance, workspaceId ?? "", 1, 100);
|
||||
const existingCard = useScopedPeerCard(instance as Instance, workspaceId ?? "", peerId ?? "");
|
||||
|
||||
const peerItems = (peers.data as { items?: Array<{ id: string }> } | undefined)?.items ?? [];
|
||||
|
||||
const existingLines = useMemo(() => {
|
||||
const card = existingCard.data as { peer_card?: unknown } | undefined;
|
||||
if (Array.isArray(card?.peer_card)) return card.peer_card as string[];
|
||||
if (typeof card === "string") return [card];
|
||||
return [] as string[];
|
||||
}, [existingCard.data]);
|
||||
|
||||
const mergedLines = useMemo(
|
||||
() => (kit ? mergeCardLines(existingLines, kit.lines) : existingLines),
|
||||
[kit, existingLines],
|
||||
);
|
||||
|
||||
const canApply =
|
||||
kit !== null &&
|
||||
instance !== null &&
|
||||
Boolean(workspaceId) &&
|
||||
Boolean(peerId) &&
|
||||
submitState.kind !== "pending";
|
||||
|
||||
async function handleApply() {
|
||||
if (!kit || !instance || !workspaceId || !peerId) return;
|
||||
setSubmitState({ kind: "pending" });
|
||||
try {
|
||||
const client = createScopedClient(instance);
|
||||
const { error } = await client.PUT("/v3/workspaces/{workspace_id}/peers/{peer_id}/card", {
|
||||
params: { path: { workspace_id: workspaceId, peer_id: peerId } },
|
||||
body: { peer_card: mergedLines },
|
||||
});
|
||||
if (error) {
|
||||
setSubmitState({
|
||||
kind: "error",
|
||||
message: typeof error === "object" ? JSON.stringify(error) : String(error),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setSubmitState({ kind: "ok" });
|
||||
await existingCard.refetch();
|
||||
setTimeout(onClose, 700);
|
||||
} catch (e) {
|
||||
setSubmitState({
|
||||
kind: "error",
|
||||
message: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Sparkles
|
||||
className="w-4 h-4"
|
||||
style={{ color: "var(--accent-text)" }}
|
||||
strokeWidth={1.8}
|
||||
/>
|
||||
Apply seed kit
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{kit ? (
|
||||
<>
|
||||
Apply <span className="font-medium">{kit.name}</span> to a peer's card. Existing
|
||||
lines with a matching prefix are replaced; everything else is appended.
|
||||
</>
|
||||
) : (
|
||||
"Pick a kit to apply."
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<PickerRow label="Instance">
|
||||
<select
|
||||
value={instanceId ?? ""}
|
||||
onChange={(e) => {
|
||||
setInstanceId(e.target.value || null);
|
||||
setWorkspaceId(null);
|
||||
setPeerId(null);
|
||||
}}
|
||||
className="flex-1 rounded-lg px-3 py-2 text-sm outline-none"
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
color: "var(--text-1)",
|
||||
border: "1px solid var(--border-2)",
|
||||
}}
|
||||
>
|
||||
<option value="">— pick an instance —</option>
|
||||
{instances.map((i) => (
|
||||
<option key={i.id} value={i.id}>
|
||||
{i.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</PickerRow>
|
||||
|
||||
<PickerRow label="Workspace">
|
||||
<select
|
||||
value={workspaceId ?? ""}
|
||||
onChange={(e) => {
|
||||
setWorkspaceId(e.target.value || null);
|
||||
setPeerId(null);
|
||||
}}
|
||||
disabled={!instance || workspaces.isLoading}
|
||||
className="flex-1 rounded-lg px-3 py-2 text-sm outline-none disabled:opacity-50"
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
color: "var(--text-1)",
|
||||
border: "1px solid var(--border-2)",
|
||||
}}
|
||||
>
|
||||
<option value="">{workspaces.isLoading ? "loading…" : "— pick a workspace —"}</option>
|
||||
{(workspaces.data ?? []).map((w) => (
|
||||
<option key={w.id} value={w.id}>
|
||||
{w.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</PickerRow>
|
||||
|
||||
<PickerRow label="Peer">
|
||||
<select
|
||||
value={peerId ?? ""}
|
||||
onChange={(e) => setPeerId(e.target.value || null)}
|
||||
disabled={!workspaceId || peers.isLoading}
|
||||
className="flex-1 rounded-lg px-3 py-2 text-sm outline-none disabled:opacity-50"
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
color: "var(--text-1)",
|
||||
border: "1px solid var(--border-2)",
|
||||
}}
|
||||
>
|
||||
<option value="">{peers.isLoading ? "loading…" : "— pick a peer —"}</option>
|
||||
{peerItems.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</PickerRow>
|
||||
</div>
|
||||
|
||||
{kit && peerId && (
|
||||
<div className="mt-4 space-y-3">
|
||||
<MergePreview
|
||||
existing={existingLines}
|
||||
merged={mergedLines}
|
||||
loading={existingCard.isLoading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{submitState.kind === "error" && (
|
||||
<p className="text-xs mt-3" style={{ color: "#f87171" }}>
|
||||
{submitState.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4 mt-4">
|
||||
<Button type="button" variant="surface" onClick={onClose}>
|
||||
<X className="w-3.5 h-3.5" strokeWidth={2} />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" variant="accent" onClick={handleApply} disabled={!canApply}>
|
||||
{submitState.kind === "pending" ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" strokeWidth={2} />
|
||||
) : submitState.kind === "ok" ? (
|
||||
<Check className="w-3.5 h-3.5" strokeWidth={2} />
|
||||
) : (
|
||||
<Sparkles className="w-3.5 h-3.5" strokeWidth={2} />
|
||||
)}
|
||||
{submitState.kind === "ok" ? "Applied" : "Apply kit"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function PickerRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
// biome-ignore lint/a11y/noLabelWithoutControl: select element is provided via `children`
|
||||
<label className="flex items-center gap-3">
|
||||
<span className="text-xs font-medium w-20 shrink-0" style={{ color: "var(--text-3)" }}>
|
||||
{label}
|
||||
</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
interface MergePreviewProps {
|
||||
existing: string[];
|
||||
merged: string[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
function MergePreview({ existing, merged, loading }: MergePreviewProps) {
|
||||
const existingSet = useMemo(() => new Set(existing), [existing]);
|
||||
|
||||
if (loading) {
|
||||
return <Muted className="text-xs">Loading existing card…</Muted>;
|
||||
}
|
||||
|
||||
if (merged.length === 0) {
|
||||
return <Muted className="text-xs">Nothing to apply.</Muted>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Caption className="block mb-1.5">Preview after apply</Caption>
|
||||
<div
|
||||
className="rounded-lg p-3 font-mono text-xs space-y-0.5 max-h-64 overflow-y-auto"
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--border-2)",
|
||||
}}
|
||||
>
|
||||
{merged.map((line, i) => {
|
||||
const isNew = !existingSet.has(line);
|
||||
return (
|
||||
<div
|
||||
key={`${i}-${line}`}
|
||||
style={{
|
||||
color: isNew ? "var(--accent-text)" : "var(--text-2)",
|
||||
fontWeight: isNew ? 500 : 400,
|
||||
}}
|
||||
>
|
||||
{isNew ? "+ " : " "}
|
||||
{line || <span style={{ color: "var(--text-4)" }}>(empty)</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Body className="text-xs mt-1.5" style={{ color: "var(--text-3)" }}>
|
||||
<span style={{ color: "var(--accent-text)" }}>+ </span>
|
||||
new or replaced • {merged.length} total line{merged.length === 1 ? "" : "s"}
|
||||
</Body>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import { Save, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input, Textarea } from "@/components/ui/input";
|
||||
import type { SeedKit } from "@/lib/seedKits";
|
||||
|
||||
interface SeedKitFormProps {
|
||||
initial?: Pick<SeedKit, "name" | "description" | "lines">;
|
||||
onSubmit: (input: { name: string; description: string; lines: string[] }) => void;
|
||||
onCancel: () => void;
|
||||
submitLabel?: string;
|
||||
}
|
||||
|
||||
export function SeedKitForm({
|
||||
initial,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
submitLabel = "Save kit",
|
||||
}: SeedKitFormProps) {
|
||||
const [name, setName] = useState(initial?.name ?? "");
|
||||
const [description, setDescription] = useState(initial?.description ?? "");
|
||||
const [linesText, setLinesText] = useState((initial?.lines ?? []).join("\n"));
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName) {
|
||||
setError("Name is required");
|
||||
return;
|
||||
}
|
||||
const lines = linesText
|
||||
.split("\n")
|
||||
.map((l) => l.trimEnd())
|
||||
.filter((l) => l.length > 0);
|
||||
if (lines.length === 0) {
|
||||
setError("Add at least one line");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
onSubmit({ name: trimmedName, description: description.trim(), lines });
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="kit-name"
|
||||
className="text-xs font-medium block mb-1"
|
||||
style={{ color: "var(--text-3)" }}
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
<Input
|
||||
id="kit-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Personal core"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="kit-description"
|
||||
className="text-xs font-medium block mb-1"
|
||||
style={{ color: "var(--text-3)" }}
|
||||
>
|
||||
Description{" "}
|
||||
<span style={{ color: "var(--text-4)" }} className="font-normal">
|
||||
(optional)
|
||||
</span>
|
||||
</label>
|
||||
<Input
|
||||
id="kit-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Short summary of what this kit seeds"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="kit-lines"
|
||||
className="text-xs font-medium block mb-1"
|
||||
style={{ color: "var(--text-3)" }}
|
||||
>
|
||||
Lines{" "}
|
||||
<span style={{ color: "var(--text-4)" }} className="font-normal">
|
||||
(one per line — e.g. <span className="font-mono">Name: Ben</span>)
|
||||
</span>
|
||||
</label>
|
||||
<Textarea
|
||||
id="kit-lines"
|
||||
value={linesText}
|
||||
onChange={(e) => setLinesText(e.target.value)}
|
||||
rows={10}
|
||||
className="font-mono resize-y"
|
||||
style={{ minHeight: "12rem" }}
|
||||
placeholder={"Name: \nEmail: \nRole: "}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs" style={{ color: "#f87171" }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="surface" onClick={onCancel}>
|
||||
<X className="w-3.5 h-3.5" strokeWidth={2} />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="accent">
|
||||
<Save className="w-3.5 h-3.5" strokeWidth={2} />
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,365 +0,0 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { Copy, Layers, Pencil, Plus, Sparkles, Trash2 } from "lucide-react";
|
||||
import { useState, useSyncExternalStore } from "react";
|
||||
import { ApplyKitDialog } from "@/components/seed-kits/ApplyKitDialog";
|
||||
import { SeedKitForm } from "@/components/seed-kits/SeedKitForm";
|
||||
import { EmptyState } from "@/components/shared/EmptyState";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Body, Caption, MonoCaption, PageTitle } from "@/components/ui/typography";
|
||||
import {
|
||||
BUILTIN_KITS,
|
||||
createKit,
|
||||
deleteKit,
|
||||
isBuiltinKit,
|
||||
loadUserKits,
|
||||
type SeedKit,
|
||||
updateKit,
|
||||
} from "@/lib/seedKits";
|
||||
|
||||
const EVENT = "openconcho:seed-kits-changed";
|
||||
|
||||
function emit() {
|
||||
window.dispatchEvent(new Event(EVENT));
|
||||
}
|
||||
|
||||
function subscribe(cb: () => void): () => void {
|
||||
window.addEventListener(EVENT, cb);
|
||||
window.addEventListener("storage", cb);
|
||||
return () => {
|
||||
window.removeEventListener(EVENT, cb);
|
||||
window.removeEventListener("storage", cb);
|
||||
};
|
||||
}
|
||||
|
||||
let cachedKey = "";
|
||||
let cachedSnapshot: SeedKit[] = [];
|
||||
|
||||
function getSnapshot(): SeedKit[] {
|
||||
const next = loadUserKits();
|
||||
const key = JSON.stringify(next);
|
||||
if (key !== cachedKey) {
|
||||
cachedKey = key;
|
||||
cachedSnapshot = next;
|
||||
}
|
||||
return cachedSnapshot;
|
||||
}
|
||||
|
||||
function getServerSnapshot(): SeedKit[] {
|
||||
return cachedSnapshot;
|
||||
}
|
||||
|
||||
function useUserKits(): SeedKit[] {
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
}
|
||||
|
||||
type Mode =
|
||||
| { kind: "list" }
|
||||
| { kind: "create"; initial?: Pick<SeedKit, "name" | "description" | "lines"> }
|
||||
| { kind: "edit"; id: string };
|
||||
|
||||
export function SeedKitsView() {
|
||||
const userKits = useUserKits();
|
||||
const [mode, setMode] = useState<Mode>({ kind: "list" });
|
||||
const [applyTarget, setApplyTarget] = useState<SeedKit | null>(null);
|
||||
|
||||
if (mode.kind === "create") {
|
||||
return (
|
||||
<div className="page-container">
|
||||
<header className="mb-6">
|
||||
<PageTitle>New seed kit</PageTitle>
|
||||
<Body className="mt-1">Define the lines that will be merged into a peer's card.</Body>
|
||||
</header>
|
||||
<div
|
||||
className="rounded-xl p-5"
|
||||
style={{
|
||||
background: "var(--bg-2)",
|
||||
border: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
<SeedKitForm
|
||||
initial={mode.initial}
|
||||
submitLabel="Create kit"
|
||||
onSubmit={(input) => {
|
||||
createKit(input);
|
||||
emit();
|
||||
setMode({ kind: "list" });
|
||||
}}
|
||||
onCancel={() => setMode({ kind: "list" })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode.kind === "edit") {
|
||||
const kit = userKits.find((k) => k.id === mode.id);
|
||||
if (!kit) {
|
||||
return (
|
||||
<div className="page-container">
|
||||
<Body>Kit not found.</Body>
|
||||
<Button variant="ghost" onClick={() => setMode({ kind: "list" })} className="mt-3">
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="page-container">
|
||||
<header className="mb-6">
|
||||
<PageTitle>Edit seed kit</PageTitle>
|
||||
<Body className="mt-1">{kit.name}</Body>
|
||||
</header>
|
||||
<div
|
||||
className="rounded-xl p-5"
|
||||
style={{
|
||||
background: "var(--bg-2)",
|
||||
border: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
<SeedKitForm
|
||||
initial={kit}
|
||||
submitLabel="Save changes"
|
||||
onSubmit={(input) => {
|
||||
updateKit(kit.id, input);
|
||||
emit();
|
||||
setMode({ kind: "list" });
|
||||
}}
|
||||
onCancel={() => setMode({ kind: "list" })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<motion.header
|
||||
initial={{ opacity: 0, y: -6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mb-6 flex items-start justify-between gap-4 flex-wrap"
|
||||
>
|
||||
<div>
|
||||
<PageTitle className="flex items-center gap-2">
|
||||
<Layers className="w-5 h-5" style={{ color: "var(--accent-text)" }} strokeWidth={1.5} />
|
||||
Seed Kits
|
||||
</PageTitle>
|
||||
<Body className="mt-1 max-w-xl">
|
||||
Pre-defined sets of card lines you can apply to any peer in one click. Useful for
|
||||
seeding identity facts across multiple agents.
|
||||
</Body>
|
||||
</div>
|
||||
<Button variant="accent" onClick={() => setMode({ kind: "create" })}>
|
||||
<Plus className="w-3.5 h-3.5" strokeWidth={2} />
|
||||
New kit
|
||||
</Button>
|
||||
</motion.header>
|
||||
|
||||
<section className="mb-8">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<h2
|
||||
className="text-xs font-medium uppercase tracking-wider"
|
||||
style={{ color: "var(--text-3)" }}
|
||||
>
|
||||
Built-in starters
|
||||
</h2>
|
||||
<Caption>Read-only — fork to customize</Caption>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{BUILTIN_KITS.map((kit) => (
|
||||
<KitCard
|
||||
key={kit.id}
|
||||
kit={kit}
|
||||
onApply={() => setApplyTarget(kit)}
|
||||
onFork={() =>
|
||||
setMode({
|
||||
kind: "create",
|
||||
initial: {
|
||||
name: `${kit.name} (copy)`,
|
||||
description: kit.description,
|
||||
lines: [...kit.lines],
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<h2
|
||||
className="text-xs font-medium uppercase tracking-wider"
|
||||
style={{ color: "var(--text-3)" }}
|
||||
>
|
||||
Your kits
|
||||
</h2>
|
||||
<Caption>
|
||||
{userKits.length} kit{userKits.length === 1 ? "" : "s"}
|
||||
</Caption>
|
||||
</div>
|
||||
{userKits.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Sparkles}
|
||||
title="No custom kits yet"
|
||||
description="Create your first kit, or fork a built-in to get started."
|
||||
action={
|
||||
<Button variant="accent" onClick={() => setMode({ kind: "create" })}>
|
||||
<Plus className="w-3.5 h-3.5" strokeWidth={2} />
|
||||
New kit
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{userKits.map((kit) => (
|
||||
<KitCard
|
||||
key={kit.id}
|
||||
kit={kit}
|
||||
onApply={() => setApplyTarget(kit)}
|
||||
onEdit={() => setMode({ kind: "edit", id: kit.id })}
|
||||
onDelete={() => {
|
||||
deleteKit(kit.id);
|
||||
emit();
|
||||
}}
|
||||
onFork={() =>
|
||||
setMode({
|
||||
kind: "create",
|
||||
initial: {
|
||||
name: `${kit.name} (copy)`,
|
||||
description: kit.description,
|
||||
lines: [...kit.lines],
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<ApplyKitDialog
|
||||
kit={applyTarget}
|
||||
open={applyTarget !== null}
|
||||
onClose={() => setApplyTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface KitCardProps {
|
||||
kit: SeedKit;
|
||||
onApply: () => void;
|
||||
onEdit?: () => void;
|
||||
onDelete?: () => void;
|
||||
onFork: () => void;
|
||||
}
|
||||
|
||||
function KitCard({ kit, onApply, onEdit, onDelete, onFork }: KitCardProps) {
|
||||
const builtin = isBuiltinKit(kit);
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="rounded-xl p-4 flex flex-col gap-3"
|
||||
style={{
|
||||
background: "var(--bg-2)",
|
||||
border: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<h3
|
||||
className="text-sm font-medium truncate"
|
||||
style={{ color: "var(--text-1)" }}
|
||||
title={kit.name}
|
||||
>
|
||||
{kit.name}
|
||||
</h3>
|
||||
{builtin && (
|
||||
<span
|
||||
className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded font-medium"
|
||||
style={{
|
||||
background: "var(--accent-dim)",
|
||||
color: "var(--accent-text)",
|
||||
border: "1px solid var(--accent-border)",
|
||||
}}
|
||||
>
|
||||
built-in
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{kit.description && <Caption className="block leading-relaxed">{kit.description}</Caption>}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="rounded-md px-3 py-2 font-mono text-xs space-y-0.5 max-h-32 overflow-y-auto"
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
{kit.lines.length === 0 ? (
|
||||
<MonoCaption>(no lines)</MonoCaption>
|
||||
) : (
|
||||
kit.lines.map((line, i) => (
|
||||
<div
|
||||
key={`${i}-${line}`}
|
||||
className="truncate"
|
||||
style={{ color: "var(--text-2)" }}
|
||||
title={line}
|
||||
>
|
||||
{line || <span style={{ color: "var(--text-4)" }}>(empty)</span>}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 mt-auto">
|
||||
<Button variant="accent" size="sm" onClick={onApply}>
|
||||
<Sparkles className="w-3 h-3" strokeWidth={2} />
|
||||
Apply
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={onFork} title="Fork into a new kit">
|
||||
<Copy className="w-3 h-3" strokeWidth={2} />
|
||||
</Button>
|
||||
{!builtin && onEdit && (
|
||||
<Button variant="ghost" size="sm" onClick={onEdit} title="Edit">
|
||||
<Pencil className="w-3 h-3" strokeWidth={2} />
|
||||
</Button>
|
||||
)}
|
||||
{!builtin &&
|
||||
onDelete &&
|
||||
(confirmingDelete ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onDelete();
|
||||
setConfirmingDelete(false);
|
||||
}}
|
||||
className="text-xs font-medium px-2 py-1 rounded-md"
|
||||
style={{
|
||||
color: "#f87171",
|
||||
border: "1px solid #f87171",
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setConfirmingDelete(true)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" strokeWidth={2} />
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -137,7 +137,7 @@ export function SessionList() {
|
||||
className="font-mono text-sm font-medium truncate"
|
||||
style={{ color: COLOR.accentSoft }}
|
||||
>
|
||||
{mask(session.id)}
|
||||
{session.name || mask(session.id)}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 shrink-0 ml-2">
|
||||
{session.is_active && (
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { Loader, RefreshCw, Sparkles } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Muted } from "@/components/ui/typography";
|
||||
import { useInstances } from "@/hooks/useInstances";
|
||||
import { COLOR } from "@/lib/constants";
|
||||
import {
|
||||
type DiscoveredInstance,
|
||||
discoverHonchoInstances,
|
||||
suggestNameForInstance,
|
||||
} from "@/lib/discovery";
|
||||
|
||||
interface Row {
|
||||
discovered: DiscoveredInstance;
|
||||
suggestedName: string;
|
||||
checked: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** If true, scan as soon as the component mounts. */
|
||||
autoRun?: boolean;
|
||||
/** Called after the user has added at least one instance. */
|
||||
onAdded?: () => void;
|
||||
}
|
||||
|
||||
export function DiscoveredInstances({ autoRun = false, onAdded }: Props) {
|
||||
const { instances, add, activate } = useInstances();
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [hasScanned, setHasScanned] = useState(false);
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
|
||||
const existingBaseUrls = useMemo(
|
||||
() => new Set(instances.map((i) => i.baseUrl.replace(/\/+$/, "").toLowerCase())),
|
||||
[instances],
|
||||
);
|
||||
|
||||
const runScan = useCallback(async () => {
|
||||
setScanning(true);
|
||||
try {
|
||||
const found = await discoverHonchoInstances();
|
||||
const fresh = found.filter(
|
||||
(d) => !existingBaseUrls.has(d.base_url.replace(/\/+$/, "").toLowerCase()),
|
||||
);
|
||||
const named = await Promise.all(
|
||||
fresh.map(async (d) => {
|
||||
const name = (await suggestNameForInstance(d.base_url)) ?? `Honcho :${d.port}`;
|
||||
return { discovered: d, suggestedName: name, checked: true } satisfies Row;
|
||||
}),
|
||||
);
|
||||
setRows(named);
|
||||
} finally {
|
||||
setScanning(false);
|
||||
setHasScanned(true);
|
||||
}
|
||||
}, [existingBaseUrls]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoRun) void runScan();
|
||||
}, [autoRun, runScan]);
|
||||
|
||||
function setRowChecked(port: number, checked: boolean) {
|
||||
setRows((r) => r.map((row) => (row.discovered.port === port ? { ...row, checked } : row)));
|
||||
}
|
||||
|
||||
function setRowName(port: number, suggestedName: string) {
|
||||
setRows((r) =>
|
||||
r.map((row) => (row.discovered.port === port ? { ...row, suggestedName } : row)),
|
||||
);
|
||||
}
|
||||
|
||||
function addSelected() {
|
||||
const selected = rows.filter((r) => r.checked);
|
||||
if (selected.length === 0) return;
|
||||
let firstId: string | null = null;
|
||||
for (const row of selected) {
|
||||
const created = add({
|
||||
name: row.suggestedName.trim() || `Honcho :${row.discovered.port}`,
|
||||
baseUrl: row.discovered.base_url,
|
||||
token: "",
|
||||
});
|
||||
if (firstId === null) firstId = created.id;
|
||||
}
|
||||
if (firstId) activate(firstId);
|
||||
setRows([]);
|
||||
onAdded?.();
|
||||
}
|
||||
|
||||
const selectedCount = rows.filter((r) => r.checked).length;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-2xl p-5 space-y-3"
|
||||
style={{ background: "var(--bg-2)", border: "1px solid var(--border)" }}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles
|
||||
className="w-4 h-4 shrink-0"
|
||||
style={{ color: COLOR.accentText }}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm font-medium" style={{ color: "var(--text-1)" }}>
|
||||
Discover local Honcho instances
|
||||
</p>
|
||||
<Muted className="text-xs">Scans 127.0.0.1:8000–8100 for running instances</Muted>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => void runScan()}
|
||||
disabled={scanning}
|
||||
className="rounded-xl px-3 py-2"
|
||||
title="Rescan"
|
||||
>
|
||||
{scanning ? (
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{
|
||||
duration: 1,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
ease: "linear",
|
||||
}}
|
||||
>
|
||||
<Loader className="w-4 h-4" strokeWidth={1.5} />
|
||||
</motion.div>
|
||||
) : (
|
||||
<RefreshCw className="w-4 h-4" strokeWidth={1.5} />
|
||||
)}
|
||||
<span className="hidden sm:inline ml-1.5 text-xs">
|
||||
{scanning ? "Scanning…" : "Rescan"}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{hasScanned && !scanning && rows.length === 0 && (
|
||||
<Muted className="text-xs">
|
||||
No new instances found. Add one manually below if you know its URL.
|
||||
</Muted>
|
||||
)}
|
||||
|
||||
{rows.length > 0 && (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
{rows.map((row) => (
|
||||
<DiscoveredRow
|
||||
key={row.discovered.port}
|
||||
row={row}
|
||||
onCheck={(c) => setRowChecked(row.discovered.port, c)}
|
||||
onRename={(n) => setRowName(row.discovered.port, n)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="accent"
|
||||
onClick={addSelected}
|
||||
disabled={selectedCount === 0}
|
||||
className="w-full rounded-xl py-2.5"
|
||||
>
|
||||
{selectedCount === 0
|
||||
? "Select at least one"
|
||||
: `Add ${selectedCount} instance${selectedCount === 1 ? "" : "s"}`}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DiscoveredRowProps {
|
||||
row: Row;
|
||||
onCheck: (checked: boolean) => void;
|
||||
onRename: (name: string) => void;
|
||||
}
|
||||
|
||||
function DiscoveredRow({ row, onCheck, onRename }: DiscoveredRowProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2.5 rounded-xl px-3 py-2"
|
||||
style={{
|
||||
background: row.checked ? "var(--surface)" : "transparent",
|
||||
border: `1px solid ${row.checked ? "var(--accent-border)" : "var(--border)"}`,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={row.checked}
|
||||
onChange={(e) => onCheck(e.target.checked)}
|
||||
className="w-4 h-4 shrink-0 cursor-pointer"
|
||||
aria-label={`Select ${row.suggestedName}`}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={row.suggestedName}
|
||||
onChange={(e) => onRename(e.target.value)}
|
||||
className="flex-1 min-w-0 bg-transparent text-sm font-medium border-0 outline-none px-1 py-0.5 rounded"
|
||||
style={{ color: "var(--text-1)" }}
|
||||
aria-label={`Name for instance on port ${row.discovered.port}`}
|
||||
disabled={!row.checked}
|
||||
/>
|
||||
<span className="text-xs font-mono shrink-0" style={{ color: "var(--text-4)" }}>
|
||||
:{row.discovered.port}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +1,12 @@
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import {
|
||||
Check,
|
||||
ChevronRight,
|
||||
Cloud,
|
||||
Pencil,
|
||||
Plus,
|
||||
Radar,
|
||||
Server,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { Check, ChevronRight, Cloud, Pencil, Plus, Server, Sparkles, Trash2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { DiscoveredInstances } from "@/components/settings/DiscoveredInstances";
|
||||
import { type ConnectionPreset, SettingsForm } from "@/components/settings/SettingsForm";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Muted } from "@/components/ui/typography";
|
||||
import { useInstances } from "@/hooks/useInstances";
|
||||
import { checkConnection, HONCHO_CLOUD_URL, type Instance, isCloudInstance } from "@/lib/config";
|
||||
import { COLOR } from "@/lib/constants";
|
||||
import { isTauri } from "@/lib/discovery";
|
||||
|
||||
const LOCALHOST_PROBE_URL = "http://localhost:8000";
|
||||
|
||||
@@ -26,8 +14,7 @@ type Mode =
|
||||
| { kind: "list" }
|
||||
| { kind: "choose-type" }
|
||||
| { kind: "create"; preset: ConnectionPreset }
|
||||
| { kind: "edit"; id: string }
|
||||
| { kind: "discover" };
|
||||
| { kind: "edit"; id: string };
|
||||
|
||||
interface InstancesManagerProps {
|
||||
onActivated?: () => void;
|
||||
@@ -36,44 +23,16 @@ interface InstancesManagerProps {
|
||||
export function InstancesManager({ onActivated }: InstancesManagerProps) {
|
||||
const { instances, activeId, activate, remove } = useInstances();
|
||||
const isFirstRun = instances.length === 0;
|
||||
const inTauri = isTauri();
|
||||
const [mode, setMode] = useState<Mode>(isFirstRun ? { kind: "choose-type" } : { kind: "list" });
|
||||
|
||||
const backFromCreate = () => setMode(isFirstRun ? { kind: "choose-type" } : { kind: "list" });
|
||||
|
||||
if (mode.kind === "discover") {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<DiscoveredInstances autoRun onAdded={() => setMode({ kind: "list" })} />
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setMode({ kind: "list" })}
|
||||
className="w-full py-2.5 px-4 rounded-xl"
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode.kind === "choose-type") {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{inTauri && (
|
||||
<DiscoveredInstances
|
||||
autoRun
|
||||
onAdded={() => {
|
||||
setMode({ kind: "list" });
|
||||
onActivated?.();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ConnectionTypeChooser
|
||||
onPick={(preset) => setMode({ kind: "create", preset })}
|
||||
onCancel={isFirstRun ? undefined : () => setMode({ kind: "list" })}
|
||||
/>
|
||||
</div>
|
||||
<ConnectionTypeChooser
|
||||
onPick={(preset) => setMode({ kind: "create", preset })}
|
||||
onCancel={isFirstRun ? undefined : () => setMode({ kind: "list" })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -123,18 +82,6 @@ export function InstancesManager({ onActivated }: InstancesManagerProps) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{inTauri && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setMode({ kind: "discover" })}
|
||||
className="w-full py-2.5 px-4 rounded-xl flex items-center justify-center gap-2"
|
||||
>
|
||||
<Radar className="w-4 h-4" strokeWidth={1.5} />
|
||||
Discover instances
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
isCloudInstance,
|
||||
} from "@/lib/config";
|
||||
import { COLOR } from "@/lib/constants";
|
||||
import { tokenTransportError } from "@/lib/security";
|
||||
|
||||
export type ConnectionPreset = "cloud" | "self-hosted";
|
||||
|
||||
@@ -82,13 +81,6 @@ export function SettingsForm({
|
||||
async function handleTest() {
|
||||
setChecking(true);
|
||||
setHealth({ status: "checking", message: "Connecting..." });
|
||||
const transportError = token.trim() ? tokenTransportError(baseUrl) : null;
|
||||
if (transportError) {
|
||||
setErrors({ baseUrl: transportError });
|
||||
setHealth({ status: "unreachable", message: transportError });
|
||||
setChecking(false);
|
||||
return;
|
||||
}
|
||||
const result = await checkConnection(baseUrl, token || undefined);
|
||||
setHealth(result);
|
||||
setChecking(false);
|
||||
@@ -120,11 +112,6 @@ export function SettingsForm({
|
||||
setErrors({ token: "API key is required for Honcho Cloud" });
|
||||
return;
|
||||
}
|
||||
const transportError = token.trim() ? tokenTransportError(result.data.baseUrl) : null;
|
||||
if (transportError) {
|
||||
setErrors({ baseUrl: transportError });
|
||||
return;
|
||||
}
|
||||
setErrors({});
|
||||
|
||||
let id: string;
|
||||
|
||||
@@ -1,310 +0,0 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { motion } from "framer-motion";
|
||||
import { Activity, AlertTriangle, CircleDot, Info, Sparkles, TimerReset } from "lucide-react";
|
||||
import { QUEUE_REFETCH_ACTIVE_MS, QUEUE_REFETCH_IDLE_MS } from "@/api/queries";
|
||||
import type { components } from "@/api/schema.d.ts";
|
||||
import { Skeleton } from "@/components/shared/Skeleton";
|
||||
import { Body, Caption, Muted, SectionHeading } from "@/components/ui/typography";
|
||||
import { useDemo } from "@/hooks/useDemo";
|
||||
import { type StaleQueueState, useStaleQueueDetection } from "@/hooks/useStaleQueueDetection";
|
||||
import { COLOR } from "@/lib/constants";
|
||||
import { formatCount } from "@/lib/utils";
|
||||
|
||||
type QueueStatus = components["schemas"]["QueueStatus"];
|
||||
|
||||
export interface DreamProgressPanelProps {
|
||||
workspaceId: string;
|
||||
data: QueueStatus | undefined;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
/** Override the stale-detection hook output (used by the dev showcase). */
|
||||
staleOverride?: StaleQueueState;
|
||||
}
|
||||
|
||||
function formatElapsed(ms: number): string {
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
const h = Math.floor(totalSeconds / 3600);
|
||||
const m = Math.floor((totalSeconds % 3600) / 60);
|
||||
const s = totalSeconds % 60;
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
if (m > 0) return `${m}m ${s}s`;
|
||||
return `${s}s`;
|
||||
}
|
||||
|
||||
export function DreamProgressPanel({
|
||||
workspaceId,
|
||||
data,
|
||||
isLoading,
|
||||
error,
|
||||
staleOverride,
|
||||
}: DreamProgressPanelProps) {
|
||||
const { mask } = useDemo();
|
||||
const detected = useStaleQueueDetection(data);
|
||||
const stale = staleOverride ?? detected;
|
||||
|
||||
const inProgress = data?.in_progress_work_units ?? 0;
|
||||
const pending = data?.pending_work_units ?? 0;
|
||||
const completed = data?.completed_work_units ?? 0;
|
||||
const total = data?.total_work_units ?? 0;
|
||||
const active = inProgress + pending;
|
||||
const isActive = active > 0;
|
||||
|
||||
const pollSeconds = isActive
|
||||
? Math.round(QUEUE_REFETCH_ACTIVE_MS / 100) / 10
|
||||
: Math.round(QUEUE_REFETCH_IDLE_MS / 100) / 10;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="rounded-xl p-5 theme-card"
|
||||
style={{
|
||||
background: COLOR.destructiveDim,
|
||||
border: `1px solid ${COLOR.destructiveBorder}`,
|
||||
}}
|
||||
>
|
||||
<SectionHeading style={{ color: COLOR.destructive }} className="mb-1">
|
||||
Could not load queue status
|
||||
</SectionHeading>
|
||||
<Caption as="p">{error.message}</Caption>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="rounded-xl theme-card overflow-hidden"
|
||||
data-testid="dream-progress-panel"
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-5 py-4"
|
||||
style={{ borderBottom: "1px solid var(--border)" }}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Sparkles
|
||||
className="w-4 h-4 flex-shrink-0"
|
||||
style={{ color: "var(--accent)" }}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<SectionHeading className="mb-0">Dreams in progress</SectionHeading>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
{isActive ? (
|
||||
<motion.div
|
||||
animate={{ opacity: [0.5, 1, 0.5] }}
|
||||
transition={{ duration: 1.5, repeat: Number.POSITIVE_INFINITY }}
|
||||
>
|
||||
<CircleDot className="w-3 h-3" style={{ color: COLOR.warning }} strokeWidth={2} />
|
||||
</motion.div>
|
||||
) : (
|
||||
<CircleDot className="w-3 h-3" style={{ color: COLOR.success }} strokeWidth={2} />
|
||||
)}
|
||||
<span
|
||||
className="text-xs font-medium"
|
||||
style={{ color: isActive ? COLOR.warning : COLOR.success }}
|
||||
data-testid="dream-progress-status"
|
||||
>
|
||||
{isActive ? `${formatCount(active)} active` : "Idle"}
|
||||
</span>
|
||||
<span className="mx-1 text-xs" style={{ color: "var(--text-4)" }}>
|
||||
·
|
||||
</span>
|
||||
<span className="text-xs font-mono" style={{ color: "var(--text-4)" }}>
|
||||
polling every {pollSeconds}s
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stale warning */}
|
||||
{stale.isStale && stale.stalledSince !== null && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
className="overflow-hidden"
|
||||
data-testid="stale-dream-warning"
|
||||
>
|
||||
<div
|
||||
className="flex items-start gap-2 px-5 py-3"
|
||||
style={{
|
||||
background: COLOR.warningDim,
|
||||
borderBottom: `1px solid ${COLOR.warningBorder}`,
|
||||
}}
|
||||
>
|
||||
<AlertTriangle
|
||||
className="w-4 h-4 flex-shrink-0 mt-0.5"
|
||||
style={{ color: COLOR.warning }}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<Body className="font-medium" style={{ color: COLOR.warning }}>
|
||||
Stalled for {formatElapsed(stale.elapsedMs)} without forward progress
|
||||
</Body>
|
||||
<Caption as="p" className="mt-0.5">
|
||||
Work has been in-flight since {new Date(stale.stalledSince).toLocaleTimeString()}{" "}
|
||||
with no advance in the completed count. A specialist may be hung — check Honcho
|
||||
logs.
|
||||
</Caption>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Counts */}
|
||||
<div className="px-5 py-5">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
{(
|
||||
[
|
||||
{ label: "Total", value: total, color: "var(--text-1)" },
|
||||
{ label: "Done", value: completed, color: COLOR.success },
|
||||
{ label: "In progress", value: inProgress, color: COLOR.warning },
|
||||
{ label: "Pending", value: pending, color: "var(--text-3)" },
|
||||
] as const
|
||||
).map(({ label, value, color }) => (
|
||||
<div key={label}>
|
||||
{isLoading ? (
|
||||
<Skeleton accent className="h-8 w-16 rounded" />
|
||||
) : (
|
||||
<div
|
||||
className="text-2xl font-semibold font-mono"
|
||||
style={{ color }}
|
||||
data-testid={`count-${label.toLowerCase().replace(/\s+/g, "-")}`}
|
||||
>
|
||||
{formatCount(value)}
|
||||
</div>
|
||||
)}
|
||||
<Caption as="div" className="mt-0.5">
|
||||
{label}
|
||||
</Caption>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* API limitation note */}
|
||||
<div
|
||||
className="flex items-start gap-2 mt-5 rounded-lg px-3 py-2"
|
||||
style={{
|
||||
background: COLOR.accentSubtle,
|
||||
border: `1px solid ${COLOR.accentBorder}`,
|
||||
}}
|
||||
>
|
||||
<Info
|
||||
className="w-3.5 h-3.5 flex-shrink-0 mt-0.5"
|
||||
style={{ color: COLOR.accentText }}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Caption as="p">
|
||||
Honcho's <code className="font-mono">/queue/status</code> exposes aggregate counts only.
|
||||
Per-dream observer/observed pair, specialist phase (deduction vs. induction), and token
|
||||
telemetry are tracked in{" "}
|
||||
<a
|
||||
href="https://github.com/plastic-labs/honcho/issues/724"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{ color: COLOR.accentText }}
|
||||
className="underline"
|
||||
>
|
||||
plastic-labs/honcho#724
|
||||
</a>{" "}
|
||||
— once the API exposes them, this panel will surface them.
|
||||
</Caption>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SessionsTable workspaceId={workspaceId} sessions={data?.sessions} mask={mask} />
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionsTable({
|
||||
workspaceId,
|
||||
sessions,
|
||||
mask,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
sessions: QueueStatus["sessions"];
|
||||
mask: (s: string) => string;
|
||||
}) {
|
||||
const entries = sessions ? Object.entries(sessions) : [];
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<div className="px-5 pb-5">
|
||||
<div
|
||||
className="flex items-center gap-2 rounded-lg px-3 py-3"
|
||||
style={{ background: "var(--bg-3)", border: "1px solid var(--border)" }}
|
||||
>
|
||||
<Activity className="w-3.5 h-3.5" style={{ color: "var(--text-4)" }} strokeWidth={1.5} />
|
||||
<Muted className="text-xs">No session-scoped work tracked right now.</Muted>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-5 pb-5">
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<TimerReset className="w-3.5 h-3.5" style={{ color: "var(--text-3)" }} strokeWidth={1.5} />
|
||||
<Caption>
|
||||
{entries.length} session{entries.length !== 1 ? "s" : ""} with active work
|
||||
</Caption>
|
||||
</div>
|
||||
<div
|
||||
className="rounded-lg overflow-hidden"
|
||||
style={{ border: "1px solid var(--border)" }}
|
||||
data-testid="sessions-table"
|
||||
>
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr
|
||||
style={{
|
||||
background: "var(--bg-3)",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
{["Session", "Total", "Done", "In progress", "Pending"].map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
className={`py-2 px-3 font-medium text-left ${h !== "Session" ? "text-right" : ""}`}
|
||||
style={{ color: "var(--text-3)" }}
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map(([sid, s], i) => (
|
||||
<tr key={sid} style={{ borderTop: i > 0 ? "1px solid var(--border)" : undefined }}>
|
||||
<td className="py-1.5 px-3">
|
||||
<Link
|
||||
to={"/workspaces/$workspaceId/sessions/$sessionId" as never}
|
||||
params={{ workspaceId, sessionId: sid } as never}
|
||||
className="font-mono truncate block max-w-[220px] hover:underline"
|
||||
style={{ color: "var(--accent-text)" }}
|
||||
>
|
||||
{mask(sid)}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-1.5 px-3 text-right font-mono" style={{ color: "var(--text-2)" }}>
|
||||
{s.total_work_units}
|
||||
</td>
|
||||
<td className="py-1.5 px-3 text-right font-mono" style={{ color: COLOR.success }}>
|
||||
{s.completed_work_units}
|
||||
</td>
|
||||
<td className="py-1.5 px-3 text-right font-mono" style={{ color: COLOR.warning }}>
|
||||
{s.in_progress_work_units}
|
||||
</td>
|
||||
<td className="py-1.5 px-3 text-right font-mono" style={{ color: "var(--text-3)" }}>
|
||||
{s.pending_work_units}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,12 +13,8 @@ import { Input } from "@/components/ui/input";
|
||||
import { Body, Muted, PageTitle, SectionHeading } from "@/components/ui/typography";
|
||||
import { useDemo } from "@/hooks/useDemo";
|
||||
import { COLOR } from "@/lib/constants";
|
||||
import { isHttpOrHttpsUrl, isSafeExternalUrl } from "@/lib/security";
|
||||
|
||||
const urlSchema = z
|
||||
.string()
|
||||
.url({ message: "Must be a valid URL" })
|
||||
.refine(isHttpOrHttpsUrl, { message: "Webhook URL must use http or https" });
|
||||
const urlSchema = z.string().url({ message: "Must be a valid URL" });
|
||||
|
||||
interface Props {
|
||||
workspaceId: string;
|
||||
@@ -54,15 +50,6 @@ export function WebhookManager({ workspaceId }: Props) {
|
||||
setTimeout(() => setTestResult(null), 5000);
|
||||
};
|
||||
|
||||
const handleOpenWebhook = async (webhookUrl: string) => {
|
||||
if (!isSafeExternalUrl(webhookUrl)) {
|
||||
setTestResult(`Blocked unsafe webhook URL: ${webhookUrl}`);
|
||||
setTimeout(() => setTestResult(null), 5000);
|
||||
return;
|
||||
}
|
||||
await open(webhookUrl);
|
||||
};
|
||||
|
||||
const list = Array.isArray(webhooks) ? webhooks : [];
|
||||
|
||||
return (
|
||||
@@ -174,7 +161,7 @@ export function WebhookManager({ workspaceId }: Props) {
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleOpenWebhook((wh as { url: string }).url)}
|
||||
onClick={() => void open((wh as { url: string }).url)}
|
||||
className="flex-shrink-0"
|
||||
style={{
|
||||
color: "var(--text-4)",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Link, useNavigate, useParams } from "@tanstack/react-router";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import {
|
||||
Activity,
|
||||
Boxes,
|
||||
ChevronDown,
|
||||
CircleDot,
|
||||
@@ -51,12 +50,6 @@ const NAV_SECTIONS = [
|
||||
to: "webhooks" as const,
|
||||
description: "Manage event webhooks",
|
||||
},
|
||||
{
|
||||
label: "Queue & dreams",
|
||||
icon: Activity,
|
||||
to: "queue" as const,
|
||||
description: "Live view of in-flight work",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function WorkspaceDetail() {
|
||||
@@ -114,7 +107,7 @@ export function WorkspaceDetail() {
|
||||
{!isLoading && workspace && (
|
||||
<div className="space-y-4">
|
||||
{/* Nav cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{NAV_SECTIONS.map((s, i) => {
|
||||
const Icon = s.icon;
|
||||
return (
|
||||
|
||||
@@ -7,7 +7,7 @@ const POLL_INTERVAL_MS = 30_000;
|
||||
export function useHealthStatus() {
|
||||
const { active } = useInstances();
|
||||
return useQuery({
|
||||
queryKey: ["health", active?.id, active?.baseUrl, Boolean(active?.token)],
|
||||
queryKey: ["health", active?.id, active?.baseUrl, active?.token],
|
||||
queryFn: async () => {
|
||||
if (!active) throw new Error("No active instance");
|
||||
return checkConnection(active.baseUrl, active.token || undefined);
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { components } from "@/api/schema.d.ts";
|
||||
|
||||
type QueueStatus = components["schemas"]["QueueStatus"];
|
||||
|
||||
export const STALE_QUEUE_THRESHOLD_MS = 30 * 60 * 1000;
|
||||
|
||||
export interface StaleQueueState {
|
||||
stalledSince: number | null;
|
||||
elapsedMs: number;
|
||||
isStale: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects stalled queue work without per-work-unit timestamps from the API.
|
||||
*
|
||||
* Anchors when in_progress + pending first goes non-zero and resets the anchor
|
||||
* whenever completed_work_units advances (forward progress). If the anchor
|
||||
* lives longer than the threshold, the queue is considered stale.
|
||||
*
|
||||
* Note: completed_work_units resets on Honcho's periodic queue cleanup; a drop
|
||||
* is treated as "no forward progress" rather than regression, so the stall
|
||||
* clock keeps running until either work finishes or completed advances.
|
||||
*/
|
||||
export function useStaleQueueDetection(
|
||||
data: QueueStatus | undefined,
|
||||
staleThresholdMs: number = STALE_QUEUE_THRESHOLD_MS,
|
||||
): StaleQueueState {
|
||||
const [stalledSince, setStalledSince] = useState<number | null>(null);
|
||||
const [lastCompleted, setLastCompleted] = useState(0);
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(Date.now()), 10_000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
const active = (data.in_progress_work_units ?? 0) + (data.pending_work_units ?? 0);
|
||||
const completed = data.completed_work_units ?? 0;
|
||||
if (active === 0) {
|
||||
setStalledSince(null);
|
||||
setLastCompleted(completed);
|
||||
return;
|
||||
}
|
||||
if (completed > lastCompleted) {
|
||||
setStalledSince(Date.now());
|
||||
setLastCompleted(completed);
|
||||
return;
|
||||
}
|
||||
if (stalledSince === null) {
|
||||
setStalledSince(Date.now());
|
||||
setLastCompleted(completed);
|
||||
}
|
||||
}, [data, lastCompleted, stalledSince]);
|
||||
|
||||
const elapsedMs = stalledSince !== null ? Math.max(0, now - stalledSince) : 0;
|
||||
const isStale = stalledSince !== null && elapsedMs > staleThresholdMs;
|
||||
return { stalledSince, elapsedMs, isStale };
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { httpFetch } from "@/lib/http";
|
||||
|
||||
export interface DiscoveredInstance {
|
||||
port: number;
|
||||
base_url: string;
|
||||
}
|
||||
|
||||
export function isTauri(): boolean {
|
||||
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe localhost ports for running Honcho instances. Desktop-only — the web
|
||||
* build can't port-scan due to CORS, so this returns an empty list when not
|
||||
* running inside Tauri.
|
||||
*/
|
||||
export async function discoverHonchoInstances(): Promise<DiscoveredInstance[]> {
|
||||
if (!isTauri()) return [];
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
return invoke<DiscoveredInstance[]>("discover_honcho_instances");
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a friendly agent name from a workspace ID. Honcho workspaces follow
|
||||
* the convention `<agent>-<camp>` (e.g. "neo-personal", "jeeves-codewalnut"),
|
||||
* so we take the prefix and capitalize it. Falls back to the full ID if no
|
||||
* hyphen is present.
|
||||
*/
|
||||
export function deriveNameFromWorkspaceId(workspaceId: string): string {
|
||||
const first = workspaceId.split("-")[0] || workspaceId;
|
||||
return first.charAt(0).toUpperCase() + first.slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe a discovered instance for its first workspace and derive a suggested
|
||||
* name from it. Returns `null` if the instance has no workspaces or the
|
||||
* request fails.
|
||||
*/
|
||||
export async function suggestNameForInstance(baseUrl: string): Promise<string | null> {
|
||||
try {
|
||||
const res = await httpFetch(`${baseUrl}/v3/workspaces/list?page=1&page_size=1`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
signal: AbortSignal.timeout(2000),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = (await res.json()) as { items?: Array<{ id?: string }> };
|
||||
const wsId = data.items?.[0]?.id;
|
||||
if (typeof wsId === "string" && wsId.length > 0) {
|
||||
return deriveNameFromWorkspaceId(wsId);
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
import type { components } from "@/api/schema.d.ts";
|
||||
|
||||
type ApiConclusion = components["schemas"]["Conclusion"];
|
||||
|
||||
export type ConclusionType = "explicit" | "deductive" | "inductive";
|
||||
|
||||
export const CONCLUSION_TYPES: readonly ConclusionType[] = [
|
||||
"explicit",
|
||||
"deductive",
|
||||
"inductive",
|
||||
] as const;
|
||||
|
||||
// The generated OpenAPI schema does not yet expose `conclusion_type`, `premises`, or
|
||||
// `reasoning_tree` (Honcho migration f1a2b3c4d5e6 added the columns but the response
|
||||
// schema hasn't been regenerated client-side). We declare them as optional here so
|
||||
// the UI consumes them when present and degrades gracefully when absent.
|
||||
export type ExtendedConclusion = ApiConclusion & {
|
||||
conclusion_type?: ConclusionType | null;
|
||||
premises?: string[] | null;
|
||||
reasoning_tree?: ReasoningTreeNode | null;
|
||||
};
|
||||
|
||||
export interface ReasoningTreeNode {
|
||||
conclusion_id: string;
|
||||
premises?: ReasoningTreeNode[];
|
||||
}
|
||||
|
||||
export interface Dream {
|
||||
id: string;
|
||||
observer_id: string;
|
||||
observed_id: string | null;
|
||||
session_id: string | null;
|
||||
earliestMs: number;
|
||||
latestMs: number;
|
||||
earliestIso: string;
|
||||
latestIso: string;
|
||||
conclusions: ExtendedConclusion[];
|
||||
}
|
||||
|
||||
export interface DreamCounts {
|
||||
explicit: number;
|
||||
deductive: number;
|
||||
inductive: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface ClusterOptions {
|
||||
/** Max gap (ms) between adjacent conclusions in the same dream. Defaults to 60s. */
|
||||
gapMs?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_GAP_MS = 60_000;
|
||||
|
||||
export function inferConclusionType(c: ExtendedConclusion): ConclusionType {
|
||||
return c.conclusion_type ?? "explicit";
|
||||
}
|
||||
|
||||
export function dreamCounts(dream: Pick<Dream, "conclusions">): DreamCounts {
|
||||
const counts: DreamCounts = { explicit: 0, deductive: 0, inductive: 0, total: 0 };
|
||||
for (const c of dream.conclusions) {
|
||||
counts[inferConclusionType(c)]++;
|
||||
counts.total++;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
function parseMs(iso: string): number {
|
||||
const t = Date.parse(iso);
|
||||
return Number.isFinite(t) ? t : 0;
|
||||
}
|
||||
|
||||
function dreamIdFor(observer: string, observed: string | null, earliestIso: string): string {
|
||||
return `${observer}|${observed ?? ""}|${earliestIso}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group conclusions into "dreams" — bursts of conclusions for the same
|
||||
* (observer, observed) pair within a short time window.
|
||||
*
|
||||
* Algorithm: walk conclusions newest-first; for each peer pair, keep an "open"
|
||||
* dream open as long as the next conclusion is within `gapMs` of the oldest
|
||||
* timestamp in the dream. When the gap exceeds `gapMs`, close the dream and
|
||||
* start a new one for that pair.
|
||||
*/
|
||||
export function clusterConclusionsIntoDreams(
|
||||
conclusions: ExtendedConclusion[],
|
||||
options: ClusterOptions = {},
|
||||
): Dream[] {
|
||||
const gapMs = options.gapMs ?? DEFAULT_GAP_MS;
|
||||
if (conclusions.length === 0) return [];
|
||||
|
||||
const sorted = [...conclusions].sort((a, b) => parseMs(b.created_at) - parseMs(a.created_at));
|
||||
const openByPair = new Map<string, Dream>();
|
||||
const result: Dream[] = [];
|
||||
|
||||
for (const c of sorted) {
|
||||
const observed = c.observed_id ?? null;
|
||||
const pairKey = `${c.observer_id}::${observed ?? ""}`;
|
||||
const t = parseMs(c.created_at);
|
||||
const open = openByPair.get(pairKey);
|
||||
|
||||
if (open && open.earliestMs - t <= gapMs) {
|
||||
open.conclusions.push(c);
|
||||
if (t < open.earliestMs) {
|
||||
open.earliestMs = t;
|
||||
open.earliestIso = c.created_at;
|
||||
open.id = dreamIdFor(c.observer_id, observed, c.created_at);
|
||||
}
|
||||
if (t > open.latestMs) {
|
||||
open.latestMs = t;
|
||||
open.latestIso = c.created_at;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const dream: Dream = {
|
||||
id: dreamIdFor(c.observer_id, observed, c.created_at),
|
||||
observer_id: c.observer_id,
|
||||
observed_id: observed,
|
||||
session_id: c.session_id ?? null,
|
||||
earliestMs: t,
|
||||
latestMs: t,
|
||||
earliestIso: c.created_at,
|
||||
latestIso: c.created_at,
|
||||
conclusions: [c],
|
||||
};
|
||||
openByPair.set(pairKey, dream);
|
||||
result.push(dream);
|
||||
}
|
||||
|
||||
return result.sort((a, b) => b.latestMs - a.latestMs);
|
||||
}
|
||||
|
||||
export function buildPremiseIndex(
|
||||
conclusions: ExtendedConclusion[],
|
||||
): Map<string, ExtendedConclusion> {
|
||||
const index = new Map<string, ExtendedConclusion>();
|
||||
for (const c of conclusions) index.set(c.id, c);
|
||||
return index;
|
||||
}
|
||||
|
||||
export interface PremiseNode {
|
||||
conclusion: ExtendedConclusion | null;
|
||||
conclusionId: string;
|
||||
depth: number;
|
||||
children: PremiseNode[];
|
||||
cycle: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand the premise tree for a conclusion. Walks `reasoning_tree` if present,
|
||||
* otherwise falls back to the flat `premises` ID list. Cycle-safe via a visited
|
||||
* set; `maxDepth` defaults to 8.
|
||||
*/
|
||||
export function expandPremiseTree(
|
||||
conclusionId: string,
|
||||
index: Map<string, ExtendedConclusion>,
|
||||
maxDepth = 8,
|
||||
): PremiseNode {
|
||||
return walk(conclusionId, index, new Set<string>(), 0, maxDepth);
|
||||
}
|
||||
|
||||
function walk(
|
||||
conclusionId: string,
|
||||
index: Map<string, ExtendedConclusion>,
|
||||
visited: Set<string>,
|
||||
depth: number,
|
||||
maxDepth: number,
|
||||
): PremiseNode {
|
||||
if (visited.has(conclusionId)) {
|
||||
return {
|
||||
conclusion: index.get(conclusionId) ?? null,
|
||||
conclusionId,
|
||||
depth,
|
||||
children: [],
|
||||
cycle: true,
|
||||
};
|
||||
}
|
||||
const conclusion = index.get(conclusionId) ?? null;
|
||||
if (!conclusion || depth >= maxDepth) {
|
||||
return { conclusion, conclusionId, depth, children: [], cycle: false };
|
||||
}
|
||||
|
||||
const nextVisited = new Set(visited);
|
||||
nextVisited.add(conclusionId);
|
||||
|
||||
let children: PremiseNode[] = [];
|
||||
if (conclusion.reasoning_tree?.premises?.length) {
|
||||
children = conclusion.reasoning_tree.premises.map((node) =>
|
||||
walk(node.conclusion_id, index, nextVisited, depth + 1, maxDepth),
|
||||
);
|
||||
} else if (conclusion.premises?.length) {
|
||||
children = conclusion.premises.map((id) => walk(id, index, nextVisited, depth + 1, maxDepth));
|
||||
}
|
||||
return { conclusion, conclusionId, depth, children, cycle: false };
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]", "0.0.0.0"]);
|
||||
|
||||
function parseUrl(value: string): URL | null {
|
||||
try {
|
||||
return new URL(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isLoopbackUrl(value: string): boolean {
|
||||
const parsed = parseUrl(value);
|
||||
if (!parsed) return false;
|
||||
return LOOPBACK_HOSTS.has(parsed.hostname.toLowerCase());
|
||||
}
|
||||
|
||||
export function isHttpOrHttpsUrl(value: string): boolean {
|
||||
const parsed = parseUrl(value);
|
||||
return parsed?.protocol === "https:" || parsed?.protocol === "http:";
|
||||
}
|
||||
|
||||
export function isSafeExternalUrl(value: string): boolean {
|
||||
const parsed = parseUrl(value);
|
||||
return parsed?.protocol === "https:" || parsed?.protocol === "http:";
|
||||
}
|
||||
|
||||
export function isSecureTokenTransport(baseUrl: string): boolean {
|
||||
const parsed = parseUrl(baseUrl);
|
||||
if (!parsed) return false;
|
||||
if (parsed.protocol === "https:") return true;
|
||||
if (parsed.protocol === "http:" && LOOPBACK_HOSTS.has(parsed.hostname.toLowerCase())) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function tokenTransportError(baseUrl: string): string | null {
|
||||
if (isSecureTokenTransport(baseUrl)) return null;
|
||||
return "API tokens require HTTPS unless connecting to localhost.";
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const STORE_KEY = "openconcho:seed-kits";
|
||||
|
||||
export const seedKitSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1, { message: "Name is required" }),
|
||||
description: z.string().default(""),
|
||||
lines: z.array(z.string()).default([]),
|
||||
});
|
||||
|
||||
export type SeedKit = z.infer<typeof seedKitSchema>;
|
||||
|
||||
const storeSchema = z.array(seedKitSchema);
|
||||
|
||||
const BUILTIN_PREFIX = "builtin:";
|
||||
|
||||
export const BUILTIN_KITS: SeedKit[] = [
|
||||
{
|
||||
id: `${BUILTIN_PREFIX}personal-core`,
|
||||
name: "Personal core",
|
||||
description: "Name, email, preferred form of address, and role.",
|
||||
lines: ["Name: ", "Email: ", "Preferred address: ", "Role: "],
|
||||
},
|
||||
{
|
||||
id: `${BUILTIN_PREFIX}github-social`,
|
||||
name: "GitHub / social",
|
||||
description: "Common developer-identity handles.",
|
||||
lines: ["Github: ", "Linkedin: ", "X: ", "Twitter: "],
|
||||
},
|
||||
{
|
||||
id: `${BUILTIN_PREFIX}work-context`,
|
||||
name: "Work context",
|
||||
description: "Employer, role, and reporting line.",
|
||||
lines: ["Employer: ", "Role: ", "Reports to: "],
|
||||
},
|
||||
];
|
||||
|
||||
export function isBuiltinKit(kit: Pick<SeedKit, "id">): boolean {
|
||||
return kit.id.startsWith(BUILTIN_PREFIX);
|
||||
}
|
||||
|
||||
function newId(): string {
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `kit_${Math.random().toString(36).slice(2)}_${Date.now()}`;
|
||||
}
|
||||
|
||||
export function loadUserKits(): SeedKit[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORE_KEY);
|
||||
if (!raw) return [];
|
||||
return storeSchema.parse(JSON.parse(raw));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function saveUserKits(kits: SeedKit[]): void {
|
||||
localStorage.setItem(STORE_KEY, JSON.stringify(kits));
|
||||
}
|
||||
|
||||
export function allKits(): SeedKit[] {
|
||||
return [...BUILTIN_KITS, ...loadUserKits()];
|
||||
}
|
||||
|
||||
export function createKit(input: Omit<SeedKit, "id">): SeedKit {
|
||||
const kits = loadUserKits();
|
||||
const kit: SeedKit = { id: newId(), ...input };
|
||||
saveUserKits([...kits, kit]);
|
||||
return kit;
|
||||
}
|
||||
|
||||
export function updateKit(id: string, patch: Partial<Omit<SeedKit, "id">>): void {
|
||||
if (id.startsWith(BUILTIN_PREFIX)) return;
|
||||
const kits = loadUserKits();
|
||||
const idx = kits.findIndex((k) => k.id === id);
|
||||
if (idx < 0) return;
|
||||
kits[idx] = { ...kits[idx], ...patch };
|
||||
saveUserKits(kits);
|
||||
}
|
||||
|
||||
export function deleteKit(id: string): void {
|
||||
if (id.startsWith(BUILTIN_PREFIX)) return;
|
||||
saveUserKits(loadUserKits().filter((k) => k.id !== id));
|
||||
}
|
||||
|
||||
// ─── Merge logic ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Lines look like "Name: Ben" — the prefix is everything before the first `:`.
|
||||
* Used for additive-replace merge so an incoming line updates the matching
|
||||
* existing line instead of duplicating it.
|
||||
*/
|
||||
export function linePrefix(line: string): string | null {
|
||||
const i = line.indexOf(":");
|
||||
if (i <= 0) return null;
|
||||
const prefix = line.slice(0, i).trim();
|
||||
if (!prefix) return null;
|
||||
return prefix.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge `incoming` into `existing`. Lines with a prefix dedupe by prefix
|
||||
* (incoming wins, in-place). Lines without a prefix dedupe by exact match.
|
||||
* The merge is additive — nothing in `existing` is dropped unless replaced.
|
||||
*/
|
||||
export function mergeCardLines(existing: string[], incoming: string[]): string[] {
|
||||
const incomingByPrefix = new Map<string, string>();
|
||||
const incomingExact = new Set<string>();
|
||||
for (const line of incoming) {
|
||||
const p = linePrefix(line);
|
||||
if (p) incomingByPrefix.set(p, line);
|
||||
else incomingExact.add(line);
|
||||
}
|
||||
|
||||
const out: string[] = [];
|
||||
const placedPrefixes = new Set<string>();
|
||||
const placedExact = new Set<string>();
|
||||
|
||||
for (const line of existing) {
|
||||
const p = linePrefix(line);
|
||||
if (p && incomingByPrefix.has(p)) {
|
||||
out.push(incomingByPrefix.get(p) as string);
|
||||
placedPrefixes.add(p);
|
||||
} else {
|
||||
out.push(line);
|
||||
if (!p) placedExact.add(line);
|
||||
}
|
||||
}
|
||||
|
||||
for (const line of incoming) {
|
||||
const p = linePrefix(line);
|
||||
if (p) {
|
||||
if (placedPrefixes.has(p)) continue;
|
||||
out.push(line);
|
||||
placedPrefixes.add(p);
|
||||
} else {
|
||||
if (placedExact.has(line)) continue;
|
||||
out.push(line);
|
||||
placedExact.add(line);
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
@@ -11,20 +11,15 @@
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as WorkspacesRouteImport } from './routes/workspaces'
|
||||
import { Route as SettingsRouteImport } from './routes/settings'
|
||||
import { Route as SeedKitsRouteImport } from './routes/seed-kits'
|
||||
import { Route as ExploreRouteImport } from './routes/explore'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as WorkspacesWorkspaceIdRouteImport } from './routes/workspaces_.$workspaceId'
|
||||
import { Route as DevDreamProgressRouteImport } from './routes/_dev.dream-progress'
|
||||
import { Route as WorkspacesWorkspaceIdWebhooksRouteImport } from './routes/workspaces_.$workspaceId_.webhooks'
|
||||
import { Route as WorkspacesWorkspaceIdSessionsRouteImport } from './routes/workspaces_.$workspaceId_.sessions'
|
||||
import { Route as WorkspacesWorkspaceIdQueueRouteImport } from './routes/workspaces_.$workspaceId_.queue'
|
||||
import { Route as WorkspacesWorkspaceIdPeersRouteImport } from './routes/workspaces_.$workspaceId_.peers'
|
||||
import { Route as WorkspacesWorkspaceIdDreamsRouteImport } from './routes/workspaces_.$workspaceId_.dreams'
|
||||
import { Route as WorkspacesWorkspaceIdConclusionsRouteImport } from './routes/workspaces_.$workspaceId_.conclusions'
|
||||
import { Route as WorkspacesWorkspaceIdSessionsSessionIdRouteImport } from './routes/workspaces_.$workspaceId_.sessions_.$sessionId'
|
||||
import { Route as WorkspacesWorkspaceIdPeersPeerIdRouteImport } from './routes/workspaces_.$workspaceId_.peers_.$peerId'
|
||||
import { Route as WorkspacesWorkspaceIdPeersPeerIdPlaygroundRouteImport } from './routes/workspaces_.$workspaceId_.peers_.$peerId_.playground'
|
||||
import { Route as WorkspacesWorkspaceIdPeersPeerIdChatRouteImport } from './routes/workspaces_.$workspaceId_.peers_.$peerId_.chat'
|
||||
|
||||
const WorkspacesRoute = WorkspacesRouteImport.update({
|
||||
@@ -37,11 +32,6 @@ const SettingsRoute = SettingsRouteImport.update({
|
||||
path: '/settings',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const SeedKitsRoute = SeedKitsRouteImport.update({
|
||||
id: '/seed-kits',
|
||||
path: '/seed-kits',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ExploreRoute = ExploreRouteImport.update({
|
||||
id: '/explore',
|
||||
path: '/explore',
|
||||
@@ -57,11 +47,6 @@ const WorkspacesWorkspaceIdRoute = WorkspacesWorkspaceIdRouteImport.update({
|
||||
path: '/workspaces/$workspaceId',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const DevDreamProgressRoute = DevDreamProgressRouteImport.update({
|
||||
id: '/_dev/dream-progress',
|
||||
path: '/dream-progress',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const WorkspacesWorkspaceIdWebhooksRoute =
|
||||
WorkspacesWorkspaceIdWebhooksRouteImport.update({
|
||||
id: '/workspaces_/$workspaceId_/webhooks',
|
||||
@@ -74,24 +59,12 @@ const WorkspacesWorkspaceIdSessionsRoute =
|
||||
path: '/workspaces/$workspaceId/sessions',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const WorkspacesWorkspaceIdQueueRoute =
|
||||
WorkspacesWorkspaceIdQueueRouteImport.update({
|
||||
id: '/workspaces_/$workspaceId_/queue',
|
||||
path: '/workspaces/$workspaceId/queue',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const WorkspacesWorkspaceIdPeersRoute =
|
||||
WorkspacesWorkspaceIdPeersRouteImport.update({
|
||||
id: '/workspaces_/$workspaceId_/peers',
|
||||
path: '/workspaces/$workspaceId/peers',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const WorkspacesWorkspaceIdDreamsRoute =
|
||||
WorkspacesWorkspaceIdDreamsRouteImport.update({
|
||||
id: '/workspaces_/$workspaceId_/dreams',
|
||||
path: '/workspaces/$workspaceId/dreams',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const WorkspacesWorkspaceIdConclusionsRoute =
|
||||
WorkspacesWorkspaceIdConclusionsRouteImport.update({
|
||||
id: '/workspaces_/$workspaceId_/conclusions',
|
||||
@@ -110,12 +83,6 @@ const WorkspacesWorkspaceIdPeersPeerIdRoute =
|
||||
path: '/workspaces/$workspaceId/peers/$peerId',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const WorkspacesWorkspaceIdPeersPeerIdPlaygroundRoute =
|
||||
WorkspacesWorkspaceIdPeersPeerIdPlaygroundRouteImport.update({
|
||||
id: '/workspaces_/$workspaceId_/peers_/$peerId_/playground',
|
||||
path: '/workspaces/$workspaceId/peers/$peerId/playground',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const WorkspacesWorkspaceIdPeersPeerIdChatRoute =
|
||||
WorkspacesWorkspaceIdPeersPeerIdChatRouteImport.update({
|
||||
id: '/workspaces_/$workspaceId_/peers_/$peerId_/chat',
|
||||
@@ -126,139 +93,104 @@ const WorkspacesWorkspaceIdPeersPeerIdChatRoute =
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/explore': typeof ExploreRoute
|
||||
'/seed-kits': typeof SeedKitsRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/workspaces': typeof WorkspacesRoute
|
||||
'/dream-progress': typeof DevDreamProgressRoute
|
||||
'/workspaces/$workspaceId': typeof WorkspacesWorkspaceIdRoute
|
||||
'/workspaces/$workspaceId/conclusions': typeof WorkspacesWorkspaceIdConclusionsRoute
|
||||
'/workspaces/$workspaceId/dreams': typeof WorkspacesWorkspaceIdDreamsRoute
|
||||
'/workspaces/$workspaceId/peers': typeof WorkspacesWorkspaceIdPeersRoute
|
||||
'/workspaces/$workspaceId/queue': typeof WorkspacesWorkspaceIdQueueRoute
|
||||
'/workspaces/$workspaceId/sessions': typeof WorkspacesWorkspaceIdSessionsRoute
|
||||
'/workspaces/$workspaceId/webhooks': typeof WorkspacesWorkspaceIdWebhooksRoute
|
||||
'/workspaces/$workspaceId/peers/$peerId': typeof WorkspacesWorkspaceIdPeersPeerIdRoute
|
||||
'/workspaces/$workspaceId/sessions/$sessionId': typeof WorkspacesWorkspaceIdSessionsSessionIdRoute
|
||||
'/workspaces/$workspaceId/peers/$peerId/chat': typeof WorkspacesWorkspaceIdPeersPeerIdChatRoute
|
||||
'/workspaces/$workspaceId/peers/$peerId/playground': typeof WorkspacesWorkspaceIdPeersPeerIdPlaygroundRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/explore': typeof ExploreRoute
|
||||
'/seed-kits': typeof SeedKitsRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/workspaces': typeof WorkspacesRoute
|
||||
'/dream-progress': typeof DevDreamProgressRoute
|
||||
'/workspaces/$workspaceId': typeof WorkspacesWorkspaceIdRoute
|
||||
'/workspaces/$workspaceId/conclusions': typeof WorkspacesWorkspaceIdConclusionsRoute
|
||||
'/workspaces/$workspaceId/dreams': typeof WorkspacesWorkspaceIdDreamsRoute
|
||||
'/workspaces/$workspaceId/peers': typeof WorkspacesWorkspaceIdPeersRoute
|
||||
'/workspaces/$workspaceId/queue': typeof WorkspacesWorkspaceIdQueueRoute
|
||||
'/workspaces/$workspaceId/sessions': typeof WorkspacesWorkspaceIdSessionsRoute
|
||||
'/workspaces/$workspaceId/webhooks': typeof WorkspacesWorkspaceIdWebhooksRoute
|
||||
'/workspaces/$workspaceId/peers/$peerId': typeof WorkspacesWorkspaceIdPeersPeerIdRoute
|
||||
'/workspaces/$workspaceId/sessions/$sessionId': typeof WorkspacesWorkspaceIdSessionsSessionIdRoute
|
||||
'/workspaces/$workspaceId/peers/$peerId/chat': typeof WorkspacesWorkspaceIdPeersPeerIdChatRoute
|
||||
'/workspaces/$workspaceId/peers/$peerId/playground': typeof WorkspacesWorkspaceIdPeersPeerIdPlaygroundRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/explore': typeof ExploreRoute
|
||||
'/seed-kits': typeof SeedKitsRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/workspaces': typeof WorkspacesRoute
|
||||
'/_dev/dream-progress': typeof DevDreamProgressRoute
|
||||
'/workspaces_/$workspaceId': typeof WorkspacesWorkspaceIdRoute
|
||||
'/workspaces_/$workspaceId_/conclusions': typeof WorkspacesWorkspaceIdConclusionsRoute
|
||||
'/workspaces_/$workspaceId_/dreams': typeof WorkspacesWorkspaceIdDreamsRoute
|
||||
'/workspaces_/$workspaceId_/peers': typeof WorkspacesWorkspaceIdPeersRoute
|
||||
'/workspaces_/$workspaceId_/queue': typeof WorkspacesWorkspaceIdQueueRoute
|
||||
'/workspaces_/$workspaceId_/sessions': typeof WorkspacesWorkspaceIdSessionsRoute
|
||||
'/workspaces_/$workspaceId_/webhooks': typeof WorkspacesWorkspaceIdWebhooksRoute
|
||||
'/workspaces_/$workspaceId_/peers_/$peerId': typeof WorkspacesWorkspaceIdPeersPeerIdRoute
|
||||
'/workspaces_/$workspaceId_/sessions_/$sessionId': typeof WorkspacesWorkspaceIdSessionsSessionIdRoute
|
||||
'/workspaces_/$workspaceId_/peers_/$peerId_/chat': typeof WorkspacesWorkspaceIdPeersPeerIdChatRoute
|
||||
'/workspaces_/$workspaceId_/peers_/$peerId_/playground': typeof WorkspacesWorkspaceIdPeersPeerIdPlaygroundRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/explore'
|
||||
| '/seed-kits'
|
||||
| '/settings'
|
||||
| '/workspaces'
|
||||
| '/dream-progress'
|
||||
| '/workspaces/$workspaceId'
|
||||
| '/workspaces/$workspaceId/conclusions'
|
||||
| '/workspaces/$workspaceId/dreams'
|
||||
| '/workspaces/$workspaceId/peers'
|
||||
| '/workspaces/$workspaceId/queue'
|
||||
| '/workspaces/$workspaceId/sessions'
|
||||
| '/workspaces/$workspaceId/webhooks'
|
||||
| '/workspaces/$workspaceId/peers/$peerId'
|
||||
| '/workspaces/$workspaceId/sessions/$sessionId'
|
||||
| '/workspaces/$workspaceId/peers/$peerId/chat'
|
||||
| '/workspaces/$workspaceId/peers/$peerId/playground'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
| '/explore'
|
||||
| '/seed-kits'
|
||||
| '/settings'
|
||||
| '/workspaces'
|
||||
| '/dream-progress'
|
||||
| '/workspaces/$workspaceId'
|
||||
| '/workspaces/$workspaceId/conclusions'
|
||||
| '/workspaces/$workspaceId/dreams'
|
||||
| '/workspaces/$workspaceId/peers'
|
||||
| '/workspaces/$workspaceId/queue'
|
||||
| '/workspaces/$workspaceId/sessions'
|
||||
| '/workspaces/$workspaceId/webhooks'
|
||||
| '/workspaces/$workspaceId/peers/$peerId'
|
||||
| '/workspaces/$workspaceId/sessions/$sessionId'
|
||||
| '/workspaces/$workspaceId/peers/$peerId/chat'
|
||||
| '/workspaces/$workspaceId/peers/$peerId/playground'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/explore'
|
||||
| '/seed-kits'
|
||||
| '/settings'
|
||||
| '/workspaces'
|
||||
| '/_dev/dream-progress'
|
||||
| '/workspaces_/$workspaceId'
|
||||
| '/workspaces_/$workspaceId_/conclusions'
|
||||
| '/workspaces_/$workspaceId_/dreams'
|
||||
| '/workspaces_/$workspaceId_/peers'
|
||||
| '/workspaces_/$workspaceId_/queue'
|
||||
| '/workspaces_/$workspaceId_/sessions'
|
||||
| '/workspaces_/$workspaceId_/webhooks'
|
||||
| '/workspaces_/$workspaceId_/peers_/$peerId'
|
||||
| '/workspaces_/$workspaceId_/sessions_/$sessionId'
|
||||
| '/workspaces_/$workspaceId_/peers_/$peerId_/chat'
|
||||
| '/workspaces_/$workspaceId_/peers_/$peerId_/playground'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
ExploreRoute: typeof ExploreRoute
|
||||
SeedKitsRoute: typeof SeedKitsRoute
|
||||
SettingsRoute: typeof SettingsRoute
|
||||
WorkspacesRoute: typeof WorkspacesRoute
|
||||
DevDreamProgressRoute: typeof DevDreamProgressRoute
|
||||
WorkspacesWorkspaceIdRoute: typeof WorkspacesWorkspaceIdRoute
|
||||
WorkspacesWorkspaceIdConclusionsRoute: typeof WorkspacesWorkspaceIdConclusionsRoute
|
||||
WorkspacesWorkspaceIdDreamsRoute: typeof WorkspacesWorkspaceIdDreamsRoute
|
||||
WorkspacesWorkspaceIdPeersRoute: typeof WorkspacesWorkspaceIdPeersRoute
|
||||
WorkspacesWorkspaceIdQueueRoute: typeof WorkspacesWorkspaceIdQueueRoute
|
||||
WorkspacesWorkspaceIdSessionsRoute: typeof WorkspacesWorkspaceIdSessionsRoute
|
||||
WorkspacesWorkspaceIdWebhooksRoute: typeof WorkspacesWorkspaceIdWebhooksRoute
|
||||
WorkspacesWorkspaceIdPeersPeerIdRoute: typeof WorkspacesWorkspaceIdPeersPeerIdRoute
|
||||
WorkspacesWorkspaceIdSessionsSessionIdRoute: typeof WorkspacesWorkspaceIdSessionsSessionIdRoute
|
||||
WorkspacesWorkspaceIdPeersPeerIdChatRoute: typeof WorkspacesWorkspaceIdPeersPeerIdChatRoute
|
||||
WorkspacesWorkspaceIdPeersPeerIdPlaygroundRoute: typeof WorkspacesWorkspaceIdPeersPeerIdPlaygroundRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
@@ -277,13 +209,6 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof SettingsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/seed-kits': {
|
||||
id: '/seed-kits'
|
||||
path: '/seed-kits'
|
||||
fullPath: '/seed-kits'
|
||||
preLoaderRoute: typeof SeedKitsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/explore': {
|
||||
id: '/explore'
|
||||
path: '/explore'
|
||||
@@ -305,13 +230,6 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof WorkspacesWorkspaceIdRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/_dev/dream-progress': {
|
||||
id: '/_dev/dream-progress'
|
||||
path: '/dream-progress'
|
||||
fullPath: '/dream-progress'
|
||||
preLoaderRoute: typeof DevDreamProgressRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/workspaces_/$workspaceId_/webhooks': {
|
||||
id: '/workspaces_/$workspaceId_/webhooks'
|
||||
path: '/workspaces/$workspaceId/webhooks'
|
||||
@@ -326,13 +244,6 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof WorkspacesWorkspaceIdSessionsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/workspaces_/$workspaceId_/queue': {
|
||||
id: '/workspaces_/$workspaceId_/queue'
|
||||
path: '/workspaces/$workspaceId/queue'
|
||||
fullPath: '/workspaces/$workspaceId/queue'
|
||||
preLoaderRoute: typeof WorkspacesWorkspaceIdQueueRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/workspaces_/$workspaceId_/peers': {
|
||||
id: '/workspaces_/$workspaceId_/peers'
|
||||
path: '/workspaces/$workspaceId/peers'
|
||||
@@ -340,13 +251,6 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof WorkspacesWorkspaceIdPeersRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/workspaces_/$workspaceId_/dreams': {
|
||||
id: '/workspaces_/$workspaceId_/dreams'
|
||||
path: '/workspaces/$workspaceId/dreams'
|
||||
fullPath: '/workspaces/$workspaceId/dreams'
|
||||
preLoaderRoute: typeof WorkspacesWorkspaceIdDreamsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/workspaces_/$workspaceId_/conclusions': {
|
||||
id: '/workspaces_/$workspaceId_/conclusions'
|
||||
path: '/workspaces/$workspaceId/conclusions'
|
||||
@@ -368,13 +272,6 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof WorkspacesWorkspaceIdPeersPeerIdRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/workspaces_/$workspaceId_/peers_/$peerId_/playground': {
|
||||
id: '/workspaces_/$workspaceId_/peers_/$peerId_/playground'
|
||||
path: '/workspaces/$workspaceId/peers/$peerId/playground'
|
||||
fullPath: '/workspaces/$workspaceId/peers/$peerId/playground'
|
||||
preLoaderRoute: typeof WorkspacesWorkspaceIdPeersPeerIdPlaygroundRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/workspaces_/$workspaceId_/peers_/$peerId_/chat': {
|
||||
id: '/workspaces_/$workspaceId_/peers_/$peerId_/chat'
|
||||
path: '/workspaces/$workspaceId/peers/$peerId/chat'
|
||||
@@ -388,15 +285,11 @@ declare module '@tanstack/react-router' {
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
ExploreRoute: ExploreRoute,
|
||||
SeedKitsRoute: SeedKitsRoute,
|
||||
SettingsRoute: SettingsRoute,
|
||||
WorkspacesRoute: WorkspacesRoute,
|
||||
DevDreamProgressRoute: DevDreamProgressRoute,
|
||||
WorkspacesWorkspaceIdRoute: WorkspacesWorkspaceIdRoute,
|
||||
WorkspacesWorkspaceIdConclusionsRoute: WorkspacesWorkspaceIdConclusionsRoute,
|
||||
WorkspacesWorkspaceIdDreamsRoute: WorkspacesWorkspaceIdDreamsRoute,
|
||||
WorkspacesWorkspaceIdPeersRoute: WorkspacesWorkspaceIdPeersRoute,
|
||||
WorkspacesWorkspaceIdQueueRoute: WorkspacesWorkspaceIdQueueRoute,
|
||||
WorkspacesWorkspaceIdSessionsRoute: WorkspacesWorkspaceIdSessionsRoute,
|
||||
WorkspacesWorkspaceIdWebhooksRoute: WorkspacesWorkspaceIdWebhooksRoute,
|
||||
WorkspacesWorkspaceIdPeersPeerIdRoute: WorkspacesWorkspaceIdPeersPeerIdRoute,
|
||||
@@ -404,8 +297,6 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
WorkspacesWorkspaceIdSessionsSessionIdRoute,
|
||||
WorkspacesWorkspaceIdPeersPeerIdChatRoute:
|
||||
WorkspacesWorkspaceIdPeersPeerIdChatRoute,
|
||||
WorkspacesWorkspaceIdPeersPeerIdPlaygroundRoute:
|
||||
WorkspacesWorkspaceIdPeersPeerIdPlaygroundRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
import { createFileRoute, Navigate } from "@tanstack/react-router";
|
||||
import { motion } from "framer-motion";
|
||||
import { Body, PageTitle } from "@/components/ui/typography";
|
||||
import { DreamProgressPanel } from "@/components/workspaces/DreamProgressPanel";
|
||||
import type { StaleQueueState } from "@/hooks/useStaleQueueDetection";
|
||||
|
||||
export const Route = createFileRoute("/_dev/dream-progress")({
|
||||
component: DreamProgressShowcase,
|
||||
});
|
||||
|
||||
const WORKSPACE_ID = "ws_benchmark_alpha";
|
||||
|
||||
const IDLE = {
|
||||
total_work_units: 142,
|
||||
completed_work_units: 142,
|
||||
in_progress_work_units: 0,
|
||||
pending_work_units: 0,
|
||||
sessions: null,
|
||||
};
|
||||
|
||||
const ACTIVE = {
|
||||
total_work_units: 64,
|
||||
completed_work_units: 38,
|
||||
in_progress_work_units: 4,
|
||||
pending_work_units: 22,
|
||||
sessions: {
|
||||
sess_2024_q4_eval_run_07: {
|
||||
total_work_units: 28,
|
||||
completed_work_units: 17,
|
||||
in_progress_work_units: 2,
|
||||
pending_work_units: 9,
|
||||
},
|
||||
sess_2024_q4_eval_run_08: {
|
||||
total_work_units: 24,
|
||||
completed_work_units: 14,
|
||||
in_progress_work_units: 1,
|
||||
pending_work_units: 9,
|
||||
},
|
||||
sess_diagnostics_cold_start: {
|
||||
total_work_units: 12,
|
||||
completed_work_units: 7,
|
||||
in_progress_work_units: 1,
|
||||
pending_work_units: 4,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const STALE = {
|
||||
total_work_units: 18,
|
||||
completed_work_units: 11,
|
||||
in_progress_work_units: 1,
|
||||
pending_work_units: 6,
|
||||
sessions: {
|
||||
sess_induction_specialist_test: {
|
||||
total_work_units: 18,
|
||||
completed_work_units: 11,
|
||||
in_progress_work_units: 1,
|
||||
pending_work_units: 6,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const STALE_OVERRIDE: StaleQueueState = {
|
||||
stalledSince: Date.now() - 35 * 60 * 1000,
|
||||
elapsedMs: 35 * 60 * 1000,
|
||||
isStale: true,
|
||||
};
|
||||
|
||||
function DreamProgressShowcase() {
|
||||
if (!import.meta.env.DEV) {
|
||||
return <Navigate to="/" />;
|
||||
}
|
||||
return (
|
||||
<div className="page-container page-container--wide space-y-8">
|
||||
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }}>
|
||||
<PageTitle>Dream Progress — showcase</PageTitle>
|
||||
<Body className="leading-none">
|
||||
Three states rendered with mock data. DEV-only; used for documentation screenshots.
|
||||
</Body>
|
||||
</motion.div>
|
||||
|
||||
<section>
|
||||
<h3
|
||||
className="text-xs font-mono uppercase tracking-wider mb-3"
|
||||
style={{ color: "var(--text-3)" }}
|
||||
>
|
||||
Idle
|
||||
</h3>
|
||||
<DreamProgressPanel workspaceId={WORKSPACE_ID} data={IDLE} isLoading={false} error={null} />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3
|
||||
className="text-xs font-mono uppercase tracking-wider mb-3"
|
||||
style={{ color: "var(--text-3)" }}
|
||||
>
|
||||
Active
|
||||
</h3>
|
||||
<DreamProgressPanel
|
||||
workspaceId={WORKSPACE_ID}
|
||||
data={ACTIVE}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3
|
||||
className="text-xs font-mono uppercase tracking-wider mb-3"
|
||||
style={{ color: "var(--text-3)" }}
|
||||
>
|
||||
Stalled (>30m)
|
||||
</h3>
|
||||
<DreamProgressPanel
|
||||
workspaceId={WORKSPACE_ID}
|
||||
data={STALE}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
staleOverride={STALE_OVERRIDE}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { SeedKitsView } from "@/components/seed-kits/SeedKitsView";
|
||||
|
||||
export const Route = createFileRoute("/seed-kits")({
|
||||
component: SeedKitsView,
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { DreamList } from "@/components/dreams/DreamList";
|
||||
|
||||
export const Route = createFileRoute("/workspaces_/$workspaceId_/dreams")({
|
||||
component: DreamList,
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { DialecticPlayground } from "@/components/playground/DialecticPlayground";
|
||||
|
||||
export const Route = createFileRoute("/workspaces_/$workspaceId_/peers_/$peerId_/playground")({
|
||||
component: DialecticPlayground,
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
import { createFileRoute, useParams } from "@tanstack/react-router";
|
||||
import { motion } from "framer-motion";
|
||||
import { Activity } from "lucide-react";
|
||||
import { useQueueStatus } from "@/api/queries";
|
||||
import { Breadcrumb } from "@/components/layout/Breadcrumb";
|
||||
import { Body, PageTitle } from "@/components/ui/typography";
|
||||
import { DreamProgressPanel } from "@/components/workspaces/DreamProgressPanel";
|
||||
|
||||
export const Route = createFileRoute("/workspaces_/$workspaceId_/queue")({
|
||||
component: WorkspaceQueuePage,
|
||||
});
|
||||
|
||||
function WorkspaceQueuePage() {
|
||||
const { workspaceId } = useParams({ strict: false }) as { workspaceId: string };
|
||||
const { data, isLoading, error } = useQueueStatus(workspaceId);
|
||||
|
||||
return (
|
||||
<div className="page-container page-container--wide">
|
||||
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }}>
|
||||
<Breadcrumb />
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Activity
|
||||
className="w-5 h-5 flex-shrink-0"
|
||||
style={{ color: "var(--accent)" }}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<PageTitle>Queue & dreams</PageTitle>
|
||||
</div>
|
||||
<Body className="leading-none">
|
||||
Live view of in-flight dream, representation, and summary work
|
||||
</Body>
|
||||
</motion.div>
|
||||
|
||||
<div className="mt-8">
|
||||
<DreamProgressPanel
|
||||
workspaceId={workspaceId}
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
error={error instanceof Error ? error : null}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import { createMemoryHistory, createRouter, RouterProvider } from "@tanstack/rea
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DemoProvider } from "@/context/DemoContext";
|
||||
import { MetadataProvider } from "@/context/MetadataContext";
|
||||
import { useDemo } from "@/hooks/useDemo";
|
||||
import { routeTree } from "@/routeTree.gen";
|
||||
|
||||
@@ -16,10 +15,8 @@ function renderAt(initialPath: string) {
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<DemoProvider>
|
||||
<MetadataProvider>
|
||||
{/* biome-ignore lint/suspicious/noExplicitAny: test router type */}
|
||||
<RouterProvider router={router as any} />
|
||||
</MetadataProvider>
|
||||
{/* biome-ignore lint/suspicious/noExplicitAny: test router type */}
|
||||
<RouterProvider router={router as any} />
|
||||
</DemoProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
@@ -43,10 +40,10 @@ describe("Sidebar/useDemo availability across routes", () => {
|
||||
const { demo } = useDemo();
|
||||
return <span data-testid="demo-flag">{String(demo)}</span>;
|
||||
}
|
||||
// After the fix, DemoProvider and MetadataProvider wrap the app at
|
||||
// the root (main.tsx) so consumers anywhere in the tree resolve.
|
||||
// This test renders a consumer as a sibling of the router under the
|
||||
// same providers the production wiring uses.
|
||||
// After the fix, DemoProvider wraps the app at the root (main.tsx /
|
||||
// __root.tsx) so consumers anywhere in the tree resolve. This test
|
||||
// renders a consumer as a sibling of the router under the same provider
|
||||
// the production wiring uses.
|
||||
localStorage.clear();
|
||||
expect(() => {
|
||||
const router = createRouter({
|
||||
@@ -59,11 +56,9 @@ describe("Sidebar/useDemo availability across routes", () => {
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<DemoProvider>
|
||||
<MetadataProvider>
|
||||
{/* biome-ignore lint/suspicious/noExplicitAny: test router type */}
|
||||
<RouterProvider router={router as any} />
|
||||
<DemoConsumer />
|
||||
</MetadataProvider>
|
||||
{/* biome-ignore lint/suspicious/noExplicitAny: test router type */}
|
||||
<RouterProvider router={router as any} />
|
||||
<DemoConsumer />
|
||||
</DemoProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { deriveNameFromWorkspaceId } from "@/lib/discovery";
|
||||
|
||||
describe("deriveNameFromWorkspaceId", () => {
|
||||
it("capitalizes the prefix before the first hyphen", () => {
|
||||
expect(deriveNameFromWorkspaceId("neo-personal")).toBe("Neo");
|
||||
});
|
||||
|
||||
it("handles a multi-segment workspace id by taking only the first segment", () => {
|
||||
expect(deriveNameFromWorkspaceId("jeeves-codewalnut-work")).toBe("Jeeves");
|
||||
});
|
||||
|
||||
it("falls back to capitalizing the full id when there is no hyphen", () => {
|
||||
expect(deriveNameFromWorkspaceId("standalone")).toBe("Standalone");
|
||||
});
|
||||
});
|
||||
@@ -1,176 +0,0 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
pickQueueRefetchInterval,
|
||||
QUEUE_REFETCH_ACTIVE_MS,
|
||||
QUEUE_REFETCH_IDLE_MS,
|
||||
} from "@/api/queries";
|
||||
import type { components } from "@/api/schema.d.ts";
|
||||
import { STALE_QUEUE_THRESHOLD_MS, useStaleQueueDetection } from "@/hooks/useStaleQueueDetection";
|
||||
|
||||
type QueueStatus = components["schemas"]["QueueStatus"];
|
||||
|
||||
function buildStatus(partial: Partial<QueueStatus>): QueueStatus {
|
||||
return {
|
||||
total_work_units: 0,
|
||||
completed_work_units: 0,
|
||||
in_progress_work_units: 0,
|
||||
pending_work_units: 0,
|
||||
sessions: null,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
describe("pickQueueRefetchInterval", () => {
|
||||
it("backs off to idle interval when no data has loaded yet", () => {
|
||||
expect(pickQueueRefetchInterval(undefined)).toBe(QUEUE_REFETCH_IDLE_MS);
|
||||
});
|
||||
|
||||
it("uses idle interval when no work is queued or in-flight", () => {
|
||||
const data = buildStatus({
|
||||
total_work_units: 5,
|
||||
completed_work_units: 5,
|
||||
});
|
||||
expect(pickQueueRefetchInterval(data)).toBe(QUEUE_REFETCH_IDLE_MS);
|
||||
});
|
||||
|
||||
it("uses active interval when work is in progress", () => {
|
||||
const data = buildStatus({
|
||||
total_work_units: 5,
|
||||
completed_work_units: 2,
|
||||
in_progress_work_units: 1,
|
||||
});
|
||||
expect(pickQueueRefetchInterval(data)).toBe(QUEUE_REFETCH_ACTIVE_MS);
|
||||
});
|
||||
|
||||
it("uses active interval when work is pending even without in-progress", () => {
|
||||
const data = buildStatus({
|
||||
total_work_units: 5,
|
||||
completed_work_units: 0,
|
||||
pending_work_units: 3,
|
||||
});
|
||||
expect(pickQueueRefetchInterval(data)).toBe(QUEUE_REFETCH_ACTIVE_MS);
|
||||
});
|
||||
|
||||
it("active interval is faster than idle interval", () => {
|
||||
// Sanity check on the constants so a future tweak doesn't invert them.
|
||||
expect(QUEUE_REFETCH_ACTIVE_MS).toBeLessThan(QUEUE_REFETCH_IDLE_MS);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useStaleQueueDetection", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-24T12:00:00Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("returns no stall when there's no active work", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useStaleQueueDetection(buildStatus({ total_work_units: 3, completed_work_units: 3 })),
|
||||
);
|
||||
expect(result.current.stalledSince).toBeNull();
|
||||
expect(result.current.isStale).toBe(false);
|
||||
});
|
||||
|
||||
it("does not flag a freshly-started run as stale", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useStaleQueueDetection(
|
||||
buildStatus({
|
||||
total_work_units: 5,
|
||||
completed_work_units: 0,
|
||||
in_progress_work_units: 2,
|
||||
pending_work_units: 3,
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(result.current.stalledSince).not.toBeNull();
|
||||
expect(result.current.isStale).toBe(false);
|
||||
});
|
||||
|
||||
it("surfaces a warning after 30 minutes of in-progress work without forward progress", () => {
|
||||
const stuck = buildStatus({
|
||||
total_work_units: 5,
|
||||
completed_work_units: 0,
|
||||
in_progress_work_units: 2,
|
||||
pending_work_units: 3,
|
||||
});
|
||||
const { result } = renderHook(({ data }) => useStaleQueueDetection(data), {
|
||||
initialProps: { data: stuck },
|
||||
});
|
||||
|
||||
expect(result.current.isStale).toBe(false);
|
||||
|
||||
// Advance past the threshold; the hook's internal tick interval triggers
|
||||
// a re-render so the new elapsed time is reflected.
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(STALE_QUEUE_THRESHOLD_MS + 30_000);
|
||||
});
|
||||
|
||||
expect(result.current.isStale).toBe(true);
|
||||
expect(result.current.elapsedMs).toBeGreaterThan(STALE_QUEUE_THRESHOLD_MS);
|
||||
});
|
||||
|
||||
it("clears the stall anchor when completed_work_units advances", () => {
|
||||
const stuck = buildStatus({
|
||||
total_work_units: 5,
|
||||
completed_work_units: 0,
|
||||
in_progress_work_units: 2,
|
||||
pending_work_units: 3,
|
||||
});
|
||||
const progressed = buildStatus({
|
||||
total_work_units: 5,
|
||||
completed_work_units: 2,
|
||||
in_progress_work_units: 1,
|
||||
pending_work_units: 2,
|
||||
});
|
||||
|
||||
const { result, rerender } = renderHook(({ data }) => useStaleQueueDetection(data), {
|
||||
initialProps: { data: stuck },
|
||||
});
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(STALE_QUEUE_THRESHOLD_MS + 60_000);
|
||||
});
|
||||
expect(result.current.isStale).toBe(true);
|
||||
|
||||
// Forward progress arrives — the stall clock should reset.
|
||||
rerender({ data: progressed });
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(0);
|
||||
});
|
||||
expect(result.current.isStale).toBe(false);
|
||||
});
|
||||
|
||||
it("clears the stall anchor once all work finishes", () => {
|
||||
const stuck = buildStatus({
|
||||
total_work_units: 5,
|
||||
completed_work_units: 0,
|
||||
in_progress_work_units: 2,
|
||||
pending_work_units: 3,
|
||||
});
|
||||
const idle = buildStatus({
|
||||
total_work_units: 5,
|
||||
completed_work_units: 5,
|
||||
});
|
||||
|
||||
const { result, rerender } = renderHook(({ data }) => useStaleQueueDetection(data), {
|
||||
initialProps: { data: stuck },
|
||||
});
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(STALE_QUEUE_THRESHOLD_MS + 60_000);
|
||||
});
|
||||
expect(result.current.isStale).toBe(true);
|
||||
|
||||
rerender({ data: idle });
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(0);
|
||||
});
|
||||
expect(result.current.stalledSince).toBeNull();
|
||||
expect(result.current.isStale).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,240 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildPremiseIndex,
|
||||
clusterConclusionsIntoDreams,
|
||||
dreamCounts,
|
||||
type ExtendedConclusion,
|
||||
expandPremiseTree,
|
||||
} from "@/lib/dreams";
|
||||
|
||||
// Helpers ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function mkConclusion(
|
||||
id: string,
|
||||
createdAt: string,
|
||||
overrides: Partial<ExtendedConclusion> = {},
|
||||
): ExtendedConclusion {
|
||||
return {
|
||||
id,
|
||||
content: `conclusion ${id}`,
|
||||
observer_id: "observer-a",
|
||||
observed_id: "observed-b",
|
||||
session_id: null,
|
||||
created_at: createdAt,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function iso(secondsFromZero: number): string {
|
||||
const base = Date.UTC(2026, 0, 1, 0, 0, 0); // 2026-01-01T00:00:00Z
|
||||
return new Date(base + secondsFromZero * 1000).toISOString();
|
||||
}
|
||||
|
||||
// Clustering ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("clusterConclusionsIntoDreams", () => {
|
||||
it("returns no dreams for empty input", () => {
|
||||
expect(clusterConclusionsIntoDreams([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("groups conclusions within the default 60s window into a single dream", () => {
|
||||
const burst = [
|
||||
mkConclusion("c1", iso(0)),
|
||||
mkConclusion("c2", iso(5)),
|
||||
mkConclusion("c3", iso(15)),
|
||||
mkConclusion("c4", iso(55)),
|
||||
];
|
||||
|
||||
const dreams = clusterConclusionsIntoDreams(burst);
|
||||
|
||||
expect(dreams).toHaveLength(1);
|
||||
expect(dreams[0].conclusions).toHaveLength(4);
|
||||
expect(dreams[0].observer_id).toBe("observer-a");
|
||||
expect(dreams[0].observed_id).toBe("observed-b");
|
||||
expect(dreams[0].earliestIso).toBe(iso(0));
|
||||
expect(dreams[0].latestIso).toBe(iso(55));
|
||||
});
|
||||
|
||||
it("starts a new dream when the gap exceeds the threshold", () => {
|
||||
const conclusions = [
|
||||
mkConclusion("a", iso(0)),
|
||||
mkConclusion("b", iso(20)),
|
||||
// 5 minutes later → new dream
|
||||
mkConclusion("c", iso(20 + 5 * 60)),
|
||||
mkConclusion("d", iso(20 + 5 * 60 + 10)),
|
||||
];
|
||||
|
||||
const dreams = clusterConclusionsIntoDreams(conclusions);
|
||||
|
||||
expect(dreams).toHaveLength(2);
|
||||
// Sorted by latest descending — the newer dream comes first.
|
||||
expect(dreams[0].conclusions.map((c) => c.id).sort()).toEqual(["c", "d"]);
|
||||
expect(dreams[1].conclusions.map((c) => c.id).sort()).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("separates dreams by (observer, observed) pair even when timestamps overlap", () => {
|
||||
const conclusions = [
|
||||
mkConclusion("a1", iso(0), { observer_id: "alice", observed_id: "bob" }),
|
||||
mkConclusion("a2", iso(5), { observer_id: "alice", observed_id: "bob" }),
|
||||
mkConclusion("c1", iso(2), { observer_id: "carol", observed_id: "dan" }),
|
||||
mkConclusion("c2", iso(7), { observer_id: "carol", observed_id: "dan" }),
|
||||
];
|
||||
|
||||
const dreams = clusterConclusionsIntoDreams(conclusions);
|
||||
|
||||
expect(dreams).toHaveLength(2);
|
||||
const aliceDream = dreams.find((d) => d.observer_id === "alice");
|
||||
const carolDream = dreams.find((d) => d.observer_id === "carol");
|
||||
expect(aliceDream?.conclusions).toHaveLength(2);
|
||||
expect(carolDream?.conclusions).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("respects a custom gap window", () => {
|
||||
const conclusions = [
|
||||
mkConclusion("a", iso(0)),
|
||||
mkConclusion("b", iso(120)), // 2 minutes apart
|
||||
];
|
||||
|
||||
const tight = clusterConclusionsIntoDreams(conclusions, { gapMs: 60_000 });
|
||||
expect(tight).toHaveLength(2);
|
||||
|
||||
const loose = clusterConclusionsIntoDreams(conclusions, { gapMs: 5 * 60_000 });
|
||||
expect(loose).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("sorts dreams newest-first by latest timestamp", () => {
|
||||
const olderTs = iso(0);
|
||||
const newerTs = iso(10 * 60); // 10 minutes later
|
||||
const dreams = clusterConclusionsIntoDreams([
|
||||
mkConclusion("old", olderTs),
|
||||
mkConclusion("new", newerTs),
|
||||
]);
|
||||
expect(dreams).toHaveLength(2);
|
||||
expect(dreams[0].latestIso).toBe(newerTs);
|
||||
expect(dreams[1].latestIso).toBe(olderTs);
|
||||
expect(dreams[0].latestMs).toBeGreaterThan(dreams[1].latestMs);
|
||||
});
|
||||
|
||||
it("computes counts by inferred conclusion_type, defaulting unknown to explicit", () => {
|
||||
const conclusions = [
|
||||
mkConclusion("c1", iso(0)),
|
||||
mkConclusion("c2", iso(2), { conclusion_type: "deductive" }),
|
||||
mkConclusion("c3", iso(4), { conclusion_type: "deductive" }),
|
||||
mkConclusion("c4", iso(6), { conclusion_type: "inductive" }),
|
||||
];
|
||||
const [dream] = clusterConclusionsIntoDreams(conclusions);
|
||||
expect(dreamCounts(dream)).toEqual({
|
||||
explicit: 1, // c1 has no type → defaults to explicit
|
||||
deductive: 2,
|
||||
inductive: 1,
|
||||
total: 4,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Premise tree ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("expandPremiseTree", () => {
|
||||
it("returns an empty tree when the conclusion has no premises", () => {
|
||||
const c = mkConclusion("solo", iso(0));
|
||||
const index = buildPremiseIndex([c]);
|
||||
const tree = expandPremiseTree("solo", index);
|
||||
expect(tree.children).toEqual([]);
|
||||
expect(tree.conclusion?.id).toBe("solo");
|
||||
});
|
||||
|
||||
it("expands a flat premises list to direct children", () => {
|
||||
const p1 = mkConclusion("p1", iso(0), { conclusion_type: "explicit" });
|
||||
const p2 = mkConclusion("p2", iso(1), { conclusion_type: "explicit" });
|
||||
const top = mkConclusion("top", iso(5), {
|
||||
conclusion_type: "inductive",
|
||||
premises: ["p1", "p2"],
|
||||
});
|
||||
const index = buildPremiseIndex([p1, p2, top]);
|
||||
|
||||
const tree = expandPremiseTree("top", index);
|
||||
expect(tree.children.map((n) => n.conclusionId)).toEqual(["p1", "p2"]);
|
||||
expect(tree.children.every((n) => n.conclusion !== null)).toBe(true);
|
||||
});
|
||||
|
||||
it("walks a multi-level reasoning_tree recursively", () => {
|
||||
const e1 = mkConclusion("e1", iso(0), { conclusion_type: "explicit" });
|
||||
const e2 = mkConclusion("e2", iso(1), { conclusion_type: "explicit" });
|
||||
const d1 = mkConclusion("d1", iso(2), {
|
||||
conclusion_type: "deductive",
|
||||
reasoning_tree: {
|
||||
conclusion_id: "d1",
|
||||
premises: [{ conclusion_id: "e1" }, { conclusion_id: "e2" }],
|
||||
},
|
||||
});
|
||||
const ind = mkConclusion("ind", iso(3), {
|
||||
conclusion_type: "inductive",
|
||||
reasoning_tree: {
|
||||
conclusion_id: "ind",
|
||||
premises: [{ conclusion_id: "d1" }],
|
||||
},
|
||||
});
|
||||
const index = buildPremiseIndex([e1, e2, d1, ind]);
|
||||
|
||||
const tree = expandPremiseTree("ind", index);
|
||||
expect(tree.children).toHaveLength(1);
|
||||
const deductive = tree.children[0];
|
||||
expect(deductive.conclusionId).toBe("d1");
|
||||
expect(deductive.children.map((n) => n.conclusionId).sort()).toEqual(["e1", "e2"]);
|
||||
});
|
||||
|
||||
it("flags missing premises (e.g., outside the loaded page) without throwing", () => {
|
||||
const top = mkConclusion("top", iso(5), { premises: ["missing"] });
|
||||
const index = buildPremiseIndex([top]);
|
||||
const tree = expandPremiseTree("top", index);
|
||||
expect(tree.children).toHaveLength(1);
|
||||
expect(tree.children[0].conclusion).toBeNull();
|
||||
expect(tree.children[0].conclusionId).toBe("missing");
|
||||
});
|
||||
|
||||
it("detects cycles and stops recursion", () => {
|
||||
const a = mkConclusion("a", iso(0), { premises: ["b"] });
|
||||
const b = mkConclusion("b", iso(1), { premises: ["a"] });
|
||||
const index = buildPremiseIndex([a, b]);
|
||||
const tree = expandPremiseTree("a", index);
|
||||
|
||||
// a → b → a(cycle)
|
||||
expect(tree.children).toHaveLength(1);
|
||||
const bNode = tree.children[0];
|
||||
expect(bNode.conclusionId).toBe("b");
|
||||
expect(bNode.children).toHaveLength(1);
|
||||
const cycleNode = bNode.children[0];
|
||||
expect(cycleNode.conclusionId).toBe("a");
|
||||
expect(cycleNode.cycle).toBe(true);
|
||||
expect(cycleNode.children).toEqual([]);
|
||||
});
|
||||
|
||||
it("stops recursion at maxDepth", () => {
|
||||
// a → b → c → d, expand with maxDepth=2: tree depth should not exceed 2
|
||||
const d = mkConclusion("d", iso(0));
|
||||
const c = mkConclusion("c", iso(1), { premises: ["d"] });
|
||||
const b = mkConclusion("b", iso(2), { premises: ["c"] });
|
||||
const a = mkConclusion("a", iso(3), { premises: ["b"] });
|
||||
const index = buildPremiseIndex([a, b, c, d]);
|
||||
|
||||
const tree = expandPremiseTree("a", index, 2);
|
||||
// depth 0: a, depth 1: b, depth 2: c (no further children)
|
||||
expect(tree.conclusionId).toBe("a");
|
||||
expect(tree.children[0].conclusionId).toBe("b");
|
||||
expect(tree.children[0].children[0].conclusionId).toBe("c");
|
||||
expect(tree.children[0].children[0].children).toEqual([]);
|
||||
});
|
||||
|
||||
it("prefers reasoning_tree over flat premises when both are present", () => {
|
||||
const e1 = mkConclusion("e1", iso(0));
|
||||
const e2 = mkConclusion("e2", iso(0));
|
||||
const top = mkConclusion("top", iso(5), {
|
||||
premises: ["e1"], // flat says one
|
||||
reasoning_tree: { conclusion_id: "top", premises: [{ conclusion_id: "e2" }] }, // tree says another
|
||||
});
|
||||
const index = buildPremiseIndex([e1, e2, top]);
|
||||
|
||||
const tree = expandPremiseTree("top", index);
|
||||
expect(tree.children.map((n) => n.conclusionId)).toEqual(["e2"]);
|
||||
});
|
||||
});
|
||||
@@ -39,23 +39,4 @@ describe("useHealthStatus", () => {
|
||||
const { result } = renderHook(() => useHealthStatus(), { wrapper: wrap(qc) });
|
||||
await waitFor(() => expect(result.current.data?.status).toBe("ok"));
|
||||
});
|
||||
|
||||
it("does not include raw tokens in query cache keys", async () => {
|
||||
saveStore({
|
||||
instances: [
|
||||
{ id: "i1", name: "Local", baseUrl: "http://localhost:8000", token: "super-secret-token" },
|
||||
],
|
||||
activeId: "i1",
|
||||
});
|
||||
httpFetch.mockResolvedValue(new Response("{}", { status: 200 }));
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
renderHook(() => useHealthStatus(), { wrapper: wrap(qc) });
|
||||
await waitFor(() => expect(httpFetch).toHaveBeenCalled());
|
||||
|
||||
const cacheKeys = qc
|
||||
.getQueryCache()
|
||||
.getAll()
|
||||
.map((query) => JSON.stringify(query.queryKey));
|
||||
expect(cacheKeys.join("\n")).not.toContain("super-secret-token");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { REASONING_LEVELS, type ReasoningLevel } from "@/api/queries";
|
||||
import {
|
||||
buildInitialColumns,
|
||||
fanoutQuery,
|
||||
latencyMs,
|
||||
} from "@/components/playground/DialecticPlayground";
|
||||
|
||||
describe("fanoutQuery", () => {
|
||||
it("fires every selected level concurrently rather than sequentially", async () => {
|
||||
const inFlight = new Set<ReasoningLevel>();
|
||||
let peakConcurrency = 0;
|
||||
const released: Array<() => void> = [];
|
||||
|
||||
const runOne = vi.fn(async (level: ReasoningLevel) => {
|
||||
inFlight.add(level);
|
||||
peakConcurrency = Math.max(peakConcurrency, inFlight.size);
|
||||
await new Promise<void>((resolve) => released.push(resolve));
|
||||
inFlight.delete(level);
|
||||
return { ok: true as const, content: `answer-${level}` };
|
||||
});
|
||||
|
||||
let nowVal = 0;
|
||||
const fanout = fanoutQuery(REASONING_LEVELS, "ping", {
|
||||
now: () => nowVal++,
|
||||
runOne,
|
||||
onStart: () => {},
|
||||
onEnd: () => {},
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(peakConcurrency).toBe(REASONING_LEVELS.length);
|
||||
|
||||
for (const release of released) release();
|
||||
await fanout;
|
||||
|
||||
expect(runOne).toHaveBeenCalledTimes(REASONING_LEVELS.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("latencyMs", () => {
|
||||
it("measures the gap between request start and response end per column", () => {
|
||||
const columns = buildInitialColumns();
|
||||
columns.medium.status = "success";
|
||||
columns.medium.startedAt = 1000;
|
||||
columns.medium.endedAt = 1842;
|
||||
|
||||
expect(latencyMs(columns.medium)).toBe(842);
|
||||
});
|
||||
});
|
||||
@@ -1,37 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isHttpOrHttpsUrl,
|
||||
isSafeExternalUrl,
|
||||
isSecureTokenTransport,
|
||||
tokenTransportError,
|
||||
} from "@/lib/security";
|
||||
|
||||
describe("security URL helpers", () => {
|
||||
it("only allows http and https URLs for external OS opens", () => {
|
||||
expect(isSafeExternalUrl("https://example.com/webhook")).toBe(true);
|
||||
expect(isSafeExternalUrl("http://localhost:3000/webhook")).toBe(true);
|
||||
expect(isSafeExternalUrl("file:///etc/passwd")).toBe(false);
|
||||
expect(isSafeExternalUrl("javascript:alert(1)")).toBe(false);
|
||||
expect(isSafeExternalUrl("openconcho://settings")).toBe(false);
|
||||
});
|
||||
|
||||
it("only accepts http and https webhook endpoints", () => {
|
||||
expect(isHttpOrHttpsUrl("https://hooks.example.com/a")).toBe(true);
|
||||
expect(isHttpOrHttpsUrl("http://hooks.example.com/a")).toBe(true);
|
||||
expect(isHttpOrHttpsUrl("ftp://hooks.example.com/a")).toBe(false);
|
||||
expect(isHttpOrHttpsUrl("notaurl")).toBe(false);
|
||||
});
|
||||
|
||||
it("requires HTTPS before sending tokens to non-loopback hosts", () => {
|
||||
expect(isSecureTokenTransport("https://honcho.example.com")).toBe(true);
|
||||
expect(isSecureTokenTransport("http://localhost:8000")).toBe(true);
|
||||
expect(isSecureTokenTransport("http://127.0.0.1:8000")).toBe(true);
|
||||
expect(isSecureTokenTransport("http://192.168.1.50:8000")).toBe(false);
|
||||
expect(isSecureTokenTransport("http://100.67.206.76:8000")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns a user-facing error for insecure token transport", () => {
|
||||
expect(tokenTransportError("http://100.67.206.76:8000")).toMatch(/HTTPS/);
|
||||
expect(tokenTransportError("https://honcho.example.com")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,159 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
BUILTIN_KITS,
|
||||
createKit,
|
||||
deleteKit,
|
||||
isBuiltinKit,
|
||||
linePrefix,
|
||||
loadUserKits,
|
||||
mergeCardLines,
|
||||
type SeedKit,
|
||||
saveUserKits,
|
||||
updateKit,
|
||||
} from "@/lib/seedKits";
|
||||
|
||||
describe("seedKits — storage round-trip", () => {
|
||||
it("returns [] when nothing is stored", () => {
|
||||
expect(loadUserKits()).toEqual([]);
|
||||
});
|
||||
|
||||
it("round-trips a kit through localStorage", () => {
|
||||
const kits: SeedKit[] = [
|
||||
{ id: "k1", name: "First", description: "desc", lines: ["a", "b", "c"] },
|
||||
{ id: "k2", name: "Second", description: "", lines: [] },
|
||||
];
|
||||
saveUserKits(kits);
|
||||
expect(loadUserKits()).toEqual(kits);
|
||||
});
|
||||
|
||||
it("recovers from corrupt storage by returning []", () => {
|
||||
localStorage.setItem("openconcho:seed-kits", "{not json");
|
||||
expect(loadUserKits()).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores entries that fail schema validation", () => {
|
||||
localStorage.setItem(
|
||||
"openconcho:seed-kits",
|
||||
JSON.stringify([{ id: "", name: "", lines: "not-an-array" }]),
|
||||
);
|
||||
expect(loadUserKits()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("seedKits — CRUD", () => {
|
||||
it("createKit assigns an id and persists", () => {
|
||||
const kit = createKit({ name: "Test", description: "", lines: ["x"] });
|
||||
expect(kit.id).toBeTruthy();
|
||||
expect(loadUserKits()).toHaveLength(1);
|
||||
expect(loadUserKits()[0].name).toBe("Test");
|
||||
});
|
||||
|
||||
it("updateKit patches a stored kit by id", () => {
|
||||
const kit = createKit({ name: "Original", description: "", lines: [] });
|
||||
updateKit(kit.id, { name: "Renamed", lines: ["new line"] });
|
||||
const reloaded = loadUserKits()[0];
|
||||
expect(reloaded.name).toBe("Renamed");
|
||||
expect(reloaded.lines).toEqual(["new line"]);
|
||||
});
|
||||
|
||||
it("deleteKit removes by id", () => {
|
||||
const a = createKit({ name: "A", description: "", lines: [] });
|
||||
createKit({ name: "B", description: "", lines: [] });
|
||||
deleteKit(a.id);
|
||||
const remaining = loadUserKits();
|
||||
expect(remaining).toHaveLength(1);
|
||||
expect(remaining[0].name).toBe("B");
|
||||
});
|
||||
|
||||
it("refuses to mutate built-in kits", () => {
|
||||
const builtin = BUILTIN_KITS[0];
|
||||
updateKit(builtin.id, { name: "Hacked" });
|
||||
deleteKit(builtin.id);
|
||||
expect(loadUserKits()).toEqual([]);
|
||||
expect(BUILTIN_KITS[0].name).not.toBe("Hacked");
|
||||
});
|
||||
});
|
||||
|
||||
describe("seedKits — isBuiltinKit", () => {
|
||||
it("flags ids starting with 'builtin:'", () => {
|
||||
expect(isBuiltinKit({ id: "builtin:foo" })).toBe(true);
|
||||
expect(isBuiltinKit({ id: "kit_abc" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("seedKits — linePrefix", () => {
|
||||
it("returns the lowercased text before the first colon", () => {
|
||||
expect(linePrefix("Name: Ben")).toBe("name");
|
||||
expect(linePrefix("EMAIL: foo@bar.com")).toBe("email");
|
||||
expect(linePrefix("Preferred address: Chief")).toBe("preferred address");
|
||||
});
|
||||
|
||||
it("returns null for lines without a colon or with empty prefix", () => {
|
||||
expect(linePrefix("loves coffee")).toBeNull();
|
||||
expect(linePrefix(": no key")).toBeNull();
|
||||
expect(linePrefix("")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("seedKits — mergeCardLines", () => {
|
||||
it("replaces existing lines with matching prefix instead of duplicating", () => {
|
||||
const existing = ["Name: Ben", "Email: ben@old.com"];
|
||||
const incoming = ["Name: Ben Sheridan-Edwards"];
|
||||
const merged = mergeCardLines(existing, incoming);
|
||||
// Matching-prefix wins → "Name: Ben" replaced; no duplicate Name line.
|
||||
expect(merged).toEqual(["Name: Ben Sheridan-Edwards", "Email: ben@old.com"]);
|
||||
});
|
||||
|
||||
it("appends incoming lines whose prefix is not in existing", () => {
|
||||
const existing = ["Name: Ben"];
|
||||
const incoming = ["Email: ben@codewalnut.com", "Role: Founder"];
|
||||
expect(mergeCardLines(existing, incoming)).toEqual([
|
||||
"Name: Ben",
|
||||
"Email: ben@codewalnut.com",
|
||||
"Role: Founder",
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats prefix comparison case-insensitively", () => {
|
||||
const existing = ["NAME: Ben"];
|
||||
const incoming = ["Name: Ben Sheridan-Edwards"];
|
||||
expect(mergeCardLines(existing, incoming)).toEqual(["Name: Ben Sheridan-Edwards"]);
|
||||
});
|
||||
|
||||
it("preserves order of existing lines when replacing", () => {
|
||||
const existing = ["A: 1", "B: 2", "C: 3"];
|
||||
const incoming = ["B: 22"];
|
||||
expect(mergeCardLines(existing, incoming)).toEqual(["A: 1", "B: 22", "C: 3"]);
|
||||
});
|
||||
|
||||
it("dedupes no-prefix lines by exact match", () => {
|
||||
const existing = ["loves coffee", "Name: Ben"];
|
||||
const incoming = ["loves coffee", "loves tea"];
|
||||
// "loves coffee" already present → kept once; "loves tea" appended.
|
||||
expect(mergeCardLines(existing, incoming)).toEqual(["loves coffee", "Name: Ben", "loves tea"]);
|
||||
});
|
||||
|
||||
it("never drops existing lines that have no incoming match", () => {
|
||||
const existing = ["Name: Ben", "Quirk: drinks tea, not coffee"];
|
||||
const incoming = ["Email: ben@x.com"];
|
||||
expect(mergeCardLines(existing, incoming)).toEqual([
|
||||
"Name: Ben",
|
||||
"Quirk: drinks tea, not coffee",
|
||||
"Email: ben@x.com",
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles empty existing and empty incoming", () => {
|
||||
expect(mergeCardLines([], [])).toEqual([]);
|
||||
expect(mergeCardLines([], ["A: 1"])).toEqual(["A: 1"]);
|
||||
expect(mergeCardLines(["A: 1"], [])).toEqual(["A: 1"]);
|
||||
});
|
||||
|
||||
it("dedupes within incoming when an existing prefix is matched", () => {
|
||||
// Two incoming lines share a prefix → the LAST one wins (Map semantics) and
|
||||
// the earlier duplicate is collapsed.
|
||||
const existing = ["Name: Ben"];
|
||||
const incoming = ["Name: First", "Name: Second"];
|
||||
expect(mergeCardLines(existing, incoming)).toEqual(["Name: Second"]);
|
||||
});
|
||||
});
|
||||
@@ -52,24 +52,6 @@ describe("SettingsForm — self-hosted preset", () => {
|
||||
const store = loadStore();
|
||||
expect(store.instances).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("blocks saving a token for a non-localhost HTTP endpoint", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderForm(<SettingsForm instance={null} preset="self-hosted" />);
|
||||
const baseUrl = screen.getByPlaceholderText("http://localhost:8000");
|
||||
await user.clear(baseUrl);
|
||||
await user.type(baseUrl, "http://100.67.206.76:8000");
|
||||
await user.type(
|
||||
screen.getByPlaceholderText(/required only if your instance has auth enabled/i),
|
||||
"secret-token",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: /Add Instance/i }));
|
||||
|
||||
expect(
|
||||
screen.getByText(/API tokens require HTTPS unless connecting to localhost/i),
|
||||
).toBeInTheDocument();
|
||||
expect(loadStore().instances).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SettingsForm — edit mode auto-detects cloud", () => {
|
||||
|
||||
@@ -2,8 +2,10 @@ import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach, vi } from "vitest";
|
||||
|
||||
// jsdom defines scrollTo but leaves it unimplemented; router scroll restoration calls it.
|
||||
window.scrollTo = vi.fn() as unknown as typeof window.scrollTo;
|
||||
// jsdom doesn't implement matchMedia; theme code reads it on mount.
|
||||
if (!window.scrollTo) {
|
||||
window.scrollTo = vi.fn() as unknown as typeof window.scrollTo;
|
||||
}
|
||||
|
||||
if (!window.matchMedia) {
|
||||
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||||
|
||||
@@ -22,26 +22,5 @@ export default defineConfig({
|
||||
css: false,
|
||||
include: ["src/**/*.{test,spec}.{ts,tsx}"],
|
||||
exclude: ["node_modules", "dist", "e2e"],
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
reporter: ["text", "html", "json-summary", "lcov"],
|
||||
include: ["src/**/*.{ts,tsx}"],
|
||||
exclude: [
|
||||
"src/**/*.{test,spec}.{ts,tsx}",
|
||||
"src/test/**",
|
||||
"src/routeTree.gen.ts",
|
||||
"src/api/schema.d.ts",
|
||||
"src/main.tsx",
|
||||
"src/vite-env.d.ts",
|
||||
],
|
||||
// Truthful baseline floor measured on main (not copied from the
|
||||
// fork). Ratchet upward as feature tests grow.
|
||||
thresholds: {
|
||||
lines: 18,
|
||||
functions: 10,
|
||||
branches: 10,
|
||||
statements: 17,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
147
pnpm-lock.yaml
generated
@@ -30,9 +30,6 @@ catalogs:
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^6.0.1
|
||||
version: 6.0.1
|
||||
'@vitest/coverage-v8':
|
||||
specifier: ^4.0.0
|
||||
version: 4.1.7
|
||||
jsdom:
|
||||
specifier: ^26.1.0
|
||||
version: 26.1.0
|
||||
@@ -231,9 +228,6 @@ importers:
|
||||
'@vitejs/plugin-react':
|
||||
specifier: 'catalog:'
|
||||
version: 6.0.1(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0))
|
||||
'@vitest/coverage-v8':
|
||||
specifier: 'catalog:'
|
||||
version: 4.1.7(vitest@4.1.5)
|
||||
jsdom:
|
||||
specifier: 'catalog:'
|
||||
version: 26.1.0
|
||||
@@ -248,7 +242,7 @@ importers:
|
||||
version: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)
|
||||
vitest:
|
||||
specifier: 'catalog:'
|
||||
version: 4.1.5(@types/node@25.6.0)(@vitest/coverage-v8@4.1.7)(jsdom@26.1.0)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0))
|
||||
version: 4.1.5(@types/node@25.6.0)(jsdom@26.1.0)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0))
|
||||
|
||||
packages:
|
||||
|
||||
@@ -312,18 +306,10 @@ packages:
|
||||
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-string-parser@7.29.7':
|
||||
resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-validator-identifier@7.28.5':
|
||||
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-validator-identifier@7.29.7':
|
||||
resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-validator-option@7.27.1':
|
||||
resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -337,11 +323,6 @@ packages:
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@babel/parser@7.29.7':
|
||||
resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@babel/plugin-syntax-jsx@7.28.6':
|
||||
resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -370,14 +351,6 @@ packages:
|
||||
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/types@7.29.7':
|
||||
resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@bcoe/v8-coverage@1.0.2':
|
||||
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@biomejs/biome@2.4.13':
|
||||
resolution: {integrity: sha512-gLXOwkOBBg0tr7bDsqlkIh4uFeKuMjxvqsrb1Tukww1iDmHcfr4Uu8MoQxp0Rcte+69+osRNWXwHsu/zxT6XqA==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
@@ -1769,15 +1742,6 @@ packages:
|
||||
babel-plugin-react-compiler:
|
||||
optional: true
|
||||
|
||||
'@vitest/coverage-v8@4.1.7':
|
||||
resolution: {integrity: sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ==}
|
||||
peerDependencies:
|
||||
'@vitest/browser': 4.1.7
|
||||
vitest: 4.1.7
|
||||
peerDependenciesMeta:
|
||||
'@vitest/browser':
|
||||
optional: true
|
||||
|
||||
'@vitest/expect@4.1.5':
|
||||
resolution: {integrity: sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==}
|
||||
|
||||
@@ -1795,9 +1759,6 @@ packages:
|
||||
'@vitest/pretty-format@4.1.5':
|
||||
resolution: {integrity: sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==}
|
||||
|
||||
'@vitest/pretty-format@4.1.7':
|
||||
resolution: {integrity: sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==}
|
||||
|
||||
'@vitest/runner@4.1.5':
|
||||
resolution: {integrity: sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==}
|
||||
|
||||
@@ -1810,9 +1771,6 @@ packages:
|
||||
'@vitest/utils@4.1.5':
|
||||
resolution: {integrity: sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==}
|
||||
|
||||
'@vitest/utils@4.1.7':
|
||||
resolution: {integrity: sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==}
|
||||
|
||||
acorn@8.16.0:
|
||||
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
@@ -1900,9 +1858,6 @@ packages:
|
||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
ast-v8-to-istanbul@1.0.2:
|
||||
resolution: {integrity: sha512-dKmJxJsGItLmc5CYZKuEjuG6GnBs6PG4gohMhyFOWKaNQoYCuRZJDECaBlHmcG0lv2wc2E0uU8lESmBEumC3DQ==}
|
||||
|
||||
babel-dead-code-elimination@1.0.12:
|
||||
resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==}
|
||||
|
||||
@@ -2445,9 +2400,6 @@ packages:
|
||||
resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
html-escaper@2.0.2:
|
||||
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
|
||||
|
||||
html-url-attributes@3.0.1:
|
||||
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
|
||||
|
||||
@@ -2600,18 +2552,6 @@ packages:
|
||||
resolution: {integrity: sha512-3YZcUUR2Wt1WsapF+S/WiA2WmlW0cWAoPccMqne7AxEBhCdFeTPjfv/Axb8V2gyCgY3nRw+ksZ3xSUX+R47iAg==}
|
||||
engines: {node: ^18.17 || >=20.6.1}
|
||||
|
||||
istanbul-lib-coverage@3.2.2:
|
||||
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
istanbul-lib-report@3.0.1:
|
||||
resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
istanbul-reports@3.2.0:
|
||||
resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
java-properties@1.0.2:
|
||||
resolution: {integrity: sha512-qjdpeo2yKlYTH7nFdK0vbZWuTCesk4o63v5iVOlhMQPfuIZQfW/HI35SjfhA+4qpg36rnFSvUK5b1m+ckIblQQ==}
|
||||
engines: {node: '>= 0.6.0'}
|
||||
@@ -2624,9 +2564,6 @@ packages:
|
||||
resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
js-tokens@10.0.0:
|
||||
resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
|
||||
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
@@ -2821,17 +2758,10 @@ packages:
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
magicast@0.5.3:
|
||||
resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==}
|
||||
|
||||
make-asynchronous@1.1.0:
|
||||
resolution: {integrity: sha512-ayF7iT+44LXdxJLTrTd3TLQpFDDvPCBxXxbv+pMUSuHA5Q8zyAfwkRP6aHHwNVFBUFWtxAHqwNJxF8vMZLAbVg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
make-dir@4.0.0:
|
||||
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
markdown-table@3.0.4:
|
||||
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
|
||||
|
||||
@@ -4182,12 +4112,8 @@ snapshots:
|
||||
|
||||
'@babel/helper-string-parser@7.27.1': {}
|
||||
|
||||
'@babel/helper-string-parser@7.29.7': {}
|
||||
|
||||
'@babel/helper-validator-identifier@7.28.5': {}
|
||||
|
||||
'@babel/helper-validator-identifier@7.29.7': {}
|
||||
|
||||
'@babel/helper-validator-option@7.27.1': {}
|
||||
|
||||
'@babel/helpers@7.29.2':
|
||||
@@ -4199,10 +4125,6 @@ snapshots:
|
||||
dependencies:
|
||||
'@babel/types': 7.29.0
|
||||
|
||||
'@babel/parser@7.29.7':
|
||||
dependencies:
|
||||
'@babel/types': 7.29.7
|
||||
|
||||
'@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
@@ -4238,13 +4160,6 @@ snapshots:
|
||||
'@babel/helper-string-parser': 7.27.1
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
|
||||
'@babel/types@7.29.7':
|
||||
dependencies:
|
||||
'@babel/helper-string-parser': 7.29.7
|
||||
'@babel/helper-validator-identifier': 7.29.7
|
||||
|
||||
'@bcoe/v8-coverage@1.0.2': {}
|
||||
|
||||
'@biomejs/biome@2.4.13':
|
||||
optionalDependencies:
|
||||
'@biomejs/cli-darwin-arm64': 2.4.13
|
||||
@@ -5539,20 +5454,6 @@ snapshots:
|
||||
'@rolldown/pluginutils': 1.0.0-rc.7
|
||||
vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)
|
||||
|
||||
'@vitest/coverage-v8@4.1.7(vitest@4.1.5)':
|
||||
dependencies:
|
||||
'@bcoe/v8-coverage': 1.0.2
|
||||
'@vitest/utils': 4.1.7
|
||||
ast-v8-to-istanbul: 1.0.2
|
||||
istanbul-lib-coverage: 3.2.2
|
||||
istanbul-lib-report: 3.0.1
|
||||
istanbul-reports: 3.2.0
|
||||
magicast: 0.5.3
|
||||
obug: 2.1.1
|
||||
std-env: 4.1.0
|
||||
tinyrainbow: 3.1.0
|
||||
vitest: 4.1.5(@types/node@25.6.0)(@vitest/coverage-v8@4.1.7)(jsdom@26.1.0)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0))
|
||||
|
||||
'@vitest/expect@4.1.5':
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
@@ -5574,10 +5475,6 @@ snapshots:
|
||||
dependencies:
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vitest/pretty-format@4.1.7':
|
||||
dependencies:
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vitest/runner@4.1.5':
|
||||
dependencies:
|
||||
'@vitest/utils': 4.1.5
|
||||
@@ -5598,12 +5495,6 @@ snapshots:
|
||||
convert-source-map: 2.0.0
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vitest/utils@4.1.7':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 4.1.7
|
||||
convert-source-map: 2.0.0
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
acorn@8.16.0: {}
|
||||
|
||||
agent-base@7.1.4: {}
|
||||
@@ -5674,12 +5565,6 @@ snapshots:
|
||||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
ast-v8-to-istanbul@1.0.2:
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
estree-walker: 3.0.3
|
||||
js-tokens: 10.0.0
|
||||
|
||||
babel-dead-code-elimination@1.0.12:
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
@@ -6251,8 +6136,6 @@ snapshots:
|
||||
dependencies:
|
||||
whatwg-encoding: 3.1.1
|
||||
|
||||
html-escaper@2.0.2: {}
|
||||
|
||||
html-url-attributes@3.0.1: {}
|
||||
|
||||
http-proxy-agent@7.0.2:
|
||||
@@ -6371,27 +6254,12 @@ snapshots:
|
||||
lodash.isstring: 4.0.1
|
||||
lodash.uniqby: 4.7.0
|
||||
|
||||
istanbul-lib-coverage@3.2.2: {}
|
||||
|
||||
istanbul-lib-report@3.0.1:
|
||||
dependencies:
|
||||
istanbul-lib-coverage: 3.2.2
|
||||
make-dir: 4.0.0
|
||||
supports-color: 7.2.0
|
||||
|
||||
istanbul-reports@3.2.0:
|
||||
dependencies:
|
||||
html-escaper: 2.0.2
|
||||
istanbul-lib-report: 3.0.1
|
||||
|
||||
java-properties@1.0.2: {}
|
||||
|
||||
jiti@2.6.1: {}
|
||||
|
||||
js-levenshtein@1.1.6: {}
|
||||
|
||||
js-tokens@10.0.0: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
js-yaml@4.1.1:
|
||||
@@ -6554,22 +6422,12 @@ snapshots:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
magicast@0.5.3:
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.7
|
||||
'@babel/types': 7.29.0
|
||||
source-map-js: 1.2.1
|
||||
|
||||
make-asynchronous@1.1.0:
|
||||
dependencies:
|
||||
p-event: 6.0.1
|
||||
type-fest: 4.41.0
|
||||
web-worker: 1.5.0
|
||||
|
||||
make-dir@4.0.0:
|
||||
dependencies:
|
||||
semver: 7.7.4
|
||||
|
||||
markdown-table@3.0.4: {}
|
||||
|
||||
marked-terminal@7.3.0(marked@15.0.12):
|
||||
@@ -7783,7 +7641,7 @@ snapshots:
|
||||
jiti: 2.6.1
|
||||
tsx: 4.21.0
|
||||
|
||||
vitest@4.1.5(@types/node@25.6.0)(@vitest/coverage-v8@4.1.7)(jsdom@26.1.0)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)):
|
||||
vitest@4.1.5(@types/node@25.6.0)(jsdom@26.1.0)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.1.5
|
||||
'@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0))
|
||||
@@ -7807,7 +7665,6 @@ snapshots:
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 25.6.0
|
||||
'@vitest/coverage-v8': 4.1.7(vitest@4.1.5)
|
||||
jsdom: 26.1.0
|
||||
transitivePeerDependencies:
|
||||
- msw
|
||||
|
||||
@@ -15,7 +15,6 @@ catalog:
|
||||
"@types/react": "^19.2.14"
|
||||
"@types/react-dom": "^19.2.3"
|
||||
"@vitejs/plugin-react": "^6.0.1"
|
||||
"@vitest/coverage-v8": "^4.0.0"
|
||||
jsdom: "^26.1.0"
|
||||
|
||||
# React
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Draft a PR body for the current branch based on the diff vs origin/main.
|
||||
#
|
||||
# Writes to PR_BODY.md in the repo root (gitignored — see end of script).
|
||||
# Pre-fills the structure required by .github/pull_request_template.md
|
||||
# and flags whether screenshots are required based on touched paths.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/pr-evidence.sh # writes ./PR_BODY.md
|
||||
# ./scripts/pr-evidence.sh > /tmp/body.md # write to stdout
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
OUTPUT="${1:-PR_BODY.md}"
|
||||
|
||||
# Find the base branch (default origin/main) the current branch diverged from.
|
||||
BASE_REF="${BASE_REF:-origin/main}"
|
||||
git fetch origin main --quiet 2>/dev/null || true
|
||||
|
||||
MERGE_BASE=$(git merge-base HEAD "$BASE_REF" 2>/dev/null || echo "$BASE_REF")
|
||||
CHANGED=$(git diff --name-only "$MERGE_BASE"...HEAD)
|
||||
ADDED=$(git diff --name-status --diff-filter=A "$MERGE_BASE"...HEAD | awk '{print $2}')
|
||||
MODIFIED=$(git diff --name-status --diff-filter=M "$MERGE_BASE"...HEAD | awk '{print $2}')
|
||||
DELETED=$(git diff --name-status --diff-filter=D "$MERGE_BASE"...HEAD | awk '{print $2}')
|
||||
|
||||
# Heuristic: any touched path under packages/web/src/{components,routes} or
|
||||
# packages/desktop counts as a UI change and requires screenshots.
|
||||
UI_CHANGED=0
|
||||
if echo "$CHANGED" | grep -qE '^(packages/web/src/(components|routes)|packages/desktop)/'; then
|
||||
UI_CHANGED=1
|
||||
fi
|
||||
|
||||
# Commits since base — useful for the "What" section.
|
||||
COMMITS=$(git log --pretty=format:'- %s' "$MERGE_BASE"..HEAD)
|
||||
|
||||
# Tests touched?
|
||||
TESTS_TOUCHED=$(echo "$CHANGED" | grep -E '(\.test\.|/test/|/e2e/)' || true)
|
||||
|
||||
BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
|
||||
draft() {
|
||||
cat <<EOF
|
||||
<!--
|
||||
Auto-drafted by scripts/pr-evidence.sh from the diff vs ${BASE_REF}.
|
||||
Fill in the prose sections; the file lists and checklist are pre-populated.
|
||||
-->
|
||||
|
||||
## Why
|
||||
|
||||
<!-- The problem this solves, in 1–3 sentences. What pain or gap does this close? -->
|
||||
|
||||
## What
|
||||
|
||||
$(if [ -n "$COMMITS" ]; then printf 'Commits on this branch:\n%s\n' "$COMMITS"; else echo '<!-- describe the change -->'; fi)
|
||||
|
||||
$(if [ -n "$ADDED" ]; then printf '\n**Added:**\n'; printf '%s\n' "$ADDED" | sed 's/^/- /'; fi)
|
||||
$(if [ -n "$MODIFIED" ]; then printf '\n**Modified:**\n'; printf '%s\n' "$MODIFIED" | sed 's/^/- /'; fi)
|
||||
$(if [ -n "$DELETED" ]; then printf '\n**Deleted:**\n'; printf '%s\n' "$DELETED" | sed 's/^/- /'; fi)
|
||||
|
||||
## Screenshots
|
||||
|
||||
EOF
|
||||
|
||||
if [ $UI_CHANGED -eq 1 ]; then
|
||||
cat <<EOF
|
||||
**Required** — this PR touches packages/web/src/{components,routes} or packages/desktop.
|
||||
Commit screenshots under \`docs/screenshots/<feature-slug>/\` and reference here:
|
||||
|
||||
\`\`\`markdown
|
||||

|
||||
\`\`\`
|
||||
|
||||
See \`.claude/rules/workflows.md\` → "Open a PR" for capture + commit guidance.
|
||||
|
||||
EOF
|
||||
else
|
||||
cat <<EOF
|
||||
<!-- No packages/web/src/{components,routes} or packages/desktop paths touched —
|
||||
screenshots not strictly required. Delete this section if truly docs-only. -->
|
||||
|
||||
EOF
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
## QA checklist
|
||||
|
||||
- [ ] \`pnpm typecheck\` clean locally
|
||||
- [ ] \`pnpm lint\` clean locally
|
||||
- [ ] \`pnpm test\` green locally
|
||||
$(if [ -n "$TESTS_TOUCHED" ]; then echo '- [x] Tests touched on this branch:'; printf '%s\n' "$TESTS_TOUCHED" | sed 's/^/ - /'; else echo '- [ ] Tests added for new behaviour (or note why none are needed)'; fi)
|
||||
- [ ] Manual verification: <!-- which Honcho instance, which workspace/peer, what you clicked, what you saw -->
|
||||
$(if echo "$CHANGED" | grep -qE '^packages/desktop/'; then echo '- [ ] \`pnpm --filter @openconcho/desktop cargo-check\` passes'; fi)
|
||||
- [x] Worked in a git worktree (current branch: \`${BRANCH}\`)
|
||||
|
||||
## Out-of-scope
|
||||
|
||||
<!-- What was intentionally left out and why. -->
|
||||
|
||||
## Notes
|
||||
|
||||
<!-- Caveats, follow-ups, anything reviewers should know. -->
|
||||
EOF
|
||||
}
|
||||
|
||||
if [ "$OUTPUT" = "-" ] || [ -t 1 ]; then
|
||||
# When piped or first arg is "-", write to stdout.
|
||||
if [ "${1:-}" = "-" ]; then
|
||||
draft
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
draft > "$OUTPUT"
|
||||
|
||||
echo "✓ Drafted PR body → ${OUTPUT}"
|
||||
echo " Open it, fill in Why / Manual verification / Out-of-scope / Notes, then use as the PR body."
|
||||
@@ -1,64 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Secret scan for staged files.
|
||||
#
|
||||
# Pre-commit hook calls this against staged additions. Fast (no external
|
||||
# tool; just regex over the staged diff). Designed to catch the common
|
||||
# accidents — API keys committed alongside code — not to replace a full
|
||||
# secret-scanning service.
|
||||
#
|
||||
# Exits non-zero with a clear message if a likely secret is found.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Only scan added/modified content (the `+` lines in the staged diff).
|
||||
# This avoids false positives from existing committed strings.
|
||||
STAGED_DIFF=$(git diff --cached --diff-filter=ACMR --unified=0 -- '*.ts' '*.tsx' '*.js' '*.jsx' '*.json' '*.yml' '*.yaml' '*.toml' '*.env*' '*.sh' '*.md' 2>/dev/null || true)
|
||||
|
||||
if [ -z "$STAGED_DIFF" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Only look at added lines (starting with `+`, excluding diff headers `+++`).
|
||||
ADDED=$(printf '%s\n' "$STAGED_DIFF" | grep -E '^\+[^+]' || true)
|
||||
|
||||
if [ -z "$ADDED" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
FOUND=0
|
||||
FINDINGS=""
|
||||
|
||||
check_pattern() {
|
||||
local name="$1"
|
||||
local pattern="$2"
|
||||
# Use `-e` to safely pass patterns that begin with `-` (e.g. PEM headers).
|
||||
if printf '%s\n' "$ADDED" | grep -qE -e "$pattern"; then
|
||||
FOUND=1
|
||||
FINDINGS="${FINDINGS} - ${name}\n"
|
||||
fi
|
||||
}
|
||||
|
||||
check_pattern "AWS access key" 'AKIA[0-9A-Z]{16}'
|
||||
check_pattern "AWS secret key (high-entropy)" 'aws_secret_access_key[[:space:]]*[:=][[:space:]]*[A-Za-z0-9/+=]{40}'
|
||||
check_pattern "Anthropic API key" 'sk-ant-[a-zA-Z0-9_-]{32,}'
|
||||
check_pattern "OpenAI API key" 'sk-[a-zA-Z0-9]{20,}T3BlbkFJ[a-zA-Z0-9]{20,}'
|
||||
check_pattern "OpenAI project key (newer)" 'sk-proj-[a-zA-Z0-9_-]{40,}'
|
||||
check_pattern "GitHub personal access token" 'gh[psoru]_[A-Za-z0-9_]{36,}'
|
||||
check_pattern "GitHub fine-grained PAT" 'github_pat_[A-Za-z0-9_]{82,}'
|
||||
check_pattern "Slack token" 'xox[abprs]-[A-Za-z0-9-]{10,}'
|
||||
check_pattern "Google API key" 'AIza[0-9A-Za-z_-]{35}'
|
||||
check_pattern "Stripe live key" 'sk_live_[A-Za-z0-9]{24,}'
|
||||
check_pattern "Honcho-style JWT (likely)" 'eyJ[A-Za-z0-9_-]{20,}\.eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}'
|
||||
check_pattern "RSA/EC/DSA/OpenSSH private key block" '-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----'
|
||||
check_pattern "Generic hardcoded password" '(password|passwd|pwd)[[:space:]]*[:=][[:space:]]*["'\'']\w{8,}["'\'']'
|
||||
|
||||
if [ $FOUND -eq 1 ]; then
|
||||
printf '\n\033[31m✗ Secret scan: potential secrets in staged changes\033[0m\n' >&2
|
||||
printf '%b' "$FINDINGS" >&2
|
||||
printf '\n' >&2
|
||||
printf 'If this is a false positive, bypass with: \033[33mgit commit --no-verify\033[0m\n' >&2
|
||||
printf 'Otherwise: remove the secret, rotate the credential, and re-stage.\n\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -15,10 +15,6 @@
|
||||
"test": {
|
||||
"inputs": ["src/**", "vitest.config.*", "package.json"]
|
||||
},
|
||||
"test:coverage": {
|
||||
"inputs": ["src/**", "vitest.config.*", "package.json"],
|
||||
"outputs": ["coverage/**"]
|
||||
},
|
||||
"test:e2e": {
|
||||
"cache": false,
|
||||
"inputs": ["e2e/**", "src/**", "playwright.config.*", "package.json"]
|
||||
|
||||