1 Commits

Author SHA1 Message Date
3b5c3290b2 "fix-sort-and-display-name" 2026-05-28 17:11:50 -04:00
89 changed files with 54 additions and 5526 deletions

View File

@@ -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
View File

@@ -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"

View File

@@ -1,8 +1,9 @@
name: Release
on:
release:
types: [published]
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
tag:

6
.gitignore vendored
View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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.

View File

@@ -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;
}

View File

@@ -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.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 338 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 247 KiB

View File

@@ -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": {

View File

@@ -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]]

View File

@@ -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"

View File

@@ -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://*:*" }
]

View File

@@ -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]);
}
}

View File

@@ -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");
}

View File

@@ -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": {

View File

@@ -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();
});
});

View File

@@ -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 911am 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 911am, 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 911am 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,
});
});

View File

@@ -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:",

View File

@@ -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);
});

View File

@@ -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");

View File

@@ -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),
});
}

View File

@@ -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,
};

View File

@@ -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) {

View File

@@ -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 });
}

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -6,7 +6,6 @@ const SECTION_LABELS: Record<string, string> = {
peers: "Peers",
sessions: "Sessions",
conclusions: "Conclusions",
dreams: "Dreams",
webhooks: "Webhooks",
chat: "Chat",
};

View File

@@ -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

View File

@@ -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 &amp; 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>

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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 && (

View File

@@ -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:80008100 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>
);
}

View File

@@ -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"

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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)",

View File

@@ -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 (

View File

@@ -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);

View File

@@ -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 };
}

View File

@@ -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;
}
}

View File

@@ -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 };
}

View File

@@ -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.";
}

View File

@@ -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;
}

View File

@@ -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)

View File

@@ -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 (&gt;30m)
</h3>
<DreamProgressPanel
workspaceId={WORKSPACE_ID}
data={STALE}
isLoading={false}
error={null}
staleOverride={STALE_OVERRIDE}
/>
</section>
</div>
);
}

View File

@@ -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,
});

View File

@@ -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,
});

View File

@@ -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,
});

View File

@@ -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 &amp; 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>
);
}

View File

@@ -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>,
);

View File

@@ -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");
});
});

View File

@@ -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);
});
});

View File

@@ -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"]);
});
});

View File

@@ -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");
});
});

View File

@@ -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);
});
});

View File

@@ -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();
});
});

View File

@@ -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"]);
});
});

View File

@@ -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", () => {

View File

@@ -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) => ({

View File

@@ -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
View File

@@ -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

View File

@@ -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

View File

@@ -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 13 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
![Description](https://raw.githubusercontent.com/BenSheridanEdwards/openconcho/${BRANCH}/docs/screenshots/<feature-slug>/01-<state>.png)
\`\`\`
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."

View File

@@ -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

View File

@@ -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"]