1 Commits

Author SHA1 Message Date
3b5c3290b2 "fix-sort-and-display-name" 2026-05-28 17:11:50 -04:00
145 changed files with 1594 additions and 10630 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

View File

@@ -6,7 +6,7 @@ runs:
steps:
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
- uses: actions/setup-node@v4
with:
node-version: "24"
cache: pnpm

View File

@@ -1,84 +0,0 @@
# Dependabot configuration
# Docs: https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
#
# Grouping policy: collapse all minor+patch bumps into ONE PR per ecosystem so
# the review queue stays small; majors get their own grouped PR per ecosystem so
# breaking changes still get individual scrutiny.
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:
npm-minor-patch:
patterns:
- "*"
update-types:
- "minor"
- "patch"
npm-major:
patterns:
- "*"
update-types:
- "major"
# ─── 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:
cargo-minor-patch:
patterns:
- "*"
update-types:
- "minor"
- "patch"
cargo-major:
patterns:
- "*"
update-types:
- "major"
# ─── 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"
groups:
github-actions:
patterns:
- "*"

View File

@@ -11,7 +11,7 @@ jobs:
name: Lint, type-check, test & build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- uses: ./.github/actions/setup
@@ -27,7 +27,7 @@ jobs:
issues: write
pull-requests: write
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.RELEASE_TOKEN }}

View File

@@ -1,77 +0,0 @@
name: Publish web image
on:
release:
types: [published]
workflow_dispatch:
permissions:
contents: read
packages: write
jobs:
publish:
name: Build & push web image to GHCR
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-qemu-action@v4
- uses: docker/setup-buildx-action@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/openconcho-web
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest
type=sha,format=short
- uses: docker/build-push-action@v7
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
publish-chart:
name: Package & push Helm chart to GHCR
runs-on: ubuntu-latest
needs: [publish]
if: startsWith(github.ref, 'refs/tags/')
steps:
- uses: actions/checkout@v4
- uses: azure/setup-helm@v4
- name: Derive chart version
id: version
run: echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
- name: Log in to GHCR (Helm OCI)
run: |
echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io \
--username "${{ github.actor }}" \
--password-stdin
- name: Package chart
run: |
helm package charts/openconcho \
--version "${{ steps.version.outputs.VERSION }}" \
--app-version "${{ steps.version.outputs.VERSION }}"
- name: Push chart
run: |
helm push "openconcho-${{ steps.version.outputs.VERSION }}.tgz" \
oci://ghcr.io/${{ github.repository_owner }}/charts

View File

@@ -1,8 +1,9 @@
name: Release
on:
release:
types: [published]
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
tag:
@@ -30,7 +31,7 @@ jobs:
contents: write
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.tag || github.ref }}

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

@@ -16,11 +16,6 @@ Frontend UI for self-hosted Honcho instances — browse memories, peers, session
| `make typecheck` | tsc --noEmit |
| `make test` | Vitest (unit + integration), excludes `e2e/` |
| `make test-e2e` | Playwright e2e (uncached) |
| `make smoke-docker` | Local: build image + hermetic smoke test of the `/api` proxy (Docker required) |
| `make up` | Run the web container from source (dev-forward, builds) at :8080 |
| `make prod` | Run the web container from the published image (pulls `ghcr…:latest`) |
| `make down` | Stop + remove the web container (dev or prod) |
| `make clean` | `down` + remove the locally built image |
| `make check` | lint + typecheck + test |
| `pnpm --filter @openconcho/desktop cargo-check` | Local Rust/Tauri compile check before pushing desktop changes |
| `pnpm --filter @openconcho/web generate:api` | Regen `src/api/schema.d.ts` from `openapi.json` |
@@ -38,7 +33,6 @@ Frontend UI for self-hosted Honcho instances — browse memories, peers, session
| `packages/web/src/test/` | Vitest unit/integration tests + setup |
| `packages/web/e2e/` | Playwright e2e specs |
| `packages/desktop/` | Tauri shell that bundles the built web app |
| `charts/openconcho/` | Helm 3 chart for self-hosting on Kubernetes (OCI artifact on GHCR) |
| `.claude/rules/` | Coding conventions (auto-loaded; stack-agnostic, applies to all agents) |
| `docs/` | Architecture and references |
@@ -69,9 +63,7 @@ Before pushing any change under `packages/desktop/**` or `packages/desktop/src-t
## Key Constraints
- **No hardcoded URLs** — connection config lives in `localStorage` under `openconcho:instances` (multi-instance store; legacy `openconcho:config` is auto-migrated)
- **Web CORS via a same-origin `/api` proxy** — the web build issues all Honcho calls to `/api/*` with an `X-Honcho-Upstream` header (the active instance's URL); nginx (docker) and a Vite middleware (dev) forward server-side. Transport is resolved by `dispatchFor` in `src/lib/dispatch.ts`: web → relative `/api` + header; Tauri → absolute URL + reqwest. Optional `OPENCONCHO_UPSTREAM_ALLOWLIST` guards the proxy when exposed.
- **Local git hooks** — `.husky/pre-commit` runs a secret scan + Biome on staged files; `.husky/pre-push` runs `pnpm check`. Your commits and pushes trigger these.
- **No hardcoded URLs** — all connection config lives in `localStorage` under `openconcho:config`
- **TanStack Router flat-route params** — always cast `params` as `as never` at `navigate()` and `<Link>` callsites
- **`framer-motion` Variants typing** — import `type Variants` and annotate objects; never use `as const` on variant objects
- **Auth is optional** — token header only sent when non-empty; `checkConnection()` detects if auth is required

View File

@@ -1,130 +1,3 @@
# [0.16.0](https://github.com/offendingcommit/openconcho/compare/v0.15.0...v0.16.0) (2026-06-10)
### Bug Fixes
* **ci:** replace userEvent.selectOptions with fireEvent.change; bump setup-node to v6 ([1c28cae](https://github.com/offendingcommit/openconcho/commit/1c28cae3f2aed84e9c2deff7c7fcb622bb86df2e))
* **dashboard:** guard setMetricsById against same-value calls to end loop ([3b88a41](https://github.com/offendingcommit/openconcho/commit/3b88a41afda842eb3d493b7d77a825595164cb94))
* **dashboard:** remove lastSeen from metrics useMemo deps to break render loop ([f79cdaf](https://github.com/offendingcommit/openconcho/commit/f79cdafba7aa16004c75d2684d7dedd6279f3265))
* **dashboard:** use primitive deps in onMetrics effect to break render loop ([9cc8637](https://github.com/offendingcommit/openconcho/commit/9cc8637dc7ebf62b2002a0a3d8e4b3781660e770))
* **test:** remove unused within import from fleet.test.tsx ([36fb6ee](https://github.com/offendingcommit/openconcho/commit/36fb6ee51923c8eb3261d851a1754f7f1c6ec0b9))
* **web:** break metricsEqual lastSeen dep-loop; add staleTime to test ([173f096](https://github.com/offendingcommit/openconcho/commit/173f096e33157a1744ffa2ef9d09a170216ac036))
* **web:** reset serverFilter when the selected instance is removed ([699ec38](https://github.com/offendingcommit/openconcho/commit/699ec3848016aad31db33eecc0079645f24f83b6))
* **web:** stop Sidebar re-render loop from cache-event subscriber ([7e529c8](https://github.com/offendingcommit/openconcho/commit/7e529c8c44b358d3f437f1f1888c0e7fa5664d52))
### Features
* **web:** merge Fleet into a server-filterable Dashboard ([e66f927](https://github.com/offendingcommit/openconcho/commit/e66f927f89cbfda6e900080369599fe0d4eeb5bf)), closes [#54](https://github.com/offendingcommit/openconcho/issues/54)
* **web:** redirect /fleet to Dashboard; update fleet tests ([da126b2](https://github.com/offendingcommit/openconcho/commit/da126b2e74f89cdeca323e00232a4f49764fb992))
# [0.15.0](https://github.com/offendingcommit/openconcho/compare/v0.14.0...v0.15.0) (2026-06-03)
### Bug Fixes
* **helm:** guard tmpfs blocks when empty, cap volume names at 63 chars ([d5a65d7](https://github.com/offendingcommit/openconcho/commit/d5a65d73b59378f5ce39bf76e0572da478cecbda))
* **helm:** pdb mutual exclusion, ingress null rules guard, hpa nil utilization guard ([b4939bd](https://github.com/offendingcommit/openconcho/commit/b4939bd57f2dba5ebca9efcd42901457512e70e4))
* **helm:** pin busybox:1.36, add -T 10 timeout, use --spider, add activeDeadlineSeconds ([8fac5d0](https://github.com/offendingcommit/openconcho/commit/8fac5d060f45b68141917efad4afe499ca2fda56))
* **helm:** use http://json-schema.org/draft-07/schema# for Helm compatibility ([8d41455](https://github.com/offendingcommit/openconcho/commit/8d41455e39db51617d7476e5cc48577eb7fff158))
### Features
* **helm:** add _helpers.tpl with name, label, and imageTag partials ([0268275](https://github.com/offendingcommit/openconcho/commit/02682750ab766851570eae58eb0b92761b98724f))
* **helm:** add Deployment template with read-only FS, tmpfs, probes ([514e1d4](https://github.com/offendingcommit/openconcho/commit/514e1d46c0248bfae5da1f2ceb12ca8799a81468))
* **helm:** add NOTES.txt with access instructions and NetworkPolicy/Ingress warning ([ce211df](https://github.com/offendingcommit/openconcho/commit/ce211df48cc59dfe933eb7a1b1415591b0e9f7fa))
* **helm:** add optional HPA, PDB, and NetworkPolicy templates ([b0b648b](https://github.com/offendingcommit/openconcho/commit/b0b648bdcf64732c0a713bd8e45077c5f1b39ba6))
* **helm:** add optional Ingress template ([9aa106c](https://github.com/offendingcommit/openconcho/commit/9aa106cede7d5719ee2cbc48c1c677491deea568))
* **helm:** add Service and ServiceAccount templates ([ee916ea](https://github.com/offendingcommit/openconcho/commit/ee916eabc485f37cdc56ffbdd8d9004f33f3a7b7))
* **helm:** add test-healthz and test-spa-root helm test jobs ([ee4630e](https://github.com/offendingcommit/openconcho/commit/ee4630e79ca588ee0f9cb167ac0f58ae4b8223cc))
* **helm:** chart scaffold — Chart.yaml, values, schema ([4112270](https://github.com/offendingcommit/openconcho/commit/411227046a3dee125a555a0d1a426afed0e74ec3))
# [0.14.0](https://github.com/offendingcommit/openconcho/compare/v0.13.1...v0.14.0) (2026-06-02)
### Bug Fixes
* **docker:** derive nginx resolver from container DNS ([66b299a](https://github.com/offendingcommit/openconcho/commit/66b299a28e912bc2f8c2922b40292696c4f7d81a))
* **docker:** drop dead HONCHO_UPSTREAM and same-origin default ([a2854ab](https://github.com/offendingcommit/openconcho/commit/a2854ab8ea0a9eec2a06838fb394a0264f7dd80d))
* **web:** enforce upstream allowlist in vite dev proxy ([b4fac95](https://github.com/offendingcommit/openconcho/commit/b4fac95f37da3985dbc4fbf64d04dd509ec86c2c))
* **web:** raise connection-test timeout for cold upstreams ([409d7d8](https://github.com/offendingcommit/openconcho/commit/409d7d8be7f5cc94421dce32a54105ea48bfd44b))
* **web:** strip content-encoding from vite dev proxy responses ([6b602c0](https://github.com/offendingcommit/openconcho/commit/6b602c05bb81721dfc102b3f97112b2cf58d4d60))
### Features
* **docker:** header-driven /api reverse proxy in nginx ([753c978](https://github.com/offendingcommit/openconcho/commit/753c978f56dab61d0c15b25b56ecf438cdc5ae88))
* **docker:** render SSRF allowlist map from env ([0af1ad9](https://github.com/offendingcommit/openconcho/commit/0af1ad923cd2aa61a201d65ce4f19acb13858790))
* **docker:** split compose into dev-forward build and prod pull ([c9bd2db](https://github.com/offendingcommit/openconcho/commit/c9bd2db07d84e0eedffeadcc6f2bc15c628eb251))
* **web:** add dispatchFor transport helper for same-origin proxy ([9945e4c](https://github.com/offendingcommit/openconcho/commit/9945e4cf148aec6fc47bb853e8661c339c52ff32))
* **web:** dev /api proxy middleware mirroring nginx ([ab8a1ba](https://github.com/offendingcommit/openconcho/commit/ab8a1ba866728ff972544c1d912fed59ba03a4a7))
* **web:** route checkConnection and discovery through the proxy ([9893230](https://github.com/offendingcommit/openconcho/commit/9893230cde3d11ce73350bd12fffae236ee9adff))
* **web:** route web build through same-origin /api proxy ([0935099](https://github.com/offendingcommit/openconcho/commit/0935099bc28468a21183f5f03105645f4ac8aa8a))
## [0.13.1](https://github.com/offendingcommit/openconcho/compare/v0.13.0...v0.13.1) (2026-05-29)
### Bug Fixes
* **docker:** make docker-compose runnable standalone ([fde4836](https://github.com/offendingcommit/openconcho/commit/fde483657fabde5b0f39578ec354c2e5c8b02daa))
# [0.13.0](https://github.com/offendingcommit/openconcho/compare/v0.12.1...v0.13.0) (2026-05-28)
### Features
* **docker:** full self-hosted Compose support ([282ba1b](https://github.com/offendingcommit/openconcho/commit/282ba1b76c43b51aae5bf4d173fd2c76bfbd8eac))
* **web:** add Fleet dashboard view for cross-instance observability ([12712bb](https://github.com/offendingcommit/openconcho/commit/12712bb0b087f3413173328cad96c9b04bb35ca5))
* **web:** configurable peer display name ([3de6832](https://github.com/offendingcommit/openconcho/commit/3de6832a5dc7aad971ac87e356674d834fe9e715))
## [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 +1,62 @@
@AGENTS.md
# openconcho
Frontend UI for self-hosted Honcho instances — browse memories, peers, sessions, conclusions, and chat with memory context. Ships as a web app (`@openconcho/web`) and a Tauri desktop wrapper (`@openconcho/desktop`).
## Commands
`make` is the canonical interface; it shells out to pnpm scripts which shell out to turborepo. CI calls the same targets — `make help` lists everything.
| Command | Purpose |
|---------|---------|
| `make bootstrap` | Install deps + Playwright Chromium (run once after clone) |
| `make dev-web` | Vite dev server on http://localhost:5173 |
| `make dev-desktop` (or `make dev`) | Tauri desktop app |
| `make build` | Turbo: build web + desktop |
| `make lint` | Biome check |
| `make typecheck` | tsc --noEmit |
| `make test` | Vitest (unit + integration), excludes `e2e/` |
| `make test-e2e` | Playwright e2e (uncached) |
| `make check` | lint + typecheck + test |
| `pnpm --filter @openconcho/desktop cargo-check` | Local Rust/Tauri compile check before pushing desktop changes |
| `pnpm --filter @openconcho/web generate:api` | Regen `src/api/schema.d.ts` from `openapi.json` |
## Structure
| Path | Purpose |
|------|---------|
| `packages/web/` | Vite + React 19 + TanStack Router/Query SPA |
| `packages/web/src/routes/` | TanStack Router file-based routes (flat-route syntax) |
| `packages/web/src/components/` | Feature components grouped by domain |
| `packages/web/src/api/` | openapi-fetch client + TanStack Query hooks |
| `packages/web/src/lib/` | Config (localStorage) + theme utilities |
| `packages/web/src/hooks/` | Custom React hooks |
| `packages/web/src/test/` | Vitest unit/integration tests + setup |
| `packages/web/e2e/` | Playwright e2e specs |
| `packages/desktop/` | Tauri shell that bundles the built web app |
| `.claude/rules/` | Coding conventions (auto-loaded) |
| `docs/` | Architecture and references |
## Code Style
Read `.claude/rules/coding-standards.md` when writing or reviewing any code file.
## Workflows
Read `.claude/rules/workflows.md` for recurring task patterns.
## Architecture
Read `docs/architecture.md` for component overview, data flow, and design decisions.
## Key Constraints
- **No hardcoded URLs** — all connection config lives in `localStorage` under `openconcho:config`
- **TanStack Router flat-route params** — always cast `params` as `as never` at `navigate()` and `<Link>` callsites
- **`framer-motion` Variants typing** — import `type Variants` and annotate objects; never use `as const` on variant objects
- **Auth is optional** — token header only sent when non-empty; `checkConnection()` detects if auth is required
- **CSS variables only** — no Tailwind color utilities for theme-aware colors; use `var(--text-1)` etc.
- **Shared deps via pnpm catalog** — version-pinned in `pnpm-workspace.yaml`; reference as `"catalog:"` in package.json
- **Conventional commits enforced** — commitlint runs in husky `commit-msg`; body lines must be ≤100 chars
- **Releases via semantic-release** — `.releaserc.json`; commits land on `main`, no manual version bumps
- **GitHub account** — push under `offendingcommit` (`gh auth switch` if needed)
- **Desktop preflight is local** — Rust/Tauri compile-check no longer runs in PR CI; run `pnpm --filter @openconcho/desktop cargo-check` before pushing any `packages/desktop/**` or `packages/desktop/src-tauri/**` change

View File

@@ -1,47 +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
# Rendered to /etc/nginx/conf.d/default.conf by the image's envsubst entrypoint.
COPY --chown=101:101 docker/nginx.conf.template /etc/nginx/templates/default.conf.template
# Writes /usr/share/nginx/html/config.js from OPENCONCHO_DEFAULT_HONCHO_URL.
# --chmod=0755 so nginx's docker-entrypoint.d actually executes it.
COPY --chown=101:101 --chmod=0755 docker/40-openconcho-config.sh /docker-entrypoint.d/40-openconcho-config.sh
# Empty default → clean first run (configure the instance in Settings). Override per
# deploy to seed the first instance; the browser routes via /api with an
# X-Honcho-Upstream header. Optional OPENCONCHO_UPSTREAM_ALLOWLIST guards the proxy.
ENV OPENCONCHO_DEFAULT_HONCHO_URL=""
EXPOSE 8080
# Base image entrypoint renders the template + runs config script, then nginx (UID 101).

View File

@@ -4,8 +4,7 @@
.PHONY: bootstrap dev dev-web dev-desktop \
build test test-e2e lint lint-fix typecheck check \
ci-web ci-desktop smoke-docker \
up prod down clean install help
ci-web ci-desktop install help
help:
@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS=":.*?## "}; {printf " \033[36m%-14s\033[0m %s\n", $$1, $$2}'
@@ -48,20 +47,5 @@ ci-web: ## CI: lint + typecheck + test + build for @openconcho/web
ci-desktop: ## CI: cargo-check for @openconcho/desktop
pnpm ci:desktop
smoke-docker: ## Local: build the image + smoke-test the /api proxy (Docker required)
bash docker/smoke-test.sh
up: ## Run the web container from source (dev profile, builds) at :8080
docker compose --profile dev up -d --build
prod: ## Run the web container from the published image (prod profile, pulls latest)
docker compose --profile prod up -d
down: ## Stop + remove the web container (either profile)
docker compose --profile dev --profile prod down --remove-orphans
clean: down ## down + remove the locally built image
-docker image rm openconcho-web:local
install: ## pnpm install (no playwright)
pnpm install

View File

@@ -29,20 +29,13 @@ Browse memories, peers, sessions, and conclusions — or chat with full memory c
| | |
|---|---|
| **Dashboard** | Workspace count and queue status, auto-refreshes every 10 s |
| **Multiple instances** | Add and switch between several Honcho connections |
| **Fleet dashboard** | Cross-instance observability — workspaces/sessions/queue side-by-side with per-instance badges |
| **Workspaces** | Paginated list with per-workspace navigation |
| **Peers** | Browse peers, view representations, context, and peer cards |
| **Peer display names** | Set a friendly `display_name` (metadata) to replace raw peer ids |
| **Peer Card Seed Kits** | Author reusable peer-card kits and apply them across instances |
| **Sessions** | Paginated message history with summaries and context |
| **Conclusions** | Semantic search across conclusions with observer/subject display |
| **Dream viewer** | Browse dream/consolidation bursts with a recursive premise tree |
| **Dialectic playground** | Fan one query across all reasoning levels side-by-side |
| **Webhooks** | Manage and trigger webhooks per workspace |
| **Chat** | Conversational interface through Honcho's chat endpoint with memory context |
| **Schedule Dream** | Trigger Honcho's dream/consolidation pass on demand |
| **Demo mode** | Mask identifiers/content for screenshots and screen-sharing |
| **Dark / light mode** | Persisted per device, instant toggle |
| **Optional auth** | Token field is optional; connection health check auto-detects auth requirement |
@@ -54,15 +47,15 @@ Pre-built binaries are attached to every [GitHub Release](https://github.com/off
|---|---|
| macOS (Apple Silicon) | `OpenConcho_*_aarch64.dmg` |
| macOS (Intel) | `OpenConcho_*_x64.dmg` |
| Linux | `openconcho_*_amd64.deb` / `openconcho_*_amd64.AppImage` / `OpenConcho-*.x86_64.rpm` |
| Linux | `openconcho_*_amd64.deb` / `openconcho_*_amd64.AppImage` |
| Windows | `OpenConcho_*_x64-setup.exe` / `OpenConcho_*_x64_en-US.msi` |
## Quick Start
### Prerequisites
- [Node.js](https://nodejs.org/) ≥ 22
- [pnpm](https://pnpm.io/) 10 (pinned via `packageManager`; `corepack enable` picks it up)
- [Node.js](https://nodejs.org/) ≥ 20
- [pnpm](https://pnpm.io/) ≥ 9
- A running [Honcho](https://github.com/plastic-labs/honcho) instance (local or remote)
### Web app
@@ -87,61 +80,6 @@ pnpm install
pnpm --filter @openconcho/desktop dev
```
### Docker (web app)
The container serves the SPA and reverse-proxies the Honcho API under its own
origin: the browser calls `/api` same-origin and names the upstream in an
`X-Honcho-Upstream` header, so there's no browser CORS to configure.
Two Compose modes (the published image is `ghcr.io/offendingcommit/openconcho-web`):
```bash
# Dev-forward — build from this repo and run your local changes:
OPENCONCHO_DEFAULT_HONCHO_URL=https://honcho.example.net make up
# Production — pull the latest published image instead of building:
OPENCONCHO_DEFAULT_HONCHO_URL=https://honcho.example.net make prod
make down # stop + remove (dev or prod)
make clean # down + drop the locally built image
# → http://localhost:8080
```
Both modes live in one [`docker-compose.yml`](docker-compose.yml) as Compose
profiles: `make up` runs the `dev` profile (`build: .`), `make prod` runs the
`prod` profile (pulls `ghcr…:latest`). `OPENCONCHO_DEFAULT_HONCHO_URL` seeds the first instance
(absolute URL); `OPENCONCHO_UPSTREAM_ALLOWLIST` is an optional SSRF guard
(comma-separated host globs) for when you expose the proxy. Full details and env
vars are in [`docs/docker.md`](docs/docker.md).
### Kubernetes (Helm)
The chart is published as an OCI artifact to GHCR on every tagged release.
```bash
helm install openconcho oci://ghcr.io/offendingcommit/charts/openconcho \
--version 0.14.0 \
--create-namespace --namespace openconcho \
--set honcho.defaultUrl=https://honcho.example.com
```
Enable an Ingress and TLS:
```bash
helm install openconcho oci://ghcr.io/offendingcommit/charts/openconcho \
--version 0.14.0 \
--create-namespace --namespace openconcho \
--set honcho.defaultUrl=https://honcho.example.com \
--set ingress.enabled=true \
--set ingress.className=nginx \
--set 'ingress.hosts[0].host=openconcho.example.com' \
--set 'ingress.hosts[0].paths[0].path=/' \
--set 'ingress.tls[0].secretName=openconcho-tls' \
--set 'ingress.tls[0].hosts[0]=openconcho.example.com'
```
Full chart documentation, configuration reference, and an ArgoCD Application example are in [`charts/openconcho/README.md`](charts/openconcho/README.md).
### Connecting to your instance
1. Enter the base URL of your Honcho instance (e.g. `http://localhost:8000`)
@@ -193,7 +131,7 @@ pnpm --filter @openconcho/web generate:api
## Privacy
- Connection details (base URL + token, one or more instances) stored in `localStorage` under `openconcho:instances`
- Base URL and token stored in `localStorage` under `openconcho:config`
- Theme preference stored in `localStorage` under `openconcho:theme`
- No telemetry, no analytics, no external requests beyond your configured Honcho instance

View File

@@ -1,16 +0,0 @@
apiVersion: v2
name: openconcho
description: Self-hosted UI for Honcho — browse memories, peers, sessions, conclusions, and chat with memory context.
type: application
version: 0.14.0
appVersion: "0.14.0"
keywords:
- honcho
- memory
- ai
home: https://github.com/offendingcommit/openconcho
sources:
- https://github.com/offendingcommit/openconcho
maintainers:
- name: offendingcommit
url: https://github.com/offendingcommit

View File

@@ -1,229 +0,0 @@
# openconcho Helm Chart
Helm 3 chart for self-hosting the [openconcho](https://github.com/offendingcommit/openconcho) web UI on Kubernetes.
The chart deploys a single nginx-unprivileged container (port 8080, UID 101) that serves the React SPA and reverse-proxies Honcho API calls under `/api` to avoid browser CORS issues.
## Prerequisites
- Kubernetes 1.25+
- Helm 3.10+
- A running [Honcho](https://github.com/plastic-labs/honcho) instance reachable from within the cluster (or via a configured ingress)
## Installing
Add the chart repository:
```bash
helm registry login ghcr.io --username <github-username> --password <github-token>
```
Install the chart:
```bash
helm install openconcho oci://ghcr.io/offendingcommit/charts/openconcho \
--version 0.14.0 \
--set honcho.defaultUrl=https://honcho.example.com
```
Or with a values file (recommended):
```bash
helm install openconcho oci://ghcr.io/offendingcommit/charts/openconcho \
--version 0.14.0 \
-f my-values.yaml
```
## Upgrading
```bash
helm upgrade openconcho oci://ghcr.io/offendingcommit/charts/openconcho \
--version <new-version> \
-f my-values.yaml
```
## Uninstalling
```bash
helm uninstall openconcho
```
## Configuration
All values with their defaults are documented in [`values.yaml`](values.yaml). Key options:
| Value | Default | Description |
|---|---|---|
| `replicaCount` | `1` | Number of pod replicas |
| `image.repository` | `ghcr.io/offendingcommit/openconcho-web` | Container image |
| `image.tag` | `""` | Tag; defaults to chart `appVersion` |
| `image.pullPolicy` | `IfNotPresent` | Image pull policy |
| `honcho.defaultUrl` | `""` | Honcho URL pre-seeded in the UI |
| `honcho.upstreamAllowlist` | `""` | SSRF guard (comma-separated host globs) |
| `service.type` | `ClusterIP` | `ClusterIP` / `NodePort` / `LoadBalancer` |
| `service.port` | `80` | Service port |
| `ingress.enabled` | `false` | Enable Ingress resource |
| `ingress.className` | `""` | IngressClass name |
| `autoscaling.enabled` | `false` | Enable HorizontalPodAutoscaler |
| `podDisruptionBudget.enabled` | `false` | Enable PodDisruptionBudget |
| `networkPolicy.enabled` | `false` | Enable NetworkPolicy (same-namespace only) |
| `resources.requests.memory` | `32Mi` | Memory request |
| `resources.limits.memory` | `128Mi` | Memory limit |
## Examples
### Minimal (ClusterIP, no ingress)
```yaml
honcho:
defaultUrl: http://honcho.honcho.svc.cluster.local:8000
```
### With Ingress and TLS (cert-manager)
```yaml
honcho:
defaultUrl: https://honcho.example.com
ingress:
enabled: true
className: nginx
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
hosts:
- host: openconcho.example.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: openconcho-tls
hosts:
- openconcho.example.com
```
### With autoscaling and disruption budget
```yaml
replicaCount: 2
autoscaling:
enabled: true
minReplicas: 2
maxReplicas: 10
targetCPUUtilizationPercentage: 70
podDisruptionBudget:
enabled: true
minAvailable: 1
```
### With NetworkPolicy
> **Note:** When `networkPolicy.enabled=true` and `ingress.enabled=true`, you must add
> a policy that allows traffic from the ingress-controller namespace. Run
> `helm status <release>` for the exact `kubectl edit` command after install.
```yaml
networkPolicy:
enabled: true
ingress:
enabled: true
className: nginx
hosts:
- host: openconcho.example.com
paths:
- path: /
pathType: Prefix
```
### Private registry
```yaml
image:
repository: registry.example.com/myorg/openconcho-web
tag: "0.14.0"
pullPolicy: Always
imagePullSecrets:
- name: registry-credentials
```
## ArgoCD Application
```yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: openconcho
namespace: argocd
spec:
project: default
source:
repoURL: ghcr.io/offendingcommit/charts
chart: openconcho
targetRevision: 0.14.0
helm:
valuesObject:
honcho:
defaultUrl: https://honcho.example.com
ingress:
enabled: true
className: nginx
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
hosts:
- host: openconcho.example.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: openconcho-tls
hosts:
- openconcho.example.com
destination:
server: https://kubernetes.default.svc
namespace: openconcho
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
```
> OCI chart sources require ArgoCD 2.10+ (OCI Helm support GA).
## Helm tests
After install, run the bundled tests to verify the deployment is healthy:
```bash
helm test openconcho
```
Two test pods run and exit 0 on success:
| Test | What it checks |
|---|---|
| `test-healthz` | `GET /healthz` body equals `ok` |
| `test-spa-root` | `GET /` returns HTTP 200 |
Pass `--logs` to see output from failing pods:
```bash
helm test openconcho --logs
```
## Security posture
| Control | Value |
|---|---|
| Run as UID/GID | 101 (nginx-unprivileged) |
| `runAsNonRoot` | `true` |
| `readOnlyRootFilesystem` | `true` |
| Linux capabilities | all dropped |
| `seccompProfile` | `RuntimeDefault` |
| `allowPrivilegeEscalation` | `false` |
| `automountServiceAccountToken` | `false` |
| Writable paths | `/var/cache/nginx`, `/var/run`, `/tmp` (tmpfs) |

View File

@@ -1,40 +0,0 @@
OpenConcho {{ .Chart.AppVersion }} deployed to namespace {{ .Release.Namespace }}.
{{- if .Values.ingress.enabled }}
Access:
{{- range .Values.ingress.hosts }}
http{{ if $.Values.ingress.tls }}s{{ end }}://{{ .host }}
{{- end }}
{{- else if eq .Values.service.type "NodePort" }}
Access (NodePort):
export NODE_PORT=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "openconcho.fullname" . }} -o jsonpath="{.spec.ports[0].nodePort}")
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
echo "http://$NODE_IP:$NODE_PORT"
{{- else if eq .Values.service.type "LoadBalancer" }}
Access (LoadBalancer — IP may take a few minutes):
export LB_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "openconcho.fullname" . }} --template '{{ "{{" }}range (index .status.loadBalancer.ingress 0){{ "}}" }}{{ "{{" }}.{{ "}}" }}{{ "{{" }}end{{ "}}" }}')
echo "http://$LB_IP:{{ .Values.service.port }}"
{{- else }}
Access (port-forward):
kubectl port-forward --namespace {{ .Release.Namespace }} svc/{{ include "openconcho.fullname" . }} 8080:{{ .Values.service.port }}
Then open http://localhost:8080
{{- end }}
Run Helm tests to verify the deployment:
helm test {{ .Release.Name }}
{{- if and .Values.networkPolicy.enabled .Values.ingress.enabled }}
WARNING: NetworkPolicy + Ingress are both enabled.
The default NetworkPolicy allows port {{ .Values.service.containerPort }} only from pods within
namespace {{ .Release.Namespace }}. Ingress controllers typically run in a separate
namespace (ingress-nginx, kube-system, etc.) and will be blocked.
To allow ingress-controller traffic, add a namespaceSelector rule:
kubectl edit networkpolicy --namespace {{ .Release.Namespace }} {{ include "openconcho.fullname" . }}
# Under spec.ingress[0].from, add:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: <ingress-controller-namespace>
{{- end }}

View File

@@ -1,46 +0,0 @@
{{- define "openconcho.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- define "openconcho.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{- define "openconcho.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- define "openconcho.labels" -}}
helm.sh/chart: {{ include "openconcho.chart" . }}
{{ include "openconcho.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{- define "openconcho.selectorLabels" -}}
app.kubernetes.io/name: {{ include "openconcho.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{- define "openconcho.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "openconcho.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}
{{- define "openconcho.imageTag" -}}
{{- .Values.image.tag | default .Chart.AppVersion }}
{{- end }}

View File

@@ -1,84 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "openconcho.fullname" . }}
labels:
{{- include "openconcho.labels" . | nindent 4 }}
spec:
{{- if not .Values.autoscaling.enabled }}
replicas: {{ .Values.replicaCount }}
{{- end }}
selector:
matchLabels:
{{- include "openconcho.selectorLabels" . | nindent 6 }}
template:
metadata:
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "openconcho.selectorLabels" . | nindent 8 }}
{{- with .Values.podLabels }}
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "openconcho.serviceAccountName" . }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: {{ .Chart.Name }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
image: "{{ .Values.image.repository }}:{{ include "openconcho.imageTag" . }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.service.containerPort }}
protocol: TCP
env:
- name: OPENCONCHO_DEFAULT_HONCHO_URL
value: {{ .Values.honcho.defaultUrl | quote }}
- name: OPENCONCHO_UPSTREAM_ALLOWLIST
value: {{ .Values.honcho.upstreamAllowlist | quote }}
livenessProbe:
{{- toYaml .Values.livenessProbe | nindent 12 }}
readinessProbe:
{{- toYaml .Values.readinessProbe | nindent 12 }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
{{- if .Values.tmpfsMounts }}
volumeMounts:
{{- range .Values.tmpfsMounts }}
- name: {{ .mountPath | trimPrefix "/" | replace "/" "-" | trunc 63 | trimSuffix "-" }}
mountPath: {{ .mountPath }}
{{- end }}
{{- end }}
{{- if .Values.tmpfsMounts }}
volumes:
{{- range .Values.tmpfsMounts }}
- name: {{ .mountPath | trimPrefix "/" | replace "/" "-" | trunc 63 | trimSuffix "-" }}
emptyDir:
medium: Memory
{{- end }}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.topologySpreadConstraints }}
topologySpreadConstraints:
{{- toYaml . | nindent 8 }}
{{- end }}

View File

@@ -1,24 +0,0 @@
{{- if .Values.autoscaling.enabled }}
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ include "openconcho.fullname" . }}
labels:
{{- include "openconcho.labels" . | nindent 4 }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ include "openconcho.fullname" . }}
minReplicas: {{ .Values.autoscaling.minReplicas }}
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
{{- if .Values.autoscaling.targetCPUUtilizationPercentage }}
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
{{- end }}
{{- end }}

View File

@@ -1,39 +0,0 @@
{{- if .Values.ingress.enabled -}}
{{- $fullName := include "openconcho.fullname" . -}}
{{- $svcPort := .Values.service.port -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ $fullName }}
labels:
{{- include "openconcho.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.ingress.className }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
{{- if .Values.ingress.tls }}
tls:
{{- toYaml .Values.ingress.tls | nindent 4 }}
{{- end }}
{{- if .Values.ingress.hosts }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
pathType: {{ .pathType | default "Prefix" }}
backend:
service:
name: {{ $fullName }}
port:
number: {{ $svcPort }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}

View File

@@ -1,20 +0,0 @@
{{- if .Values.networkPolicy.enabled }}
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: {{ include "openconcho.fullname" . }}
labels:
{{- include "openconcho.labels" . | nindent 4 }}
spec:
podSelector:
matchLabels:
{{- include "openconcho.selectorLabels" . | nindent 6 }}
policyTypes:
- Ingress
ingress:
- ports:
- port: {{ .Values.service.containerPort }}
protocol: TCP
from:
- podSelector: {}
{{- end }}

View File

@@ -1,17 +0,0 @@
{{- if .Values.podDisruptionBudget.enabled }}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ include "openconcho.fullname" . }}
labels:
{{- include "openconcho.labels" . | nindent 4 }}
spec:
{{- if not (kindIs "invalid" .Values.podDisruptionBudget.maxUnavailable) }}
maxUnavailable: {{ .Values.podDisruptionBudget.maxUnavailable }}
{{- else }}
minAvailable: {{ .Values.podDisruptionBudget.minAvailable | default 1 }}
{{- end }}
selector:
matchLabels:
{{- include "openconcho.selectorLabels" . | nindent 6 }}
{{- end }}

View File

@@ -1,15 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "openconcho.fullname" . }}
labels:
{{- include "openconcho.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "openconcho.selectorLabels" . | nindent 4 }}

View File

@@ -1,13 +0,0 @@
{{- if .Values.serviceAccount.create -}}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "openconcho.serviceAccountName" . }}
labels:
{{- include "openconcho.labels" . | nindent 4 }}
{{- with .Values.serviceAccount.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
automountServiceAccountToken: {{ .Values.serviceAccount.automount }}
{{- end }}

View File

@@ -1,25 +0,0 @@
apiVersion: v1
kind: Pod
metadata:
name: "{{ include "openconcho.fullname" . }}-test-healthz"
labels:
{{- include "openconcho.labels" . | nindent 4 }}
annotations:
helm.sh/hook: test
helm.sh/hook-delete-policy: before-hook-creation
spec:
restartPolicy: Never
activeDeadlineSeconds: 60
containers:
- name: healthz
image: busybox:1.36
command:
- sh
- -c
- |
RESPONSE=$(wget -T 10 -qO- http://{{ include "openconcho.fullname" . }}:{{ .Values.service.port }}/healthz)
if [ "$RESPONSE" != "ok" ]; then
echo "FAIL: expected 'ok', got '$RESPONSE'"
exit 1
fi
echo "PASS: /healthz returned 'ok'"

View File

@@ -1,24 +0,0 @@
apiVersion: v1
kind: Pod
metadata:
name: "{{ include "openconcho.fullname" . }}-test-spa-root"
labels:
{{- include "openconcho.labels" . | nindent 4 }}
annotations:
helm.sh/hook: test
helm.sh/hook-delete-policy: before-hook-creation
spec:
restartPolicy: Never
activeDeadlineSeconds: 60
containers:
- name: spa-root
image: busybox:1.36
command:
- sh
- -c
- |
if ! wget -T 10 -q --spider http://{{ include "openconcho.fullname" . }}:{{ .Values.service.port }}/; then
echo "FAIL: GET / did not return HTTP 200"
exit 1
fi
echo "PASS: / returned HTTP 200"

View File

@@ -1,67 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"replicaCount": {
"type": "integer",
"minimum": 1
},
"image": {
"type": "object",
"required": ["repository", "pullPolicy"],
"properties": {
"repository": { "type": "string", "minLength": 1 },
"tag": { "type": "string" },
"pullPolicy": {
"type": "string",
"enum": ["Always", "IfNotPresent", "Never"]
}
}
},
"service": {
"type": "object",
"required": ["type", "port", "containerPort"],
"properties": {
"type": {
"type": "string",
"enum": ["ClusterIP", "NodePort", "LoadBalancer"]
},
"port": { "type": "integer", "minimum": 1, "maximum": 65535 },
"containerPort": { "type": "integer", "minimum": 1, "maximum": 65535 }
}
},
"honcho": {
"type": "object",
"properties": {
"defaultUrl": { "type": "string" },
"upstreamAllowlist": { "type": "string" }
}
},
"autoscaling": {
"type": "object",
"properties": {
"enabled": { "type": "boolean" },
"minReplicas": { "type": "integer", "minimum": 1 },
"maxReplicas": { "type": "integer", "minimum": 1 },
"targetCPUUtilizationPercentage": {
"type": "integer",
"minimum": 1,
"maximum": 100
}
}
},
"podDisruptionBudget": {
"type": "object",
"properties": {
"enabled": { "type": "boolean" },
"minAvailable": { "type": "integer", "minimum": 0 }
}
},
"networkPolicy": {
"type": "object",
"properties": {
"enabled": { "type": "boolean" }
}
}
}
}

View File

@@ -1,178 +0,0 @@
# Number of pod replicas. Increase for high availability or use autoscaling instead.
replicaCount: 1
image:
# Container image repository. Override to use a custom registry or fork.
repository: ghcr.io/offendingcommit/openconcho-web
# Image tag. Defaults to the chart appVersion when left empty.
tag: ""
pullPolicy: IfNotPresent
# Secrets for pulling images from private registries.
# Example: [{ name: my-registry-secret }]
imagePullSecrets: []
# Override the name portion used in resource names and labels.
nameOverride: ""
# Override the full resource name (normally release-name + chart-name).
fullnameOverride: ""
serviceAccount:
# Create a dedicated ServiceAccount for the pod.
create: true
# Disable automatic ServiceAccount token mounting — the app never calls the Kubernetes API.
automount: false
# Annotations to add to the ServiceAccount (e.g. for IRSA, Workload Identity, Vault).
annotations: {}
# Use a pre-existing ServiceAccount instead of creating one. Ignored when create is true.
name: ""
# Annotations applied to every pod (not the Deployment). Useful for Prometheus scraping,
# Vault agent injection, Datadog unified service tagging, etc.
podAnnotations: {}
# Extra labels applied to every pod.
podLabels: {}
# Pod-level security context shared by all containers.
# UID/GID 101 matches the nginx-unprivileged base image — do not change without rebuilding.
podSecurityContext:
runAsNonRoot: true
runAsUser: 101
runAsGroup: 101
fsGroup: 101
seccompProfile:
type: RuntimeDefault
# Container-level security context.
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: [ALL]
# Directories mounted as ephemeral tmpfs (in-memory) to satisfy nginx's write requirements
# when the root filesystem is read-only. Add entries for any additional writable paths.
tmpfsMounts:
- mountPath: /var/cache/nginx
- mountPath: /var/run
- mountPath: /tmp
service:
# Kubernetes Service type. Options: ClusterIP | NodePort | LoadBalancer
type: ClusterIP
# Port exposed by the Service (what the Ingress or other pods target).
port: 80
# Port the container actually listens on (nginx-unprivileged default).
containerPort: 8080
ingress:
enabled: false
# IngressClass name. Leave empty to accept the cluster default.
# Examples: nginx | traefik | alb | kong
className: ""
# Annotations forwarded verbatim to the Ingress resource.
# Example (cert-manager + nginx-ingress):
# kubernetes.io/ingress.class: nginx
# cert-manager.io/cluster-issuer: letsencrypt-prod
annotations: {}
hosts:
- host: openconcho.example.com
paths:
- path: /
pathType: Prefix
# TLS configuration. Provide a Secret containing the certificate.
# Example:
# - secretName: openconcho-tls
# hosts:
# - openconcho.example.com
tls: []
honcho:
# Default Honcho instance URL pre-populated in the UI on first load.
# Users can change or add instances at runtime; this only seeds the initial value.
# Example: https://honcho.example.com
defaultUrl: ""
# Optional SSRF guard: comma-separated host globs the nginx proxy is allowed to forward to.
# Leave empty to allow any upstream. Applies only when the proxy is publicly reachable.
# Example: honcho.example.com,*.internal.example.com
upstreamAllowlist: ""
# CPU and memory requests / limits for the web container.
# The SPA is static HTML/JS, so memory is the primary concern and CPU is negligible at rest.
resources:
requests:
cpu: 50m
memory: 32Mi
limits:
memory: 128Mi
livenessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 5
periodSeconds: 10
autoscaling:
enabled: false
minReplicas: 1
maxReplicas: 5
# Scale up when average CPU utilization across pods exceeds this percentage.
targetCPUUtilizationPercentage: 80
podDisruptionBudget:
enabled: false
# Minimum number of pods that must remain available during voluntary disruptions
# (node drains, rolling upgrades). Set maxUnavailable instead to flip the direction.
minAvailable: 1
# Uncomment to use maxUnavailable instead — cannot set both simultaneously.
# maxUnavailable: 1
# Restrict pod-to-pod traffic at the network layer. When enabled, only pods in the
# same namespace may reach the web container.
# WARNING: if ingress is also enabled, you must add a policy that allows traffic from
# the ingress-controller namespace — run `helm status <release>` to see the reminder.
networkPolicy:
enabled: false
# Spread pods across failure domains (availability zones, nodes, etc.) to reduce
# the blast radius of a single-node or single-zone failure.
# Example (zone spread):
# - maxSkew: 1
# topologyKey: topology.kubernetes.io/zone
# whenUnsatisfiable: DoNotSchedule
# labelSelector:
# matchLabels:
# app.kubernetes.io/name: openconcho
topologySpreadConstraints: []
# Constrain pods to nodes whose labels match these key/value pairs.
# Example: kubernetes.io/arch: amd64
nodeSelector: {}
# Allow pods to be scheduled on tainted nodes.
# Example:
# - key: dedicated
# operator: Equal
# value: web
# effect: NoSchedule
tolerations: []
# Advanced pod affinity / anti-affinity rules.
# Example (soft anti-affinity — prefer spreading across different nodes):
# podAntiAffinity:
# preferredDuringSchedulingIgnoredDuringExecution:
# - weight: 100
# podAffinityTerm:
# labelSelector:
# matchLabels:
# app.kubernetes.io/name: openconcho
# topologyKey: kubernetes.io/hostname
affinity: {}

View File

@@ -1,48 +0,0 @@
# OpenConcho web UI — one file, two Compose profiles (dev builds, prod pulls).
#
# make up # profile dev: build from THIS repo + run → http://localhost:8080
# make prod # profile prod: pull ghcr…:latest instead of building
# make down # stop + remove (either profile)
# make clean # down + drop the locally built image
#
# The SPA issues all Honcho calls same-origin to /api; nginx forwards each to the
# URL named in the per-request X-Honcho-Upstream header (no browser CORS). Seed the
# first instance with OPENCONCHO_DEFAULT_HONCHO_URL:
#
# OPENCONCHO_DEFAULT_HONCHO_URL=https://honcho.example.net make up
#
# To fold into an existing Honcho Compose stack, point the seed at the api service
# (e.g. http://api:8000 — nginx resolves it on the compose network).
# Shared config (defined once); both profiles reference it via a YAML merge.
x-openconcho: &openconcho
environment:
# Absolute URL seeding the first instance; the browser sends it as the
# X-Honcho-Upstream header and nginx forwards there (no browser CORS).
OPENCONCHO_DEFAULT_HONCHO_URL: ${OPENCONCHO_DEFAULT_HONCHO_URL:-http://host.docker.internal:8000}
# Optional SSRF guard. Unset = forward anywhere (safe for the localhost-only
# binding below). Set comma-separated host globs before exposing the proxy:
# OPENCONCHO_UPSTREAM_ALLOWLIST: honcho.example.net,*.honcho.dev
OPENCONCHO_UPSTREAM_ALLOWLIST: ${OPENCONCHO_UPSTREAM_ALLOWLIST:-}
ports:
- "127.0.0.1:8080:8080"
# Lets the default host.docker.internal upstream resolve on Linux too
# (Docker Desktop / Colima provide it automatically).
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
services:
# Dev-forward — builds from source so you run your local changes (`make up`).
openconcho:
<<: *openconcho
profiles: ["dev"]
build: .
image: openconcho-web:local
# Production — pulls the published image instead of building (`make prod`).
openconcho-prod:
<<: *openconcho
profiles: ["prod"]
image: ghcr.io/offendingcommit/openconcho-web:latest
pull_policy: always

View File

@@ -1,42 +0,0 @@
#!/bin/sh
# Regenerate the SPA's runtime config from the environment at container start.
# Lets one prebuilt image target any Honcho backend without a rebuild.
# OPENCONCHO_DEFAULT_HONCHO_URL — absolute URL seeding the first instance, or empty.
# OPENCONCHO_UPSTREAM_ALLOWLIST — optional comma-separated host globs (SSRF guard).
# Runs from /docker-entrypoint.d before nginx starts. Requires the html dir to
# be writable (default); skip or bind-mount config.js when running --read-only.
set -eu
cat > /usr/share/nginx/html/config.js <<EOF
window.__OPENCONCHO_DEFAULT_HONCHO_URL__ = "${OPENCONCHO_DEFAULT_HONCHO_URL:-}";
EOF
# Derive nginx's resolver from the container's own DNS so the runtime-variable
# proxy_pass resolves on BOTH user-defined networks (Docker embedded DNS at
# 127.0.0.11) and the default bridge (host nameservers from /etc/resolv.conf).
# Hardcoding 127.0.0.11 breaks `docker run` on the default bridge (no embedded DNS).
RESOLVERS=$(awk '/^nameserver/ { print $2 }' /etc/resolv.conf | tr '\n' ' ' | sed 's/ *$//')
[ -z "$RESOLVERS" ] && RESOLVERS=127.0.0.11
printf 'resolver %s ipv6=off valid=10s;\n' "$RESOLVERS" > /etc/nginx/conf.d/00-resolver.conf
# Render the SSRF allowlist into an nginx map for $allow_upstream.
# Unset/empty OPENCONCHO_UPSTREAM_ALLOWLIST → open (default 1), fine for the
# localhost-bound default. Set it (comma-separated host globs) before exposing
# the proxy (e.g. behind a tunnel) to reject non-matching upstreams.
ALLOWLIST_CONF=/etc/nginx/conf.d/allowlist_map.conf
if [ -z "${OPENCONCHO_UPSTREAM_ALLOWLIST:-}" ]; then
printf 'map $http_x_honcho_upstream $allow_upstream { default 1; }\n' > "$ALLOWLIST_CONF"
else
{
printf 'map $http_x_honcho_upstream $allow_upstream {\n'
printf ' default 0;\n'
IFS=','
for host in $OPENCONCHO_UPSTREAM_ALLOWLIST; do
host=$(printf '%s' "$host" | tr -d ' ')
[ -z "$host" ] && continue
esc=$(printf '%s' "$host" | sed -e 's/[.]/\\./g' -e 's#[*]#[^/]*#g')
printf ' "~^https?://%s(:[0-9]+)?(/.*)?$" 1;\n' "$esc"
done
printf '}\n'
} > "$ALLOWLIST_CONF"
fi

View File

@@ -1,69 +0,0 @@
# OpenConcho — nginx site config.
# Serves the React SPA and header-driven same-origin /api proxy to Honcho.
# The browser sends X-Honcho-Upstream per request; nginx forwards server-side (no browser CORS).
server {
listen 8080;
listen [::]:8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Don't leak the nginx version.
server_tokens off;
# The resolver (required for per-request DNS with a runtime-variable proxy_pass)
# is rendered by the entrypoint into conf.d from the container's own DNS, so it
# works on both user-defined networks (127.0.0.11) and the default bridge.
# Header-driven same-origin proxy: the browser names the Honcho upstream per
# request via X-Honcho-Upstream, so the browser never makes a cross-origin call.
# $allow_upstream is provided by the allowlist map in conf.d (entrypoint-rendered).
location ^~ /api/ {
set $upstream $http_x_honcho_upstream;
if ($upstream = "") {
add_header X-Honcho-Proxy-Reject "no-upstream" always;
return 421;
}
if ($allow_upstream = 0) {
add_header X-Honcho-Proxy-Reject "allowlist" always;
return 403;
}
rewrite ^/api/(.*)$ /$1 break;
proxy_pass $upstream;
proxy_ssl_server_name on;
proxy_set_header Host $proxy_host;
proxy_set_header X-Honcho-Upstream "";
}
# Runtime config — regenerated per container start, must never be cached.
location = /config.js {
add_header Cache-Control "no-cache, no-store, must-revalidate";
try_files $uri =404;
}
# Long-cache static assets — Vite hashes filenames so they're 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;
}
# Container healthcheck (local; distinct from Honcho's /health).
location = /healthz {
access_log off;
default_type text/plain;
return 200 "ok\n";
}
# SPA fallback: unknown paths return index.html so the router resolves them.
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
gzip on;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;
}

View File

@@ -1,82 +0,0 @@
#!/usr/bin/env bash
# Hermetic container smoke test for the same-origin /api proxy.
#
# Builds the image, then stands up a stub upstream + the openconcho container on
# a shared Docker network and asserts the proxy forwards correctly. Fully
# self-contained — no external Honcho or tailnet needed. Local-only (requires a
# Docker daemon); not part of PR CI, like the desktop cargo-check preflight.
#
# Idempotent: removes its own containers/network on entry and exit. Exits non-zero
# on any failed assertion.
#
# Usage: make smoke-docker (or: bash docker/smoke-test.sh)
set -euo pipefail
cd "$(dirname "$0")/.."
IMAGE="openconcho-web:smoke"
NET="oc-smoke-net"
UPSTREAM="oc-smoke-upstream"
APP="oc-smoke-app"
PORT="${SMOKE_PORT:-18080}"
# Echo server: returns request method/path/headers as JSON for any verb.
STUB_IMAGE="mendhak/http-https-echo:31"
FAIL=0
cleanup() {
docker rm -f "$APP" "$UPSTREAM" >/dev/null 2>&1 || true
docker network rm "$NET" >/dev/null 2>&1 || true
}
trap cleanup EXIT
cleanup
wait_ready() { # url
for _ in $(seq 1 30); do
curl -fsS "$1" >/dev/null 2>&1 && return 0
sleep 0.5
done
echo " FAIL: container did not become ready at $1"
FAIL=1
}
check() { # label expected actual
if [ "$2" = "$3" ]; then echo " PASS: $1 ($3)"; else echo " FAIL: $1 — expected $2, got $3"; FAIL=1; fi
}
echo "==> build image"
docker build -t "$IMAGE" . >/dev/null
echo "==> create network + stub upstream"
docker network create "$NET" >/dev/null
docker run -d --name "$UPSTREAM" --network "$NET" -e HTTP_PORT=8080 "$STUB_IMAGE" >/dev/null
echo "==> start openconcho (default-open allowlist)"
docker run -d --name "$APP" --network "$NET" -p "$PORT:8080" \
-e "OPENCONCHO_DEFAULT_HONCHO_URL=http://$UPSTREAM:8080" "$IMAGE" >/dev/null
wait_ready "http://localhost:$PORT/healthz"
echo "==> assertions"
check "healthz 200" 200 "$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:$PORT/healthz")"
check "SPA served 200" 200 "$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:$PORT/")"
check "config.js injected" 200 "$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:$PORT/config.js")"
# Proxy forwards POST /api/v3/test -> stub, stripping the /api prefix.
body=$(curl -s "http://localhost:$PORT/api/v3/test" \
-H "X-Honcho-Upstream: http://$UPSTREAM:8080" -H 'content-type: application/json' -X POST -d '{}')
if echo "$body" | grep -q '/v3/test'; then echo " PASS: /api forwards + strips prefix"; else echo " FAIL: forward/strip — body: $body"; FAIL=1; fi
# Routing header must NOT leak to the upstream.
if echo "$body" | grep -qi 'x-honcho-upstream'; then echo " FAIL: X-Honcho-Upstream leaked upstream"; FAIL=1; else echo " PASS: X-Honcho-Upstream cleared upstream"; fi
# Missing routing header -> 421.
check "missing header 421" 421 "$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:$PORT/api/v3/test" -X POST -d '{}')"
echo "==> restart with a non-matching allowlist"
docker rm -f "$APP" >/dev/null
docker run -d --name "$APP" --network "$NET" -p "$PORT:8080" \
-e "OPENCONCHO_UPSTREAM_ALLOWLIST=*.honcho.dev" "$IMAGE" >/dev/null
wait_ready "http://localhost:$PORT/healthz"
check "allowlist reject 403" 403 "$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:$PORT/api/v3/test" \
-H "X-Honcho-Upstream: http://$UPSTREAM:8080" -X POST -d '{}')"
reject=$(curl -s -D- -o /dev/null "http://localhost:$PORT/api/v3/test" \
-H "X-Honcho-Upstream: http://$UPSTREAM:8080" -X POST -d '{}' | grep -i 'X-Honcho-Proxy-Reject' | tr -d '\r')
if echo "$reject" | grep -qi 'allowlist'; then echo " PASS: reject sentinel header present"; else echo " FAIL: missing reject sentinel — got: $reject"; FAIL=1; fi
if [ "$FAIL" = 0 ]; then echo "==> SMOKE TEST PASSED"; else echo "==> SMOKE TEST FAILED"; exit 1; fi

View File

@@ -1,104 +0,0 @@
# Running OpenConcho in Docker
The `@openconcho/web` SPA ships as a container: a two-stage build (Node + pnpm
builds the static bundle, then `nginx-unprivileged` serves it on port `8080` as
a non-root user) that also **reverse-proxies the Honcho API under its own
origin**, so the browser never makes a cross-origin request.
## How the proxy works
The browser issues every Honcho call same-origin to `/api/*` and names the real
upstream per request in an `X-Honcho-Upstream` header (sourced from the active
instance's base URL). nginx strips `/api`, forwards to that upstream server-side,
and returns the response. Because the browser→nginx hop is same-origin, **no CORS
applies**; the nginx→Honcho hop is server-side, where CORS is irrelevant. The
frontend stays the source of truth for which instance to talk to, so the
multi-instance switcher and the Fleet view keep working.
## Compose: dev vs prod profiles
One [`docker-compose.yml`](../docker-compose.yml) with two profiles; the shared
config (env, ports, extra_hosts) is defined once via a YAML anchor:
- **`dev` profile** — `build: .`, runs **your local source** (`make up`).
- **`prod` profile** — pulls the **published image**
(`ghcr.io/offendingcommit/openconcho-web:latest`, `pull_policy: always`) (`make prod`).
```bash
make up # build from source + run → http://localhost:8080
make prod # pull ghcr…:latest instead of building
make down # stop + remove (either profile)
make clean # down + drop the locally built image
```
`make up` expands to `docker compose --profile dev up -d --build` and `make prod`
to `docker compose --profile prod up -d`. A bare `docker compose up` (no profile)
starts nothing — use the make targets or pass `--profile`. Set env inline or via a
`.env` file:
```bash
OPENCONCHO_DEFAULT_HONCHO_URL=https://honcho.example.net make prod
```
The published image is multi-arch (amd64 + arm64); the first publish creates a
private GHCR package — make it public for unauthenticated pulls.
## Add it to an existing Honcho Compose stack
Drop the `openconcho` service into the project that runs your Honcho `api`,
pointing the seed at the api service (nginx resolves it on the compose network):
```yaml
services:
openconcho:
image: ghcr.io/offendingcommit/openconcho-web:latest
environment:
OPENCONCHO_DEFAULT_HONCHO_URL: http://api:8000
ports:
- "127.0.0.1:8080:8080"
depends_on:
api:
condition: service_healthy
restart: unless-stopped
```
`OPENCONCHO_DEFAULT_HONCHO_URL` seeds the UI's first instance with an absolute
URL. The browser sends that URL in the `X-Honcho-Upstream` header; nginx (on the
compose network) forwards to it — **no browser CORS, and the API token never
leaves the origin.**
## Standalone (no compose)
```bash
docker run --rm -p 8080:8080 -e OPENCONCHO_DEFAULT_HONCHO_URL=http://host.docker.internal:8000 \
ghcr.io/offendingcommit/openconcho-web:latest
# → http://localhost:8080 · GET /healthz returns "ok"
```
Runtime knobs (no rebuild needed):
| Env | Default | Meaning |
|-----|---------|---------|
| `OPENCONCHO_DEFAULT_HONCHO_URL` | _(empty)_ | Absolute URL seeding the first instance; empty = configure in Settings |
| `OPENCONCHO_UPSTREAM_ALLOWLIST` | _(empty)_ | Optional SSRF guard: comma-separated host globs (e.g. `honcho.example.net,*.honcho.dev`). Empty = forward anywhere |
Hardened run adds `--read-only --cap-drop ALL --security-opt no-new-privileges`
with `--tmpfs /tmp --tmpfs /var/cache/nginx`. Note: the entrypoint writes
`config.js` and the allowlist map at start, which a read-only root blocks — under
`--read-only` either bind-mount those paths or leave the env empty and configure
the URL in Settings.
## SSRF: when to set the allowlist
The header-driven proxy forwards to whatever upstream the client names. With the
default `127.0.0.1:8080` binding only your own machine can reach nginx, so leaving
the allowlist open is fine. **Before exposing the proxy** (e.g. behind a tunnel),
set `OPENCONCHO_UPSTREAM_ALLOWLIST` to the host globs you trust — non-matching
upstreams are rejected with `403` and an `X-Honcho-Proxy-Reject: allowlist` header.
## CORS, the short version
The desktop app routes HTTP through Rust (reqwest) and bypasses browser CORS; the
web build solves it with the same-origin `/api` proxy above — **nothing to
configure on Honcho.** The proxy makes a Honcho-side `CORSMiddleware` unnecessary
regardless of which instance you point at.

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: 218 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 215 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,782 +0,0 @@
# Header-Driven `/api` Proxy — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Eliminate browser CORS for the web build by routing Honcho API calls through a same-origin, header-driven reverse proxy, while leaving the Tauri desktop path untouched and preserving Fleet aggregation.
**Architecture:** A single `dispatchFor(instance)` helper decides transport at runtime — web mode returns `baseUrl="/api"` plus an `X-Honcho-Upstream` header (the real Honcho URL); Tauri mode returns the absolute URL and reqwest. nginx (docker) and a Vite middleware (dev) read the header and forward server-side, so the browser never makes a cross-origin request. Instances are still stored as absolute URLs — only dispatch changes.
**Tech Stack:** React 19, openapi-fetch, TanStack Query, Vitest, Biome, nginx (envsubst template), Vite dev server, Tauri v2.
**Spec:** `docs/superpowers/specs/2026-06-02-honcho-api-proxy-design.md`
**Baseline gate (run before starting AND after every task):**
`make ci-web` (lint + typecheck + test + build). Targeted test during a task: `pnpm --filter @openconcho/web exec vitest run <path>`.
**Commit discipline:** conventional commits, one logical change per commit, body lines ≤100 chars, no AI attribution. Branch `feat/web-api-proxy` (already created; spec already committed there).
> **Amendment (2026-06-02, post-execution):** Tasks 13 are implemented and committed
> (`d4452ab`, `9945e4c`, `0935099`). During execution a design correction was made:
> the web-mode base is **absolute same-origin** (`${location.origin}/api`), not the bare
> relative `"/api"`. Reason: a relative base makes `openapi-fetch` call
> `new Request("/api/...")`, which throws `ERR_INVALID_URL` under node/undici and is
> fragile in the browser. `dispatchFor` now returns `${location.origin}${API_PREFIX}`
> in web mode. Consequently `fleet.test.tsx` **was** updated (the original claim that it
> "stays green untouched" was wrong — the transport contract genuinely changed) and any
> test that inspects the dispatched URL asserts the absolute same-origin form. Tests mock
> `@/lib/http` (not `globalThis.fetch`), because `httpFetch` captures the fetch reference
> at module load, so `vi.stubGlobal("fetch", …)` would not intercept it.
---
## File Structure
| File | Responsibility | Action |
|------|----------------|--------|
| `packages/web/src/lib/platform.ts` | Single `isTauri()` predicate (leaf module, no app imports) | Create |
| `packages/web/src/lib/dispatch.ts` | `dispatchFor()` + proxy constants — the one transport decision | Create |
| `packages/web/src/lib/http.ts` | Keep `httpFetch`; consume `isTauri` from platform | Modify |
| `packages/web/src/lib/discovery.ts` | Re-export `isTauri` from platform; route probe via `dispatchFor` | Modify |
| `packages/web/src/api/client.ts` | Active-instance client via `dispatchFor` | Modify |
| `packages/web/src/api/scopedClient.ts` | Scoped client (Fleet/seed-kits) via `dispatchFor` | Modify |
| `packages/web/src/lib/config.ts` | `checkConnection` via `dispatchFor` + proxy-reject handling | Modify |
| `packages/web/src/lib/runtimeConfig.ts` | Drop `same-origin` sentinel | Modify |
| `packages/web/vite.config.ts` | Dev `/api` proxy middleware mirroring nginx | Modify |
| `docker/nginx.conf.template` | Header-driven `^~ /api/` block (replaces `/v3` + `/health`) | Modify |
| `docker/40-openconcho-config.sh` | Render allowlist `map` for `$allow_upstream` | Modify |
| `docker-compose.yml` | Retire `HONCHO_UPSTREAM`; document allowlist env | Modify |
| `AGENTS.md`, `README.md` | Proxy contract + env vars | Modify |
| `packages/web/src/test/dispatch.test.ts` | Unit tests for `dispatchFor` | Create |
| `packages/web/src/test/check-connection.test.ts` | Unit tests for `checkConnection` proxy behavior | Create |
---
## Task 1: Extract `isTauri()` into a leaf module
Prevents an import cycle: `discovery.ts` will later import `dispatchFor`, and `dispatch.ts` needs `isTauri`. A leaf `platform.ts` breaks the cycle and gives one canonical predicate (WIOCHE).
**Files:**
- Create: `packages/web/src/lib/platform.ts`
- Test: `packages/web/src/test/platform.test.ts`
- Modify: `packages/web/src/lib/http.ts`, `packages/web/src/lib/discovery.ts`
- [ ] **Step 1: Write the failing test**
`packages/web/src/test/platform.test.ts`:
```ts
import { afterEach, describe, expect, it } from "vitest";
import { isTauri } from "@/lib/platform";
describe("isTauri", () => {
afterEach(() => {
delete (window as unknown as Record<string, unknown>).__TAURI_INTERNALS__;
});
it("returns false in a plain browser/jsdom environment", () => {
expect(isTauri()).toBe(false);
});
it("returns true when the Tauri internals global is present", () => {
(window as unknown as Record<string, unknown>).__TAURI_INTERNALS__ = {};
expect(isTauri()).toBe(true);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `pnpm --filter @openconcho/web exec vitest run src/test/platform.test.ts`
Expected: FAIL — cannot resolve `@/lib/platform`.
- [ ] **Step 3: Create the module**
`packages/web/src/lib/platform.ts`:
```ts
/** True when running inside the Tauri desktop shell (WebView with injected internals). */
export function isTauri(): boolean {
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
}
```
- [ ] **Step 4: Point existing consumers at the canonical predicate**
In `packages/web/src/lib/http.ts`, replace the inline const with the shared predicate (call it at module load to preserve current behavior):
```ts
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
import { isTauri } from "@/lib/platform";
// Route fetch through Rust (reqwest) when running in Tauri — bypasses WebView CORS enforcement.
// Falls back to native browser fetch during plain web dev.
export const httpFetch: typeof globalThis.fetch = isTauri()
? (tauriFetch as typeof globalThis.fetch)
: globalThis.fetch;
```
In `packages/web/src/lib/discovery.ts`, delete the local `isTauri` function (lines 8-10) and re-export the canonical one so existing importers keep working. Add at the top, after the `httpFetch` import:
```ts
export { isTauri } from "@/lib/platform";
```
- [ ] **Step 5: Run tests + lint + typecheck**
Run: `pnpm --filter @openconcho/web exec vitest run src/test/platform.test.ts && make lint && make typecheck`
Expected: PASS; no type errors.
- [ ] **Step 6: Commit**
```bash
git add packages/web/src/lib/platform.ts packages/web/src/lib/http.ts \
packages/web/src/lib/discovery.ts packages/web/src/test/platform.test.ts
git commit -m "refactor(web): extract isTauri into a leaf platform module"
```
---
## Task 2: `dispatchFor` helper + proxy constants
The heart of the design. Decides transport per instance.
**Files:**
- Create: `packages/web/src/lib/dispatch.ts`
- Test: `packages/web/src/test/dispatch.test.ts`
- [ ] **Step 1: Write the failing test**
`packages/web/src/test/dispatch.test.ts`:
```ts
import { afterEach, describe, expect, it, vi } from "vitest";
const mockIsTauri = vi.fn();
vi.mock("@/lib/platform", () => ({ isTauri: () => mockIsTauri() }));
import {
API_PREFIX,
dispatchFor,
PROXY_REJECT_HEADER,
UPSTREAM_HEADER,
} from "@/lib/dispatch";
afterEach(() => mockIsTauri.mockReset());
describe("dispatchFor — web mode", () => {
it("targets the /api prefix and carries the upstream header", () => {
mockIsTauri.mockReturnValue(false);
const d = dispatchFor({ baseUrl: "https://honcho.example.net/", token: "" });
expect(d.baseUrl).toBe(API_PREFIX);
expect(d.headers[UPSTREAM_HEADER]).toBe("https://honcho.example.net");
expect(d.headers.Authorization).toBeUndefined();
});
it("adds Authorization only when a token is present", () => {
mockIsTauri.mockReturnValue(false);
const d = dispatchFor({ baseUrl: "https://honcho.example.net", token: "sk-1" });
expect(d.headers.Authorization).toBe("Bearer sk-1");
});
});
describe("dispatchFor — tauri mode", () => {
it("targets the absolute URL with no upstream header", () => {
mockIsTauri.mockReturnValue(true);
const d = dispatchFor({ baseUrl: "https://honcho.example.net", token: "sk-1" });
expect(d.baseUrl).toBe("https://honcho.example.net");
expect(d.headers[UPSTREAM_HEADER]).toBeUndefined();
expect(d.headers.Authorization).toBe("Bearer sk-1");
});
});
describe("proxy reject header constant", () => {
it("is the agreed sentinel name", () => {
expect(PROXY_REJECT_HEADER).toBe("X-Honcho-Proxy-Reject");
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `pnpm --filter @openconcho/web exec vitest run src/test/dispatch.test.ts`
Expected: FAIL — cannot resolve `@/lib/dispatch`.
- [ ] **Step 3: Create the helper**
`packages/web/src/lib/dispatch.ts`:
```ts
import { httpFetch } from "@/lib/http";
import { isTauri } from "@/lib/platform";
/** Same-origin path prefix the web build issues all Honcho calls through. */
export const API_PREFIX = "/api";
/** Request header naming the real Honcho upstream for the proxy to forward to. */
export const UPSTREAM_HEADER = "X-Honcho-Upstream";
/** Response header the proxy sets on its OWN refusals (so they aren't read as upstream auth). */
export const PROXY_REJECT_HEADER = "X-Honcho-Proxy-Reject";
export interface Dispatch {
baseUrl: string;
headers: Record<string, string>;
fetch: typeof globalThis.fetch;
}
function normalizeUpstream(url: string): string {
return url.trim().replace(/\/+$/, "");
}
/**
* Resolve how to issue a request for an instance.
* - Web: same-origin `/api` + `X-Honcho-Upstream` header (proxy forwards server-side, no CORS).
* - Tauri: the absolute instance URL via reqwest (no browser same-origin policy).
*/
export function dispatchFor(instance: { baseUrl: string; token?: string }): Dispatch {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (instance.token) headers.Authorization = `Bearer ${instance.token}`;
if (isTauri()) {
return { baseUrl: instance.baseUrl, headers, fetch: httpFetch };
}
headers[UPSTREAM_HEADER] = normalizeUpstream(instance.baseUrl);
return { baseUrl: API_PREFIX, headers, fetch: httpFetch };
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `pnpm --filter @openconcho/web exec vitest run src/test/dispatch.test.ts`
Expected: PASS (5 assertions across 4 tests).
- [ ] **Step 5: Commit**
```bash
git add packages/web/src/lib/dispatch.ts packages/web/src/test/dispatch.test.ts
git commit -m "feat(web): add dispatchFor transport helper for same-origin proxy"
```
---
## Task 3: Route the API clients through `dispatchFor`
**Files:**
- Modify: `packages/web/src/api/client.ts`, `packages/web/src/api/scopedClient.ts`
- [ ] **Step 1: Rewrite `client.ts`**
`packages/web/src/api/client.ts`:
```ts
import createClient from "openapi-fetch";
import { loadConfig } from "@/lib/config";
import { dispatchFor } from "@/lib/dispatch";
import type { paths } from "./schema.d.ts";
export function createHonchoClient() {
const config = loadConfig() ?? { baseUrl: "http://localhost:8000", token: "" };
const { baseUrl, headers, fetch } = dispatchFor(config);
return createClient<paths>({ baseUrl, headers, fetch });
}
export const client = {
get current() {
return createHonchoClient();
},
};
```
- [ ] **Step 2: Rewrite `scopedClient.ts`**
`packages/web/src/api/scopedClient.ts`:
```ts
import createClient from "openapi-fetch";
import type { Instance } from "@/lib/config";
import { dispatchFor } from "@/lib/dispatch";
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
* query non-active instances (e.g. the Fleet side-by-side comparison). Each scoped
* client self-routes via its own X-Honcho-Upstream header in web mode.
*/
export function createScopedClient(instance: Instance): ScopedClient {
const { baseUrl, headers, fetch } = dispatchFor(instance);
return createClient<paths>({ baseUrl, headers, fetch });
}
```
- [ ] **Step 3: Verify the full web suite stays green**
Run: `pnpm --filter @openconcho/web exec vitest run && make typecheck && make lint`
Expected: PASS — existing `fleet.test.tsx`, `seed-kits.test.ts`, `app.test.tsx` still pass (transport swap is invisible to them).
- [ ] **Step 4: Commit**
```bash
git add packages/web/src/api/client.ts packages/web/src/api/scopedClient.ts
git commit -m "feat(web): route api clients through dispatchFor"
```
---
## Task 4: `checkConnection` + discovery via the proxy, with reject handling
**Files:**
- Modify: `packages/web/src/lib/config.ts` (`checkConnection`), `packages/web/src/lib/discovery.ts` (`suggestNameForInstance`)
- Test: `packages/web/src/test/check-connection.test.ts`
- [ ] **Step 1: Write the failing test**
`packages/web/src/test/check-connection.test.ts`:
```ts
import { afterEach, describe, expect, it, vi } from "vitest";
// Mock the platform predicate (web mode) and the fetch boundary. We mock
// @/lib/http — NOT globalThis.fetch — because httpFetch captures the fetch
// reference at module load, so vi.stubGlobal would not be observed by dispatchFor.
const { mockIsTauri, httpFetchMock } = vi.hoisted(() => ({
mockIsTauri: vi.fn(() => false),
httpFetchMock: vi.fn(),
}));
vi.mock("@/lib/platform", () => ({ isTauri: () => mockIsTauri() }));
vi.mock("@/lib/http", () => ({ httpFetch: httpFetchMock }));
import { checkConnection } from "@/lib/config";
afterEach(() => {
httpFetchMock.mockReset();
mockIsTauri.mockReturnValue(false);
});
describe("checkConnection — web proxy mode", () => {
it("calls the absolute same-origin /api path with the upstream header", async () => {
httpFetchMock.mockResolvedValue(new Response("{}", { status: 200 }));
const res = await checkConnection("https://honcho.example.net", "sk-1");
expect(res.status).toBe("ok");
const [url, init] = httpFetchMock.mock.calls[0];
expect(String(url)).toBe(`${location.origin}/api/v3/workspaces/list`);
expect((init.headers as Record<string, string>)["X-Honcho-Upstream"]).toBe(
"https://honcho.example.net",
);
expect((init.headers as Record<string, string>).Authorization).toBe("Bearer sk-1");
});
it("maps an upstream 401 to auth-required", async () => {
httpFetchMock.mockResolvedValue(new Response("{}", { status: 401 }));
const res = await checkConnection("https://honcho.example.net");
expect(res.status).toBe("auth-required");
});
it("treats a proxy reject as unreachable, not auth-required", async () => {
httpFetchMock.mockResolvedValue(
new Response("", { status: 403, headers: { "X-Honcho-Proxy-Reject": "allowlist" } }),
);
const res = await checkConnection("https://blocked.example.net");
expect(res.status).toBe("unreachable");
expect(res.message).toMatch(/allowlist/i);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `pnpm --filter @openconcho/web exec vitest run src/test/check-connection.test.ts`
Expected: FAIL — current `checkConnection` fetches `${baseUrl}/v3/...` directly (no `/api`, no upstream header, no reject handling).
- [ ] **Step 3: Rewrite `checkConnection`**
In `packages/web/src/lib/config.ts`, replace the `checkConnection` body (keep the signature). Add the import `import { dispatchFor, PROXY_REJECT_HEADER } from "@/lib/dispatch";` at the top (and remove the now-unused `httpFetch` import if nothing else in the file uses it):
```ts
export async function checkConnection(
baseUrl: string,
token?: string,
): Promise<{ status: HealthStatus; message: string }> {
try {
const { baseUrl: base, headers, fetch } = dispatchFor({ baseUrl, token });
const res = await fetch(`${base}/v3/workspaces/list`, {
method: "POST",
headers,
body: JSON.stringify({}),
signal: AbortSignal.timeout(5000),
});
const reject = res.headers.get(PROXY_REJECT_HEADER);
if (reject) {
return { status: "unreachable", message: `Proxy refused upstream (${reject})` };
}
if (res.ok) return { status: "ok", message: "Connected successfully" };
if (res.status === 401 || res.status === 403) {
return { status: "auth-required", message: "Authentication required — provide an API token" };
}
return { status: "unreachable", message: `Server returned ${res.status}` };
} catch (err) {
const msg = err instanceof Error ? err.message : "Unknown error";
if (msg.includes("AbortError") || msg.includes("timeout")) {
return { status: "unreachable", message: "Connection timed out" };
}
return { status: "unreachable", message: `Cannot reach server: ${msg}` };
}
}
```
- [ ] **Step 4: Route the discovery probe through `dispatchFor` too**
In `packages/web/src/lib/discovery.ts`, rewrite `suggestNameForInstance` to dispatch consistently. Add `import { dispatchFor } from "@/lib/dispatch";` and replace the fetch line:
```ts
export async function suggestNameForInstance(baseUrl: string): Promise<string | null> {
try {
const { baseUrl: base, headers, fetch } = dispatchFor({ baseUrl });
const res = await fetch(`${base}/v3/workspaces/list?page=1&page_size=1`, {
method: "POST",
headers,
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;
}
}
```
(Leave the top-of-file `httpFetch` import only if still referenced; otherwise remove it. `discovery.ts` no longer needs `httpFetch` after this change — remove the import.)
- [ ] **Step 5: Run tests + gate**
Run: `pnpm --filter @openconcho/web exec vitest run src/test/check-connection.test.ts && pnpm --filter @openconcho/web exec vitest run && make typecheck && make lint`
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add packages/web/src/lib/config.ts packages/web/src/lib/discovery.ts \
packages/web/src/test/check-connection.test.ts
git commit -m "feat(web): route checkConnection and discovery through the proxy"
```
---
## Task 5: Drop the `same-origin` sentinel from runtime config
In header mode the default instance needs a real absolute URL (it becomes the header value); `same-origin` was glue for the retired `/v3` proxy.
**Files:**
- Modify: `packages/web/src/lib/runtimeConfig.ts`
- Test: `packages/web/src/test/runtime-config.test.ts`
- [ ] **Step 1: Write the failing test**
`packages/web/src/test/runtime-config.test.ts`:
```ts
import { afterEach, describe, expect, it } from "vitest";
import { runtimeDefaultBaseUrl } from "@/lib/runtimeConfig";
const KEY = "__OPENCONCHO_DEFAULT_HONCHO_URL__";
afterEach(() => {
delete (globalThis as Record<string, unknown>)[KEY];
});
describe("runtimeDefaultBaseUrl", () => {
it("returns an injected absolute URL verbatim", () => {
(globalThis as Record<string, unknown>)[KEY] = "https://honcho.example.net";
expect(runtimeDefaultBaseUrl()).toBe("https://honcho.example.net");
});
it("returns null when unset or empty", () => {
expect(runtimeDefaultBaseUrl()).toBeNull();
(globalThis as Record<string, unknown>)[KEY] = " ";
expect(runtimeDefaultBaseUrl()).toBeNull();
});
});
```
- [ ] **Step 2: Run test to verify current behavior is covered/fails appropriately**
Run: `pnpm --filter @openconcho/web exec vitest run src/test/runtime-config.test.ts`
Expected: PASS for absolute/empty (existing behavior), but the goal is to simplify; proceed to remove the sentinel branch.
- [ ] **Step 3: Simplify the module**
`packages/web/src/lib/runtimeConfig.ts`:
```ts
const GLOBAL_KEY = "__OPENCONCHO_DEFAULT_HONCHO_URL__";
/**
* Runtime-injected default Honcho base URL for container deployments.
*
* The Docker image writes `/config.js` from `OPENCONCHO_DEFAULT_HONCHO_URL` at
* container start, so one prebuilt image can target any backend without a rebuild.
* The web build proxies this URL via the same-origin `/api` reverse proxy (no CORS).
*
* - an absolute URL → that URL (seeds the first instance)
* - empty / unset → null (no default; the user configures in Settings)
*/
export function runtimeDefaultBaseUrl(): string | null {
const raw = (globalThis as Record<string, unknown>)[GLOBAL_KEY];
if (typeof raw !== "string" || raw.trim() === "") return null;
return raw.trim();
}
```
- [ ] **Step 4: Run test + gate**
Run: `pnpm --filter @openconcho/web exec vitest run src/test/runtime-config.test.ts && make typecheck && make lint`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add packages/web/src/lib/runtimeConfig.ts packages/web/src/test/runtime-config.test.ts
git commit -m "refactor(web): drop same-origin sentinel from runtime config"
```
---
## Task 6: nginx header-driven `/api` proxy
**Files:**
- Modify: `docker/nginx.conf.template`
- [ ] **Step 1: Replace the `/v3` + `/health` blocks with the `/api` block**
In `docker/nginx.conf.template`, delete the `location ^~ /v3/ { ... }` and `location = /health { ... }` blocks (lines 21-32) and the `set $honcho_upstream ...` line. Keep the `resolver` line. Insert:
```nginx
# Header-driven same-origin proxy: the browser names the Honcho upstream per
# request via X-Honcho-Upstream, so the browser never makes a cross-origin call.
# $allow_upstream is provided by the allowlist map in conf.d (entrypoint-rendered).
location ^~ /api/ {
set $upstream $http_x_honcho_upstream;
if ($upstream = "") {
add_header X-Honcho-Proxy-Reject "no-upstream" always;
return 421;
}
if ($allow_upstream = 0) {
add_header X-Honcho-Proxy-Reject "allowlist" always;
return 403;
}
rewrite ^/api/(.*)$ /$1 break;
proxy_pass $upstream;
proxy_ssl_server_name on;
proxy_set_header Host $proxy_host;
proxy_set_header X-Honcho-Upstream "";
}
```
- [ ] **Step 2: Validate template renders to valid nginx syntax**
Run (renders the template with a dummy upstream and an open allowlist map, then `nginx -t`):
```bash
docker run --rm -e HONCHO_UPSTREAM=http://x:8000 -v "$PWD/docker":/d nginxinc/nginx-unprivileged:stable sh -c '
mkdir -p /etc/nginx/conf.d
echo "map \$http_x_honcho_upstream \$allow_upstream { default 1; }" > /etc/nginx/conf.d/allowlist_map.conf
envsubst "\$HONCHO_UPSTREAM" < /d/nginx.conf.template > /etc/nginx/conf.d/default.conf
nginx -t'
```
Expected: `nginx: configuration file /etc/nginx/nginx.conf test is successful`.
- [ ] **Step 3: Commit**
```bash
git add docker/nginx.conf.template
git commit -m "feat(docker): header-driven /api reverse proxy in nginx"
```
---
## Task 7: Entrypoint renders the allowlist map
**Files:**
- Modify: `docker/40-openconcho-config.sh`
- [ ] **Step 1: Append allowlist-map rendering**
In `docker/40-openconcho-config.sh`, after the existing `config.js` heredoc, append:
```sh
# Render the SSRF allowlist into an nginx map for $allow_upstream.
# Unset/empty OPENCONCHO_UPSTREAM_ALLOWLIST → open (default 1), fine for the
# localhost-bound default. Set it (comma-separated host globs) before exposing
# the proxy (e.g. behind a tunnel) to reject non-matching upstreams.
ALLOWLIST_CONF=/etc/nginx/conf.d/allowlist_map.conf
if [ -z "${OPENCONCHO_UPSTREAM_ALLOWLIST:-}" ]; then
printf 'map $http_x_honcho_upstream $allow_upstream { default 1; }\n' > "$ALLOWLIST_CONF"
else
{
printf 'map $http_x_honcho_upstream $allow_upstream {\n'
printf ' default 0;\n'
IFS=','
for host in $OPENCONCHO_UPSTREAM_ALLOWLIST; do
host=$(printf '%s' "$host" | tr -d ' ')
[ -z "$host" ] && continue
esc=$(printf '%s' "$host" | sed -e 's/[.]/\\./g' -e 's/[*]/[^/]*/g')
printf ' "~^https?://%s(:[0-9]+)?(/.*)?$" 1;\n' "$esc"
done
printf '}\n'
} > "$ALLOWLIST_CONF"
fi
```
- [ ] **Step 2: Validate generated map syntax (allowlist set)**
Run:
```bash
docker run --rm -e OPENCONCHO_UPSTREAM_ALLOWLIST="honcho.example.net,*.honcho.dev" \
-e HONCHO_UPSTREAM=http://x:8000 -v "$PWD/docker":/d nginxinc/nginx-unprivileged:stable sh -c '
mkdir -p /etc/nginx/conf.d
export OPENCONCHO_DEFAULT_HONCHO_URL=https://honcho.example.net
sh /d/40-openconcho-config.sh || true
envsubst "\$HONCHO_UPSTREAM" < /d/nginx.conf.template > /etc/nginx/conf.d/default.conf
nginx -t && cat /etc/nginx/conf.d/allowlist_map.conf'
```
Expected: `nginx -t` success; printed map contains regex lines for both hosts.
Note: the script writes `config.js` to `/usr/share/nginx/html`; if that dir is absent in this bare check, the heredoc line may error — that is fine for syntax validation (the `|| true` guards it). The allowlist block still runs.
- [ ] **Step 3: Commit**
```bash
git add docker/40-openconcho-config.sh
git commit -m "feat(docker): render SSRF allowlist map from env"
```
---
## Task 8: Vite dev proxy middleware (dev/CI parity)
**Files:**
- Modify: `packages/web/vite.config.ts`
- [ ] **Step 1: Add a `configureServer` plugin mirroring nginx**
In `packages/web/vite.config.ts`, add this plugin factory above `defineConfig` and include `honchoApiProxy()` in the `plugins` array (after `react()`):
```ts
import type { Plugin } from "vite";
function honchoApiProxy(): Plugin {
const HEADER = "x-honcho-upstream";
return {
name: "honcho-api-proxy",
configureServer(server) {
server.middlewares.use("/api", async (req, res) => {
const upstream = req.headers[HEADER];
if (typeof upstream !== "string" || upstream.trim() === "") {
res.statusCode = 421;
res.setHeader("X-Honcho-Proxy-Reject", "no-upstream");
res.end();
return;
}
const target = upstream.replace(/\/+$/, "") + (req.url ?? "");
const chunks: Buffer[] = [];
for await (const c of req) chunks.push(c as Buffer);
try {
const upstreamRes = await fetch(target, {
method: req.method,
headers: {
"content-type": req.headers["content-type"] ?? "application/json",
...(req.headers.authorization
? { authorization: req.headers.authorization }
: {}),
},
body: ["GET", "HEAD"].includes(req.method ?? "") ? undefined : Buffer.concat(chunks),
});
res.statusCode = upstreamRes.status;
upstreamRes.headers.forEach((v, k) => res.setHeader(k, v));
res.end(Buffer.from(await upstreamRes.arrayBuffer()));
} catch (e) {
res.statusCode = 502;
res.end(`proxy error: ${e instanceof Error ? e.message : String(e)}`);
}
});
},
};
}
```
Update the plugins line to:
```ts
plugins: [tanstackRouter({ autoCodeSplitting: true }), react(), honchoApiProxy(), tailwindcss()],
```
- [ ] **Step 2: Typecheck + lint (the config is type-checked by the build)**
Run: `make typecheck && make lint`
Expected: PASS (no `any`, `Plugin` typed).
- [ ] **Step 3: Commit**
```bash
git add packages/web/vite.config.ts
git commit -m "feat(web): dev /api proxy middleware mirroring nginx"
```
---
## Task 9: Compose env + docs
**Files:**
- Modify: `docker-compose.yml`, `AGENTS.md`, `README.md`
- [ ] **Step 1: Update `docker-compose.yml`**
Replace the `environment:` block and its comments so the upstream is no longer a single env var. New `environment:` section:
```yaml
environment:
# The SPA seeds its first instance from this absolute URL; the browser then
# routes all calls same-origin through /api, and nginx forwards them to the
# URL named per-request in the X-Honcho-Upstream header (no browser CORS).
OPENCONCHO_DEFAULT_HONCHO_URL: ${OPENCONCHO_DEFAULT_HONCHO_URL:-http://host.docker.internal:8000}
# Optional SSRF guard. Unset = forward anywhere (safe for the localhost-only
# binding below). Set comma-separated host globs before exposing the proxy:
# OPENCONCHO_UPSTREAM_ALLOWLIST: honcho.example.net,*.honcho.dev
OPENCONCHO_UPSTREAM_ALLOWLIST: ${OPENCONCHO_UPSTREAM_ALLOWLIST:-}
```
Update the top-of-file comment block: replace the `HONCHO_UPSTREAM=...` example with `OPENCONCHO_DEFAULT_HONCHO_URL=https://honcho.example.net docker compose up` and note the per-request header model.
- [ ] **Step 2: Update `AGENTS.md` Key Constraints**
Replace the CORS-relevant bullet(s) with:
```markdown
- **Web CORS is handled by a same-origin `/api` proxy** — the browser issues all
Honcho calls to `/api/*` with an `X-Honcho-Upstream` header; nginx (docker) and a
Vite middleware (dev) forward server-side. Tauri bypasses CORS via reqwest and uses
absolute URLs. Optional `OPENCONCHO_UPSTREAM_ALLOWLIST` guards the proxy when exposed.
```
- [ ] **Step 3: Update `README.md`**
In the Docker/run section, document `OPENCONCHO_DEFAULT_HONCHO_URL` (absolute URL seed) and `OPENCONCHO_UPSTREAM_ALLOWLIST` (optional, comma-separated host globs), and remove any `HONCHO_UPSTREAM` references.
- [ ] **Step 4: Commit**
```bash
git add docker-compose.yml AGENTS.md README.md
git commit -m "docs: document the /api proxy contract and env vars"
```
---
## Task 10: Final CI-parity gate + branch readiness
**Files:** none (verification only)
- [ ] **Step 1: Run the full web gate**
Run: `make ci-web`
Expected: lint, typecheck, test, and build all PASS.
- [ ] **Step 2: Confirm no `HONCHO_UPSTREAM` or `same-origin` references remain**
Run: `grep -rn "HONCHO_UPSTREAM\|same-origin" docker docker-compose.yml packages/web/src README.md AGENTS.md`
Expected: no matches in code/config (only the spec/plan under `docs/` may mention them historically).
- [ ] **Step 3: Confirm clean tree**
Run: `git status --short`
Expected: empty (all work committed).
---
## Self-Review (completed by author)
- **Spec coverage:** dispatch helper (T2/T3), checkConnection+discovery (T4), nginx header proxy + SNI + reject header (T6), allowlist map (T7), vite parity (T8), env migration + docs (T9), Fleet preserved (T3 — scopedClient routed; existing fleet.test.tsx asserted green), runtime sentinel drop (T5). All spec sections mapped.
- **Placeholder scan:** none — every code step shows full content.
- **Type consistency:** `dispatchFor`, `Dispatch`, `API_PREFIX`, `UPSTREAM_HEADER`, `PROXY_REJECT_HEADER` are defined in T2 and consumed verbatim in T3/T4. `isTauri()` defined T1, consumed T2.

View File

@@ -1,243 +0,0 @@
# Header-Driven `/api` Proxy for Web CORS — Design
- **Date:** 2026-06-02
- **Status:** Approved (design) — pending spec review
- **Scope:** One concern — eliminate browser CORS for the web build by routing
Honcho API calls through a same-origin, header-driven reverse proxy. Preserve
existing Fleet aggregation. No new aggregation features (deferred).
## Problem
The web build (`@openconcho/web`) talks to Honcho directly from the browser. When
the configured instance URL is a different origin than the page (e.g. a self-hosted
Honcho at `https://honcho.example.net` while the UI runs on
`http://localhost:8080`), the browser issues a CORS preflight on the `Authorization`
header and the request fails — Honcho ships no `CORSMiddleware`.
The desktop (Tauri) build is unaffected: it routes fetch through Rust/`reqwest`
(`packages/web/src/lib/http.ts`), which has no browser same-origin policy.
The repo already had a partial mitigation (a static `^~ /v3/` nginx proxy keyed to a
single `HONCHO_UPSTREAM`), but it (a) supported only one backend and (b) was bypassed
the moment a user typed an absolute URL into Settings — which is the bug that surfaced.
### Evidence gathered
- Browser → `honcho.example.net` is reachable; the CORS error proves the
request reached Honcho and only the browser policy check failed.
- A Docker container under Colima **also** reaches the tailnet: `docker run ...
curl https://honcho.example.net/health` returned **HTTP 200**, connected over the
tailnet on `:443` with TLS verified. So a container-side proxy is viable on this
host (Colima forwards container egress through the host's tailnet routing).
## Decisions
1. **Coexist by runtime mode.** Tauri keeps absolute-URL + `reqwest`. The web build
(docker **and** `make dev-web`) routes through a same-origin `/api` proxy. One
build; behavior chosen at runtime by `isTauri()`.
2. **Header-driven routing.** The browser names the target upstream per request via
an `X-Honcho-Upstream` header (sourced from the active/scoped instance's
`baseUrl`). The proxy is a stateless forwarder; the frontend stays the single
source of truth for instances. No server-side slug→upstream map.
3. **SSRF posture: optional allowlist, open by default.** Unset
`OPENCONCHO_UPSTREAM_ALLOWLIST` ⇒ forward anywhere (safe for the default
`127.0.0.1:8080` binding). Set it (host globs) before exposing the proxy (e.g.
behind `cloudflared`) to reject non-matching upstreams.
4. **Aggregation: preserve, don't extend.** The existing Fleet dashboard
(`compareQueries.ts`, `fleetAggregates.ts`, `FleetDashboard`/`FleetRow`) must keep
working identically. New cross-instance merge/dedup/search is explicitly a
**non-goal** of this PR.
## Architecture
```
WEB (docker + dev):
browser ──same-origin──▶ /api/v3/... (openapi-fetch base = "/api")
X-Honcho-Upstream: https://honcho.example.net (from instance.baseUrl)
Authorization: Bearer … (unchanged, when set)
│ proxy: validate header, allowlist-check, strip "/api",
│ proxy_pass $upstream, set SNI/Host, drop routing header
https://honcho.example.net/v3/... (server-side hop — no CORS)
TAURI:
webview ──reqwest──▶ https://honcho.example.net/v3/... (unchanged)
```
**Why a custom header is free here:** `X-Honcho-Upstream` rides a *same-origin*
request (browser → `/api`), so it triggers no CORS preflight. Preflight only fires
cross-origin — the exact condition this design removes.
**Why the instance store is unchanged:** instances still persist an absolute
`baseUrl` (`z.string().url()` stays valid). We change only *how a request is
dispatched*, not what is stored. In web mode the instance URL stops being the fetch
target and becomes the header value.
## Components
### A. Centralized dispatch helper (new) — `src/lib/dispatch.ts`
Single source of truth for "how to issue a request for an instance," replacing four
ad-hoc constructions.
```ts
export const API_PREFIX = "/api";
export const UPSTREAM_HEADER = "X-Honcho-Upstream";
export interface Dispatch {
baseUrl: string; // "/api" (web) | instance.baseUrl (tauri)
headers: Record<string, string>; // Content-Type, Authorization?, X-Honcho-Upstream?
fetch: typeof globalThis.fetch; // globalThis.fetch (web) | tauriFetch (tauri)
}
export function dispatchFor(
instance: Pick<Instance, "baseUrl" | "token">,
): Dispatch;
```
- **Web:** `baseUrl = API_PREFIX`; headers include `UPSTREAM_HEADER =
normalizedUpstream(instance.baseUrl)` (trailing slash stripped) and `Authorization`
when `token` is non-empty; `fetch = globalThis.fetch`.
- **Tauri:** `baseUrl = instance.baseUrl`; no upstream header; `fetch = tauriFetch`.
`API_PREFIX` and `UPSTREAM_HEADER` are named constants (WIOCHE) referenced by both
the frontend and documented for the proxy.
### B. Consumers of the helper (the four dispatch sites)
| Site | File | Change |
|------|------|--------|
| Active-instance client | `src/api/client.ts` | build client from `dispatchFor(loadConfig())` |
| Scoped client (Fleet/compare, seed-kits) | `src/api/scopedClient.ts` | build client from `dispatchFor(instance)` |
| Connection health check | `src/lib/config.ts` `checkConnection` | fetch `${baseUrl}/v3/workspaces/list` via `dispatchFor` (hits `/api/...` in web) |
| Discovery name probe | `src/lib/discovery.ts` `suggestNameForInstance` | same, via `dispatchFor` |
`compareQueries.ts` and `fleetAggregates.ts` need **no changes** — they go through
`createScopedClient`, so the transport swap is invisible to them. `FleetRow.tsx:114`
uses `instance.baseUrl` only to render a hostname label (cosmetic) — untouched.
### C. nginx proxy — `docker/nginx.conf.template`
Replace the `^~ /v3/` and `= /health` upstream blocks with one header-driven block:
```nginx
resolver 127.0.0.11 ipv6=off valid=10s;
# Rendered by the entrypoint from OPENCONCHO_UPSTREAM_ALLOWLIST.
# Unset → default 1 (open). Set → 1 only for matching hosts, else 0.
# (map block injected here)
location ^~ /api/ {
set $upstream $http_x_honcho_upstream;
if ($upstream = "") { return 421; } # misdirected: no target named
if ($allow_upstream = 0) { return 403; } # allowlist reject
rewrite ^/api/(.*)$ /$1 break; # strip /api, keep /v3/...
proxy_pass $upstream;
proxy_ssl_server_name on; # SNI for HTTPS upstreams
proxy_set_header Host $proxy_host; # upstream host, not localhost:8080
proxy_set_header X-Honcho-Upstream ""; # never leak routing header upstream
}
```
`Authorization` and other client headers pass through by nginx default. The
container's own `/healthz` liveness endpoint is unchanged.
### D. Vite dev parity — `vite.config.ts`
A `configureServer` middleware mirrors nginx for `make dev-web`: read
`X-Honcho-Upstream`, forward `/api/*` (prefix stripped) to it, drop the routing
header, apply the same allowlist if configured. Result: dev behaves identically to
the docker image (local/CI parity).
### E. Config / env — `docker/40-openconcho-config.sh`, `docker-compose.yml`
- `OPENCONCHO_DEFAULT_HONCHO_URL` keeps seeding the first instance, now an **absolute
URL only**. Drop the `same-origin` sentinel (glue for the retired `/v3` proxy) from
`src/lib/runtimeConfig.ts`.
- **Retire `HONCHO_UPSTREAM`** from compose — the upstream now comes from the header.
- New optional `OPENCONCHO_UPSTREAM_ALLOWLIST` (comma-separated host globs, e.g.
`honcho.example.net,*.honcho.dev`). The entrypoint renders it into the
nginx `map` for `$allow_upstream`; unset ⇒ map default 1.
## Data flow (Fleet aggregation, web mode)
```
FleetDashboard → useScoped*(instanceA) → createScopedClient(A) → dispatchFor(A)
POST /api/v3/... X-Honcho-Upstream: A ─┐
→ useScoped*(instanceB) → createScopedClient(B) → dispatchFor(B) ├▶ nginx → A,B
POST /api/v3/... X-Honcho-Upstream: B ─┘ (concurrent)
fleetAggregates.ts merges results (transport-agnostic). B down ⇒ only B's column errors.
```
Query keys in `compareQueries.ts` are already scoped by `instance.id`, so caches
never collide across columns. Stateless per-request routing isolates partial
failures per instance.
## Error handling
The proxy must not let its own refusals masquerade as upstream responses. Both
proxy-origin refusals carry a sentinel response header **`X-Honcho-Proxy-Reject`**
(value: `no-upstream` | `allowlist`). `checkConnection` treats any response bearing
that header as `unreachable` with the reject reason — **regardless of status code** —
so an allowlist `403` is never mis-mapped to the upstream's auth `403`.
- **No `X-Honcho-Upstream`** (misconfigured web request) → proxy `421` +
`X-Honcho-Proxy-Reject: no-upstream`. Fail loud, not silent.
- **Allowlist reject** → proxy `403` + `X-Honcho-Proxy-Reject: allowlist`. The
sentinel header is what disambiguates it from the upstream's own `401/403` (which
arrive without the header and still map to `auth-required`).
- **Upstream unreachable / TLS failure** → nginx `502`; UI shows "Cannot reach
server." This is the symptom if a future host's container is *not* on the tailnet —
a network problem, not CORS (documented caveat: proxy portability depends on the
container host being able to route to the upstream).
## Testing (TDD)
Unit (mock only `isTauri` + the fetch boundary, per project test rules):
1. `dispatchFor` (web): `baseUrl === "/api"`, sets `X-Honcho-Upstream` to the
normalized instance URL, sets `Authorization` only when token non-empty, uses
`globalThis.fetch`.
2. `dispatchFor` (tauri): `baseUrl === instance.baseUrl`, no upstream header, uses
`tauriFetch`.
3. `checkConnection` (web): issues to `/api/v3/workspaces/list` with the upstream
header; maps `ok` / `401|403` / other correctly.
4. **Aggregation regression:** two scoped clients for distinct instances emit two
distinct `X-Honcho-Upstream` values; existing `src/test/fleet.test.tsx` stays
green.
Integration (nginx): compose up against a stub upstream — assert (a) header →
forward with `/api` stripped, (b) missing header → 421, (c) allowlist miss → 403,
(d) `X-Honcho-Upstream` absent on the upstream-side request.
## Non-goals (deferred to separate PRs)
- New cross-instance aggregation intelligence (unified merged lists, dedup, conflict
resolution, cross-instance search).
- Running Tailscale inside the Colima VM / any host-networking change (not needed —
reachability confirmed on the target host).
- Adding `CORSMiddleware` to Honcho (the proxy keeps openconcho self-contained and
backend-agnostic; this is the deliberate alternative *not* taken).
## Files touched
- `packages/web/src/lib/dispatch.ts` (new)
- `packages/web/src/api/client.ts`
- `packages/web/src/api/scopedClient.ts`
- `packages/web/src/lib/config.ts`
- `packages/web/src/lib/discovery.ts`
- `packages/web/src/lib/runtimeConfig.ts`
- `packages/web/vite.config.ts`
- `docker/nginx.conf.template`
- `docker/40-openconcho-config.sh`
- `docker-compose.yml`
- tests: `packages/web/src/test/` (new dispatch + checkConnection cases; keep
`fleet.test.tsx`, `settings-form.test.tsx` green)
- docs: `AGENTS.md`, `README` (env vars + the proxy contract)
## Constraints honored
- Web-only change → PR CI (web checks) covers it; no desktop `cargo-check` needed
(Tauri path unchanged).
- No hardcoded URLs (upstream comes from instance config / runtime env).
- One concern per PR (proxy only); conventional commits; push under `offendingcommit`.

View File

@@ -1,7 +1,7 @@
{
"name": "openconcho",
"private": true,
"version": "0.16.0",
"version": "0.8.0",
"packageManager": "pnpm@10.33.2",
"engines": {
"node": ">=22",
@@ -22,22 +22,21 @@
"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": {
"@biomejs/biome": "catalog:",
"@commitlint/cli": "~20.5.3",
"@commitlint/config-conventional": "~20.5.3",
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/commit-analyzer": "^13.0.1",
"@commitlint/cli": "~20.5.2",
"@commitlint/config-conventional": "~20.5.0",
"@semantic-release/changelog": "^6.0.0",
"@semantic-release/commit-analyzer": "^13.0.0",
"@semantic-release/exec": "^7.1.0",
"@semantic-release/git": "^10.0.1",
"@semantic-release/github": "^10.3.5",
"@semantic-release/git": "^10.0.0",
"@semantic-release/github": "^10.0.0",
"@semantic-release/npm": "^13.1.5",
"@semantic-release/release-notes-generator": "^14.1.1",
"@semantic-release/release-notes-generator": "^14.0.0",
"husky": "~9.1.7",
"semantic-release": "catalog:",
"turbo": "^2.9.16"
"turbo": "^2"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "openconcho"
version = "0.16.0"
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,51 +0,0 @@
import { expect, test } from "@playwright/test";
const STORE_KEY = "openconcho:instances";
// Two unreachable instances — the rows still render with their configured
// names; only the health column flips to "unreachable" once the workspaces
// query errors. We only assert on the rendered names + row count, so the
// test doesn't depend on a live backend.
const FLEET_STORE = JSON.stringify({
instances: [
{ id: "a", name: "Neo", baseUrl: "http://localhost:9001", token: "" },
{ id: "b", name: "Iris", baseUrl: "http://localhost:9002", token: "" },
{ id: "c", name: "Lexi", baseUrl: "http://localhost:9003", token: "" },
],
activeId: "a",
});
test.describe("Fleet route", () => {
test.beforeEach(async ({ context }) => {
await context.addInitScript(
([key, value]) => {
window.localStorage.setItem(key, value);
},
[STORE_KEY, FLEET_STORE],
);
});
test("renders one row per configured instance and the Fleet heading", async ({ page }) => {
await page.goto("/fleet");
// Page header
await expect(page.getByRole("heading", { name: /^Fleet$/ })).toBeVisible();
// One row per instance, asserted via the table not the sidebar (the
// active instance's name also appears in the sidebar switcher).
const table = page.getByRole("table");
await expect(table.getByText("Neo", { exact: true })).toBeVisible();
await expect(table.getByText("Iris", { exact: true })).toBeVisible();
await expect(table.getByText("Lexi", { exact: true })).toBeVisible();
// 1 header row + 3 instance rows
await expect(table.getByRole("row")).toHaveCount(4);
});
test("Fleet link in the sidebar navigates to /fleet", async ({ page }) => {
await page.goto("/");
await page.getByRole("link", { name: /fleet/i }).click();
await expect(page).toHaveURL(/\/fleet$/);
await expect(page.getByRole("heading", { name: /^Fleet$/ })).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

@@ -17,7 +17,6 @@ test.describe("Sidebar", () => {
await page.goto("/");
await expect(page.getByRole("complementary")).toBeVisible();
await expect(page.getByRole("link", { name: /dashboard/i })).toBeVisible();
await expect(page.getByRole("link", { name: /fleet/i })).toBeVisible();
await expect(page.getByRole("link", { name: /workspaces/i })).toBeVisible();
await expect(page.getByRole("link", { name: /settings/i })).toBeVisible();
});

View File

@@ -6,8 +6,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OpenConcho</title>
<meta name="description" content="Frontend for self-hosted Honcho instances — browse memories, chat with memory context" />
<!-- Runtime config (regenerated by the Docker image at start; no-op otherwise) -->
<script src="/config.js"></script>
</head>
<body>
<div id="root"></div>

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,4 +0,0 @@
// Runtime configuration placeholder. In the Docker image this file is
// regenerated at container start from the OPENCONCHO_DEFAULT_HONCHO_URL env.
// In dev and the desktop build it stays a no-op.
window.__OPENCONCHO_DEFAULT_HONCHO_URL__ = "";

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,12 +1,21 @@
import createClient from "openapi-fetch";
import { loadConfig } from "@/lib/config";
import { dispatchFor } from "@/lib/dispatch";
import { httpFetch } from "@/lib/http";
import type { paths } from "./schema.d.ts";
export function createHonchoClient() {
const config = loadConfig() ?? { baseUrl: "http://localhost:8000", token: "" };
const { baseUrl, headers, fetch } = dispatchFor(config);
return createClient<paths>({ baseUrl, headers, fetch });
const config = loadConfig();
const baseUrl = config?.baseUrl ?? "http://localhost:8000";
const token = config?.token ?? "";
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
return createClient<paths>({ baseUrl, headers, fetch: httpFetch });
}
export const client = {

View File

@@ -1,128 +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,
queueStatus: (instId: string, wsId: string) => ["compare", instId, "queue-status", wsId] as const,
conclusionsCount: (instId: string, wsId: string) =>
["compare", instId, "conclusions-count", wsId] 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),
});
}
// Option builders — used by both single-fetch hooks and useQueries fan-out (e.g. Fleet view).
export function scopedQueueStatusOptions(instance: Instance, workspaceId: string) {
return {
queryKey: CK.queueStatus(instance.id, workspaceId),
queryFn: async () => {
const client = createScopedClient(instance);
const { data, error } = await client.GET("/v3/workspaces/{workspace_id}/queue/status", {
params: { path: { workspace_id: workspaceId } },
});
return data ?? err(error);
},
enabled: Boolean(workspaceId),
refetchInterval: 10_000,
} as const;
}
export function scopedConclusionsCountOptions(instance: Instance, workspaceId: string) {
return {
queryKey: CK.conclusionsCount(instance.id, workspaceId),
queryFn: async () => {
const client = createScopedClient(instance);
const { data, error } = await client.POST("/v3/workspaces/{workspace_id}/conclusions/list", {
params: { path: { workspace_id: workspaceId }, query: { page: 1, size: 1 } },
body: {},
});
return data ?? err(error);
},
enabled: Boolean(workspaceId),
} as const;
}
export function useScopedQueueStatus(instance: Instance, workspaceId: string) {
return useQuery(scopedQueueStatusOptions(instance, workspaceId));
}
export function useScopedConclusionsCount(instance: Instance, workspaceId: string) {
return useQuery(scopedConclusionsCountOptions(instance, workspaceId));
}

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

@@ -15,7 +15,7 @@ export function useWorkspaces(page = 1, pageSize = 20) {
queryKey: QK.workspaces(page, pageSize),
queryFn: async () => {
const { data, error } = await client.current.POST("/v3/workspaces/list", {
params: { query: { page, size: pageSize } },
params: { query: { page, page_size: pageSize } },
body: {},
});
return data ?? err(error);
@@ -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,
});
}
@@ -139,7 +123,7 @@ export function usePeers(workspaceId: string, page = 1, pageSize = 20) {
const { data, error } = await client.current.POST(
"/v3/workspaces/{workspace_id}/peers/list",
{
params: { path: { workspace_id: workspaceId }, query: { page, size: pageSize } },
params: { path: { workspace_id: workspaceId }, query: { page, page_size: pageSize } },
body: {},
},
);
@@ -253,7 +237,7 @@ export function usePeerSessions(workspaceId: string, peerId: string, page = 1, p
{
params: {
path: { workspace_id: workspaceId, peer_id: peerId },
query: { page, size: pageSize },
query: { page, page_size: pageSize },
},
body: {},
},
@@ -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);
@@ -323,9 +293,9 @@ export function useSessions(workspaceId: string, page = 1, pageSize = 20) {
{
params: {
path: { workspace_id: workspaceId },
query: { page, size: pageSize },
query: { page, page_size: pageSize },
},
body: { filters: { reverse: true } },
body: { reverse: true },
},
);
return data ?? err(error);
@@ -412,7 +382,7 @@ export function useSessionMessages(
{
params: {
path: { workspace_id: workspaceId, session_id: sessionId },
query: { page, size: pageSize },
query: { page, page_size: pageSize },
},
body: {},
},
@@ -642,7 +612,7 @@ export function useConclusions(
{
params: {
path: { workspace_id: workspaceId },
query: { page, size: pageSize, reverse },
query: { page, page_size: pageSize, reverse },
},
body: filters,
},
@@ -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,16 +0,0 @@
import createClient from "openapi-fetch";
import type { Instance } from "@/lib/config";
import { dispatchFor } from "@/lib/dispatch";
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
* query non-active instances (e.g. the Fleet side-by-side comparison). Each scoped
* client self-routes via its own X-Honcho-Upstream header in web mode.
*/
export function createScopedClient(instance: Instance): ScopedClient {
const { baseUrl, headers, fetch } = dispatchFor(instance);
return createClient<paths>({ baseUrl, headers, fetch });
}

View File

@@ -1,101 +1,159 @@
import { Link, useNavigate } from "@tanstack/react-router";
import { Link } from "@tanstack/react-router";
import { motion } from "framer-motion";
import { Boxes, LayoutDashboard, Network, Settings as SettingsIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
computeFleetAggregates,
DEFAULT_ROW_METRICS,
type FleetRowMetrics,
} from "@/components/fleet/fleetAggregates";
import { EmptyState } from "@/components/shared/EmptyState";
import { Body, PageTitle, SectionHeading } from "@/components/ui/typography";
import { useInstances } from "@/hooks/useInstances";
import type { Instance } from "@/lib/config";
import { Activity, Boxes, ChevronRight, CircleDot, LayoutDashboard } from "lucide-react";
import { useState } from "react";
import { useQueueStatus, useWorkspaces } from "@/api/queries";
import type { components } from "@/api/schema.d.ts";
import { ErrorAlert } from "@/components/shared/ErrorAlert";
import { Skeleton } from "@/components/shared/Skeleton";
import { Body, Muted, PageTitle, SectionHeading } from "@/components/ui/typography";
import { useDemo } from "@/hooks/useDemo";
import { COLOR } from "@/lib/constants";
import { formatCount } from "@/lib/utils";
import { ServerWorkspaceRows } from "./ServerWorkspaceRows";
const ALL_SERVERS = "all";
type QueueStatus = components["schemas"]["QueueStatus"];
/**
* Unified, server-aware dashboard: every workspace across every configured server,
* labelled `<workspace> (<server>)` and filterable by server. Aggregates fold in the
* cross-server totals (formerly the standalone Fleet view). Opening a workspace
* activates its server, then drills into the existing workspace detail route.
*/
export function Dashboard() {
const { instances, activeId, activate } = useInstances();
const navigate = useNavigate();
const [serverFilter, setServerFilter] = useState<string>(ALL_SERVERS);
const [metricsById, setMetricsById] = useState<Record<string, FleetRowMetrics>>({});
const lastMetrics = useRef<Record<string, FleetRowMetrics>>({});
// ─── Per-workspace queue row ─────────────────────────────────────────────────
useEffect(() => {
if (serverFilter !== ALL_SERVERS && !instances.find((i) => i.id === serverFilter)) {
setServerFilter(ALL_SERVERS);
}
}, [instances, serverFilter]);
function WorkspaceQueueRow({ workspaceId }: { workspaceId: string }) {
const { mask } = useDemo();
const { data, isLoading } = useQueueStatus(workspaceId);
const onMetrics = useCallback((id: string, m: FleetRowMetrics) => {
const prev = lastMetrics.current[id];
if (
prev &&
prev.workspaceCount === m.workspaceCount &&
prev.conclusionCount === m.conclusionCount &&
prev.queueActive === m.queueActive &&
prev.queuePending === m.queuePending &&
prev.health === m.health
)
return;
lastMetrics.current = { ...lastMetrics.current, [id]: m };
setMetricsById((prev) => ({ ...prev, [id]: m }));
}, []);
const pending = data?.pending_work_units ?? 0;
const active = data?.in_progress_work_units ?? 0;
const done = data?.completed_work_units ?? 0;
const total = data?.total_work_units ?? 0;
const isActive = active > 0 || pending > 0;
const onOpenWorkspace = useCallback(
(instance: Instance, workspaceId: string) => {
if (instance.id !== activeId) activate(instance.id);
navigate({ to: "/workspaces/$workspaceId", params: { workspaceId } as never });
},
[activeId, activate, navigate],
);
return (
<tr
style={{
borderTop: "1px solid var(--border)",
background: isActive ? COLOR.warningDim : undefined,
}}
>
<td className="py-2 px-4">
<Link
to="/workspaces/$workspaceId"
params={{ workspaceId } as never}
className="flex items-center gap-2 group"
>
<span
className="font-mono text-xs truncate max-w-[200px] group-hover:underline"
style={{ color: "var(--accent-text)" }}
>
{mask(workspaceId)}
</span>
<ChevronRight
className="w-3 h-3 opacity-0 group-hover:opacity-60 transition-opacity flex-shrink-0"
style={{ color: "var(--accent)" }}
strokeWidth={2}
/>
</Link>
</td>
const shownInstances = useMemo(
() =>
serverFilter === ALL_SERVERS ? instances : instances.filter((i) => i.id === serverFilter),
[instances, serverFilter],
);
const agg = useMemo(
() =>
computeFleetAggregates(shownInstances.map((i) => metricsById[i.id] ?? DEFAULT_ROW_METRICS)),
[shownInstances, metricsById],
);
if (instances.length === 0) {
return (
<div className="page-container page-container--xl">
<EmptyState
icon={Boxes}
title="No servers configured"
description="Add at least one Honcho server in Settings to see your workspaces."
action={
<Link
to="/settings"
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md"
style={{
background: "var(--accent-dim)",
border: "1px solid var(--accent-border)",
color: "var(--accent-text)",
}}
<td className="py-2 px-4 text-right">
{isLoading ? (
<span className="text-xs font-mono" style={{ color: "var(--text-4)" }}>
</span>
) : (
<div className="flex items-center justify-end gap-1.5">
{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 }}
>
<SettingsIcon className="w-4 h-4" strokeWidth={1.5} />
Go to Settings
</Link>
}
/>
</div>
);
}
{isActive ? `${formatCount(pending + active)} pending` : "Idle"}
</span>
</div>
)}
</td>
{(
[
{ key: "total", val: total, color: "var(--text-2)" },
{ key: "done", val: done, color: COLOR.success },
{ key: "active", val: active, color: COLOR.warning },
{ key: "pending", val: pending, color: "var(--text-3)" },
] satisfies Array<{ key: string; val: number; color: string }>
).map(({ key, val, color }) => (
<td
key={key}
className="py-2 px-4 text-right font-mono text-xs"
style={{ color: isLoading ? "var(--text-4)" : color }}
>
{isLoading ? "—" : formatCount(val)}
</td>
))}
</tr>
);
}
// ─── Aggregate banner ─────────────────────────────────────────────────────────
// Each workspace row already called useQueueStatus — TanStack Query deduplicates
// the fetches so calling the same hooks here just reads from cache.
function GlobalQueueBanner({ workspaces }: { workspaces: Array<{ id: string }> }) {
const statuses = workspaces.map((ws) => {
const { data } = useQueueStatus(ws.id);
return data as QueueStatus | undefined;
});
const totalPending = statuses.reduce((s, d) => s + (d?.pending_work_units ?? 0), 0);
const totalActive = statuses.reduce((s, d) => s + (d?.in_progress_work_units ?? 0), 0);
const totalDone = statuses.reduce((s, d) => s + (d?.completed_work_units ?? 0), 0);
const allLoaded = statuses.every((d) => d !== undefined);
return (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{(
[
{ label: "Workspaces", value: workspaces.length, color: "var(--text-1)", always: true },
{ label: "Total done", value: totalDone, color: COLOR.success, always: false },
{ label: "Active", value: totalActive, color: COLOR.warning, always: false },
{
label: "Pending",
value: totalPending,
color: totalPending > 0 ? COLOR.warning : "var(--text-3)",
always: false,
},
] as Array<{ label: string; value: number; color: string; always: boolean }>
).map(({ label, value, color, always }) => (
<div key={label} className="rounded-xl p-4 theme-card">
<div
className="text-2xl font-semibold font-mono"
style={{ color: allLoaded || always ? color : "var(--text-4)" }}
>
{allLoaded || always ? formatCount(value) : "—"}
</div>
<div className="text-xs mt-0.5" style={{ color: "var(--text-3)" }}>
{label}
</div>
</div>
))}
</div>
);
}
// ─── Main dashboard ───────────────────────────────────────────────────────────
export function Dashboard() {
const [page] = useState(1);
const { data, isLoading, error } = useWorkspaces(page, 50);
const workspaces =
(data as { items?: Array<{ id: string; created_at?: string }> } | undefined)?.items ?? [];
const total = (data as { total?: number } | undefined)?.total ?? 0;
return (
<div className="page-container page-container--xl">
@@ -107,136 +165,163 @@ export function Dashboard() {
strokeWidth={1.5}
/>
<PageTitle>Dashboard</PageTitle>
<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}`,
}}
>
{agg.totalInstances} server{agg.totalInstances !== 1 ? "s" : ""}
</span>
{total > 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}`,
}}
>
{total} workspace{total !== 1 ? "s" : ""}
</span>
)}
</div>
<Body className="leading-none">Workspaces across every configured server</Body>
<Body className="leading-none">Overview of your Honcho instance</Body>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.05 }}
className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-4"
>
<MetricCard label="Workspaces" value={agg.totalWorkspaces} />
<MetricCard label="Conclusions" value={agg.totalConclusions} accent />
<MetricCard
label="Healthy"
value={agg.healthyCount}
total={agg.totalInstances}
color={agg.healthyCount === agg.totalInstances ? COLOR.success : COLOR.warning}
/>
<MetricCard
label="Unreachable"
value={agg.unreachableCount}
color={agg.unreachableCount > 0 ? COLOR.destructive : "var(--text-3)"}
/>
</motion.div>
<ErrorAlert error={error instanceof Error ? error : null} />
{isLoading && <DashboardSkeleton />}
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.12 }}
className="rounded-xl theme-card overflow-hidden"
>
{!isLoading && workspaces.length > 0 && (
<div className="space-y-4">
{/* Aggregate stat row */}
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.05 }}
>
<GlobalQueueBanner workspaces={workspaces} />
</motion.div>
{/* Per-workspace queue table */}
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.12 }}
className="rounded-xl theme-card overflow-hidden"
>
<div
className="flex items-center gap-2 px-4 py-3"
style={{ borderBottom: "1px solid var(--border)" }}
>
<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 · updates every 10s
</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr style={{ background: "var(--bg-3)" }}>
{["Workspace", "Status", "Total", "Done", "Active", "Pending"].map((h) => (
<th
key={h}
className={`py-2 px-4 font-medium text-left ${h !== "Workspace" && h !== "Status" ? "text-right" : ""}`}
style={{ color: "var(--text-3)" }}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{workspaces.map((ws) => (
<WorkspaceQueueRow key={ws.id} workspaceId={ws.id} />
))}
</tbody>
</table>
</div>
</motion.div>
{total > workspaces.length && (
<p className="text-xs text-center" style={{ color: "var(--text-4)" }}>
Showing {workspaces.length} of {total} workspaces.{" "}
<Link
to="/workspaces"
className="hover:underline"
style={{ color: "var(--accent-text)" }}
>
View all
</Link>
</p>
)}
</div>
)}
{!isLoading && workspaces.length === 0 && (
<div className="rounded-xl p-10 text-center theme-card">
<Boxes
className="w-8 h-8 mx-auto mb-3"
style={{ color: "var(--text-4)" }}
strokeWidth={1}
/>
<Muted>No workspaces found.</Muted>
</div>
)}
</div>
);
}
function DashboardSkeleton() {
return (
<div className="space-y-4" aria-hidden="true">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="rounded-xl p-4 theme-card">
<Skeleton accent={index === 0} className="h-8 w-16 rounded-lg" />
<Skeleton className="mt-3 h-3 w-20 rounded" />
</div>
))}
</div>
<div className="rounded-xl theme-card overflow-hidden">
<div
className="flex items-center gap-2 px-4 py-3"
style={{ borderBottom: "1px solid var(--border)" }}
>
<Network className="w-4 h-4" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
<SectionHeading className="mb-0">Workspaces</SectionHeading>
{instances.length > 1 && (
<label className="ml-auto flex items-center gap-1.5 text-xs">
<span style={{ color: "var(--text-4)" }}>Server</span>
<select
aria-label="Filter by server"
value={serverFilter}
onChange={(e) => setServerFilter(e.target.value)}
className="rounded-md px-2 py-1 text-xs"
style={{
background: "var(--bg-3)",
border: "1px solid var(--border)",
color: "var(--text-2)",
}}
>
<option value={ALL_SERVERS}>All servers</option>
{instances.map((i) => (
<option key={i.id} value={i.id}>
{i.name}
</option>
))}
</select>
</label>
)}
<Skeleton accent className="h-4 w-4 rounded" />
<Skeleton className="h-4 w-28 rounded" />
<Skeleton className="ml-1 h-3 w-32 rounded" />
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr style={{ background: "var(--bg-3)" }}>
<th className="py-2 px-4 font-medium text-left" style={{ color: "var(--text-3)" }}>
Workspace (server)
</th>
<th className="py-2 px-4 font-medium text-right" style={{ color: "var(--text-3)" }}>
Conclusions
</th>
<th
className="py-2 px-4 font-medium text-right"
style={{ color: "var(--text-3)" }}
title="Active / Pending queue work units"
>
Queue (a/p)
</th>
{Array.from({ length: 6 }).map((_, index) => (
<th key={index} className="px-4 py-2 text-left">
<Skeleton className="h-3 w-14 rounded" />
</th>
))}
</tr>
</thead>
<tbody>
{shownInstances.map((inst) => (
<ServerWorkspaceRows
key={inst.id}
instance={inst}
onOpenWorkspace={onOpenWorkspace}
onMetrics={onMetrics}
/>
{Array.from({ length: 5 }).map((_, rowIndex) => (
<tr key={rowIndex} style={{ borderTop: "1px solid var(--border)" }}>
<td className="px-4 py-3">
<Skeleton accent className="h-3 w-28 rounded" />
</td>
<td className="px-4 py-3">
<div className="flex justify-end">
<Skeleton className="h-3 w-20 rounded" />
</div>
</td>
{Array.from({ length: 4 }).map((__, cellIndex) => (
<td key={cellIndex} className="px-4 py-3">
<div className="flex justify-end">
<Skeleton className="h-3 w-8 rounded" />
</div>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</motion.div>
</div>
);
}
interface MetricCardProps {
label: string;
value: number;
total?: number;
color?: string;
accent?: boolean;
}
function MetricCard({ label, value, total, color, accent }: MetricCardProps) {
const valueColor = color ?? (accent ? COLOR.accentText : "var(--text-1)");
return (
<div className="rounded-xl p-4 theme-card">
<div className="text-2xl font-semibold font-mono" style={{ color: valueColor }}>
{formatCount(value)}
{total !== undefined && (
<span className="text-base ml-1" style={{ color: "var(--text-4)" }}>
/ {formatCount(total)}
</span>
)}
</div>
<div className="text-xs mt-0.5" style={{ color: "var(--text-3)" }}>
{label}
</div>
</div>
);

View File

@@ -1,191 +0,0 @@
import { useQueries } from "@tanstack/react-query";
import { motion } from "framer-motion";
import { ChevronRight, CircleDot } from "lucide-react";
import { useEffect, useMemo } from "react";
import {
scopedConclusionsCountOptions,
scopedQueueStatusOptions,
useScopedWorkspaces,
} from "@/api/compareQueries";
import type { components } from "@/api/schema.d.ts";
import type { FleetRowMetrics } from "@/components/fleet/fleetAggregates";
import { useDemo } from "@/hooks/useDemo";
import type { Instance } from "@/lib/config";
import { COLOR } from "@/lib/constants";
import { formatCount } from "@/lib/utils";
type Workspace = components["schemas"]["Workspace"];
type QueueStatus = components["schemas"]["QueueStatus"];
type ConclusionPage = components["schemas"]["Page_Conclusion_"];
interface Props {
instance: Instance;
/** Open a workspace's drill-down (activates the instance first if needed). */
onOpenWorkspace: (instance: Instance, workspaceId: string) => void;
/** Report this server's summed metrics up for the aggregate header. */
onMetrics: (id: string, metrics: FleetRowMetrics) => void;
}
const WORKSPACE_PAGE_SIZE = 100;
/**
* Renders one `<tr>` per workspace on a single server (instance), labelled
* `<workspace> (<server>)`, and reports the server's summed metrics up so the
* Dashboard header can aggregate across servers. Per-instance data fetching lives
* here (not in a parent loop) to satisfy the rules of hooks — one child per server.
*/
export function ServerWorkspaceRows({ instance, onOpenWorkspace, onMetrics }: Props) {
const { mask } = useDemo();
const workspacesQ = useScopedWorkspaces(instance, 1, WORKSPACE_PAGE_SIZE);
const workspaces: Workspace[] = useMemo(
() => (workspacesQ.data as { items?: Workspace[] } | undefined)?.items ?? [],
[workspacesQ.data],
);
const totalWorkspaces =
(workspacesQ.data as { total?: number } | undefined)?.total ?? workspaces.length;
const queueResults = useQueries({
queries: workspaces.map((ws) => scopedQueueStatusOptions(instance, ws.id)),
});
const conclusionsResults = useQueries({
queries: workspaces.map((ws) => scopedConclusionsCountOptions(instance, ws.id)),
});
const queueActive = queueResults.reduce(
(s, q) => s + ((q.data as QueueStatus | undefined)?.in_progress_work_units ?? 0),
0,
);
const queuePending = queueResults.reduce(
(s, q) => s + ((q.data as QueueStatus | undefined)?.pending_work_units ?? 0),
0,
);
const conclusionCount = conclusionsResults.reduce(
(s, c) => s + ((c.data as ConclusionPage | undefined)?.total ?? 0),
0,
);
const health: FleetRowMetrics["health"] = workspacesQ.isError
? "unreachable"
: workspacesQ.isSuccess
? "ok"
: "loading";
// Dep array uses primitives only — an object dep (e.g. the metrics shape) would
// create a new reference on each render even when values are unchanged, causing
// onMetrics → setMetricsById → re-render → new object → onMetrics … loop.
useEffect(() => {
onMetrics(instance.id, {
workspaceCount: totalWorkspaces,
conclusionCount,
queueActive,
queuePending,
lastSeen: null,
health,
});
}, [instance.id, totalWorkspaces, conclusionCount, queueActive, queuePending, health, onMetrics]);
if (workspacesQ.isError) {
return (
<tr
style={{ borderTop: "1px solid var(--border)" }}
data-testid={`server-error-${instance.id}`}
>
<td className="py-2.5 px-4" colSpan={3}>
<span className="text-xs" style={{ color: COLOR.destructive }}>
{instance.name} unreachable
</span>
</td>
</tr>
);
}
if (workspaces.length === 0) {
return (
<tr style={{ borderTop: "1px solid var(--border)" }}>
<td className="py-2.5 px-4" colSpan={3}>
<span className="text-xs" style={{ color: "var(--text-4)" }}>
{instance.name} {workspacesQ.isLoading ? "loading…" : "no workspaces"}
</span>
</td>
</tr>
);
}
return (
<>
{workspaces.map((ws, i) => {
const queue = queueResults[i]?.data as QueueStatus | undefined;
const active = queue?.in_progress_work_units ?? 0;
const pending = queue?.pending_work_units ?? 0;
const isActive = active > 0 || pending > 0;
const conclusions = (conclusionsResults[i]?.data as ConclusionPage | undefined)?.total;
return (
<tr
key={`${instance.id}:${ws.id}`}
data-testid={`ws-row-${instance.id}-${ws.id}`}
style={{
borderTop: "1px solid var(--border)",
background: isActive ? COLOR.warningDim : undefined,
}}
>
<td className="py-2 px-4">
<button
type="button"
onClick={() => onOpenWorkspace(instance, ws.id)}
className="flex items-center gap-2 group text-left"
>
<span
className="font-mono text-xs truncate max-w-[200px] group-hover:underline"
style={{ color: "var(--accent-text)" }}
>
{mask(ws.id)}
</span>
<span className="text-xs" style={{ color: "var(--text-4)" }}>
({instance.name})
</span>
<ChevronRight
className="w-3 h-3 opacity-0 group-hover:opacity-60 transition-opacity flex-shrink-0"
style={{ color: "var(--accent)" }}
strokeWidth={2}
/>
</button>
</td>
<td
className="py-2 px-4 text-right font-mono text-xs"
style={{ color: "var(--text-2)" }}
>
{conclusions === undefined ? "—" : formatCount(conclusions)}
</td>
<td className="py-2 px-4 text-right">
<div className="flex items-center justify-end gap-1.5">
{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 font-mono"
style={{ color: isActive ? COLOR.warning : "var(--text-3)" }}
>
{isActive ? `${formatCount(active)}/${formatCount(pending)}` : "idle"}
</span>
</div>
</td>
</tr>
);
})}
</>
);
}

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

@@ -1,177 +0,0 @@
import { Link } from "@tanstack/react-router";
import { motion } from "framer-motion";
import { Network, Server, Settings as SettingsIcon } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { EmptyState } from "@/components/shared/EmptyState";
import { Body, PageTitle, SectionHeading } from "@/components/ui/typography";
import { useInstances } from "@/hooks/useInstances";
import { COLOR } from "@/lib/constants";
import { formatCount } from "@/lib/utils";
import { FleetRow } from "./FleetRow";
import {
computeFleetAggregates,
DEFAULT_ROW_METRICS,
type FleetRowMetrics,
} from "./fleetAggregates";
export function FleetDashboard() {
const { instances } = useInstances();
const [metricsById, setMetricsById] = useState<Record<string, FleetRowMetrics>>({});
const setMetrics = useCallback((id: string, m: FleetRowMetrics) => {
setMetricsById((prev) => ({ ...prev, [id]: m }));
}, []);
const rows = useMemo(
() => instances.map((i) => metricsById[i.id] ?? DEFAULT_ROW_METRICS),
[instances, metricsById],
);
const agg = useMemo(() => computeFleetAggregates(rows), [rows]);
if (instances.length === 0) {
return (
<div className="page-container page-container--xl">
<EmptyState
icon={Network}
title="No instances configured"
description="Add at least one Honcho instance in Settings to use the Fleet view."
action={
<Link
to="/settings"
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md"
style={{
background: "var(--accent-dim)",
border: "1px solid var(--accent-border)",
color: "var(--accent-text)",
}}
>
<SettingsIcon className="w-4 h-4" strokeWidth={1.5} />
Go to Settings
</Link>
}
/>
</div>
);
}
return (
<div className="page-container page-container--xl">
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }} className="mb-8">
<div className="flex items-center gap-2 mb-1">
<Network className="w-5 h-5" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
<PageTitle>Fleet</PageTitle>
<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}`,
}}
>
{agg.totalInstances} agent{agg.totalInstances !== 1 ? "s" : ""}
</span>
</div>
<Body className="leading-none">Cross-instance overview of all configured agents</Body>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.05 }}
className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-4"
>
<MetricCard label="Workspaces" value={agg.totalWorkspaces} />
<MetricCard label="Conclusions" value={agg.totalConclusions} accent />
<MetricCard
label="Healthy"
value={agg.healthyCount}
total={agg.totalInstances}
color={agg.healthyCount === agg.totalInstances ? COLOR.success : COLOR.warning}
/>
<MetricCard
label="Unreachable"
value={agg.unreachableCount}
color={agg.unreachableCount > 0 ? COLOR.destructive : "var(--text-3)"}
/>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.12 }}
className="rounded-xl theme-card overflow-hidden"
>
<div
className="flex items-center gap-2 px-4 py-3"
style={{ borderBottom: "1px solid var(--border)" }}
>
<Server className="w-4 h-4" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
<SectionHeading className="mb-0">Agents</SectionHeading>
<span className="text-xs ml-1" style={{ color: "var(--text-4)" }}>
all configured instances · queue updates every 10s
</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr style={{ background: "var(--bg-3)" }}>
<th className="py-2 px-4 font-medium text-left" style={{ color: "var(--text-3)" }}>
Agent
</th>
<th className="py-2 px-4 font-medium text-right" style={{ color: "var(--text-3)" }}>
Workspaces
</th>
<th className="py-2 px-4 font-medium text-right" style={{ color: "var(--text-3)" }}>
Conclusions
</th>
<th
className="py-2 px-4 font-medium text-right"
style={{ color: "var(--text-3)" }}
title="Active / Pending queue work units"
>
Queue (a/p)
</th>
<th className="py-2 px-4 font-medium text-right" style={{ color: "var(--text-3)" }}>
Last seen
</th>
</tr>
</thead>
<tbody>
{instances.map((inst) => (
<FleetRow key={inst.id} instance={inst} onMetrics={setMetrics} />
))}
</tbody>
</table>
</div>
</motion.div>
</div>
);
}
interface MetricCardProps {
label: string;
value: number;
total?: number;
color?: string;
accent?: boolean;
}
function MetricCard({ label, value, total, color, accent }: MetricCardProps) {
const valueColor = color ?? (accent ? COLOR.accentText : "var(--text-1)");
return (
<div className="rounded-xl p-4 theme-card">
<div className="text-2xl font-semibold font-mono" style={{ color: valueColor }}>
{formatCount(value)}
{total !== undefined && (
<span className="text-base ml-1" style={{ color: "var(--text-4)" }}>
/ {formatCount(total)}
</span>
)}
</div>
<div className="text-xs mt-0.5" style={{ color: "var(--text-3)" }}>
{label}
</div>
</div>
);
}

View File

@@ -1,192 +0,0 @@
import { useQueries } from "@tanstack/react-query";
import { motion } from "framer-motion";
import { CircleDot } from "lucide-react";
import { useEffect, useMemo, useRef } from "react";
import {
scopedConclusionsCountOptions,
scopedQueueStatusOptions,
useScopedWorkspaces,
} from "@/api/compareQueries";
import type { components } from "@/api/schema.d.ts";
import { HealthDot } from "@/components/shared/HealthDot";
import { useDemo } from "@/hooks/useDemo";
import type { Instance } from "@/lib/config";
import { COLOR } from "@/lib/constants";
import { formatCount } from "@/lib/utils";
import type { FleetRowMetrics } from "./fleetAggregates";
type Workspace = components["schemas"]["Workspace"];
type QueueStatus = components["schemas"]["QueueStatus"];
type ConclusionPage = components["schemas"]["Page_Conclusion_"];
interface Props {
instance: Instance;
onMetrics: (id: string, metrics: FleetRowMetrics) => void;
}
function shallowEqualMetrics(a: FleetRowMetrics, b: FleetRowMetrics): boolean {
return (
a.workspaceCount === b.workspaceCount &&
a.conclusionCount === b.conclusionCount &&
a.queueActive === b.queueActive &&
a.queuePending === b.queuePending &&
a.lastSeen === b.lastSeen &&
a.health === b.health
);
}
function formatRelative(ts: number | null): string {
if (!ts) return "—";
const seconds = Math.round((Date.now() - ts) / 1000);
if (seconds < 5) return "just now";
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.round(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.round(hours / 24);
return `${days}d ago`;
}
export function FleetRow({ instance, onMetrics }: Props) {
const { mask } = useDemo();
const workspacesQ = useScopedWorkspaces(instance, 1, 100);
const workspaces: Workspace[] = useMemo(
() => (workspacesQ.data as { items?: Workspace[] } | undefined)?.items ?? [],
[workspacesQ.data],
);
const totalWorkspaces =
(workspacesQ.data as { total?: number } | undefined)?.total ?? workspaces.length;
const queueResults = useQueries({
queries: workspaces.map((ws) => scopedQueueStatusOptions(instance, ws.id)),
});
const conclusionsResults = useQueries({
queries: workspaces.map((ws) => scopedConclusionsCountOptions(instance, ws.id)),
});
const queueActive = queueResults.reduce(
(s, q) => s + ((q.data as QueueStatus | undefined)?.in_progress_work_units ?? 0),
0,
);
const queuePending = queueResults.reduce(
(s, q) => s + ((q.data as QueueStatus | undefined)?.pending_work_units ?? 0),
0,
);
const conclusionCount = conclusionsResults.reduce(
(s, c) => s + ((c.data as ConclusionPage | undefined)?.total ?? 0),
0,
);
const health: FleetRowMetrics["health"] = workspacesQ.isError
? "unreachable"
: workspacesQ.isSuccess
? "ok"
: "loading";
const lastSeen = workspacesQ.dataUpdatedAt > 0 ? workspacesQ.dataUpdatedAt : null;
const isActive = queueActive > 0 || queuePending > 0;
const isLoading =
workspacesQ.isLoading ||
queueResults.some((q) => q.isLoading) ||
conclusionsResults.some((c) => c.isLoading);
const metrics: FleetRowMetrics = useMemo(
() => ({
workspaceCount: totalWorkspaces,
conclusionCount,
queueActive,
queuePending,
lastSeen,
health,
}),
[totalWorkspaces, conclusionCount, queueActive, queuePending, lastSeen, health],
);
const lastReported = useRef<FleetRowMetrics | null>(null);
useEffect(() => {
if (lastReported.current && shallowEqualMetrics(lastReported.current, metrics)) return;
lastReported.current = metrics;
onMetrics(instance.id, metrics);
}, [instance.id, metrics, onMetrics]);
const hostname = instance.baseUrl.replace(/^https?:\/\//, "");
return (
<tr
data-testid={`fleet-row-${instance.id}`}
style={{
borderTop: "1px solid var(--border)",
background: isActive ? COLOR.warningDim : undefined,
}}
>
<td className="py-2.5 px-4">
<div className="flex items-center gap-2 min-w-0">
<HealthDot
status={health === "ok" ? "ok" : health === "unreachable" ? "unreachable" : "checking"}
/>
<div className="min-w-0">
<div className="text-sm font-medium truncate" style={{ color: "var(--text-1)" }}>
{instance.name}
</div>
<div
className="text-xs font-mono truncate max-w-[16rem]"
style={{ color: "var(--text-4)" }}
title={mask(hostname)}
>
{mask(hostname)}
</div>
</div>
</div>
</td>
<td className="py-2.5 px-4 text-right font-mono text-xs" style={{ color: "var(--text-2)" }}>
{workspacesQ.isLoading ? "—" : formatCount(totalWorkspaces)}
</td>
<td className="py-2.5 px-4 text-right font-mono text-xs" style={{ color: "var(--text-2)" }}>
{isLoading ? "—" : formatCount(conclusionCount)}
</td>
<td className="py-2.5 px-4 text-right">
{isLoading ? (
<span className="text-xs font-mono" style={{ color: "var(--text-4)" }}>
</span>
) : (
<div className="flex items-center justify-end gap-1.5">
{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: health === "ok" ? COLOR.success : "var(--text-4)" }}
strokeWidth={2}
/>
)}
<span
className="text-xs font-medium font-mono"
style={{ color: isActive ? COLOR.warning : "var(--text-3)" }}
>
{isActive ? `${formatCount(queueActive)}/${formatCount(queuePending)}` : "idle"}
</span>
</div>
)}
</td>
<td
className="py-2.5 px-4 text-right text-xs font-mono"
style={{ color: "var(--text-4)" }}
title={lastSeen ? new Date(lastSeen).toLocaleString() : undefined}
>
{health === "unreachable" ? "unreachable" : formatRelative(lastSeen)}
</td>
</tr>
);
}

View File

@@ -1,43 +0,0 @@
export type FleetHealth = "ok" | "unreachable" | "loading";
export interface FleetRowMetrics {
workspaceCount: number;
conclusionCount: number;
queueActive: number;
queuePending: number;
lastSeen: number | null;
health: FleetHealth;
}
export interface FleetAggregates {
totalInstances: number;
totalWorkspaces: number;
totalConclusions: number;
totalQueueActive: number;
totalQueuePending: number;
healthyCount: number;
unreachableCount: number;
loadingCount: number;
}
export const DEFAULT_ROW_METRICS: FleetRowMetrics = {
workspaceCount: 0,
conclusionCount: 0,
queueActive: 0,
queuePending: 0,
lastSeen: null,
health: "loading",
};
export function computeFleetAggregates(rows: FleetRowMetrics[]): FleetAggregates {
return {
totalInstances: rows.length,
totalWorkspaces: rows.reduce((s, r) => s + r.workspaceCount, 0),
totalConclusions: rows.reduce((s, r) => s + r.conclusionCount, 0),
totalQueueActive: rows.reduce((s, r) => s + r.queueActive, 0),
totalQueuePending: rows.reduce((s, r) => s + r.queuePending, 0),
healthyCount: rows.filter((r) => r.health === "ok").length,
unreachableCount: rows.filter((r) => r.health === "unreachable").length,
loadingCount: rows.filter((r) => r.health === "loading").length,
};
}

View File

@@ -6,7 +6,6 @@ const SECTION_LABELS: Record<string, string> = {
peers: "Peers",
sessions: "Sessions",
conclusions: "Conclusions",
dreams: "Dreams",
webhooks: "Webhooks",
chat: "Chat",
};
@@ -15,11 +14,7 @@ const KNOWN_SECTIONS = new Set(Object.keys(SECTION_LABELS));
type Segment = { label: string; href: string | null; mono?: boolean };
function buildSegments(
pathname: string,
mask: (v: string) => string,
labels: Record<string, string>,
): Segment[] {
function buildSegments(pathname: string, mask: (v: string) => string): Segment[] {
if (!pathname.startsWith("/workspaces")) return [];
const rest = pathname.slice("/workspaces".length); // "" | "/wid" | "/wid/peers" | ...
@@ -50,20 +45,14 @@ function buildSegments(
const subId = parts[2];
if (!subId) return segments;
// A friendly label override (e.g. a peer's display_name) renders in place of
// the raw id and drops the mono styling reserved for ids.
const override = labels[subId];
const subLabel = mask(override ?? subId);
const subMono = override === undefined;
if (parts.length === 3) {
segments.push({ label: subLabel, href: null, mono: subMono });
segments.push({ label: mask(subId), href: null, mono: true });
return segments;
}
segments.push({
label: subLabel,
label: mask(subId),
href: `/workspaces/${wid}/${section}/${subId}`,
mono: subMono,
mono: true,
});
const subSection = parts[3];
@@ -74,10 +63,10 @@ function buildSegments(
return segments;
}
export function Breadcrumb({ labels = {} }: { labels?: Record<string, string> } = {}) {
export function Breadcrumb() {
const { state } = useRouter();
const { mask } = useDemo();
const segments = buildSegments(state.location.pathname, mask, labels);
const segments = buildSegments(state.location.pathname, mask);
if (segments.length <= 1) return null;

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,51 +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() {
// No setNow here — calling setNow on every cache event causes a render loop on
// CI (each Date.now() call crosses a ms boundary → new value → React re-renders
// Sidebar → cache events fire again → loop). setNow belongs only in the interval.
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(() => {
setNow(Date.now()); // refresh relative-time display ("X ago") every 30s
refresh();
}, 30_000);
return () => {
unsubscribe();
window.clearInterval(interval);
};
}, [queryClient]);
return formatLastUpdated(updatedAt, now);
}
export function Sidebar() {
const matchRoute = useMatchRoute();
const { instances, active, activate } = useInstances();
@@ -92,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);
@@ -168,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,18 +1,6 @@
import { useNavigate, useParams } from "@tanstack/react-router";
import { AnimatePresence, motion } from "framer-motion";
import {
Check,
Eye,
EyeOff,
FlaskConical,
MessageCircle,
Pencil,
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,
@@ -21,7 +9,6 @@ import {
usePeerRepresentation,
useSearchPeer,
useSetPeerCard,
useUpdatePeer,
} from "@/api/queries";
import { Breadcrumb } from "@/components/layout/Breadcrumb";
import { Badge } from "@/components/shared/Badge";
@@ -44,7 +31,6 @@ import {
import { useDemo } from "@/hooks/useDemo";
import { useMetadata } from "@/hooks/useMetadata";
import { COLOR } from "@/lib/constants";
import { DISPLAY_NAME_KEY, hasDisplayName, peerDisplayName } from "@/lib/peerDisplay";
export function PeerDetail() {
const { mask } = useDemo();
@@ -73,20 +59,6 @@ export function PeerDetail() {
const [cardDraft, setCardDraft] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const peerMeta = (peer as { metadata?: Record<string, unknown> } | undefined)?.metadata;
const displayName = peerDisplayName(peerMeta, peerId);
const showsDisplayName = hasDisplayName(peerMeta, peerId);
const updatePeer = useUpdatePeer(workspaceId, peerId);
const [nameDraft, setNameDraft] = useState<string | null>(null);
function saveDisplayName() {
const next = (nameDraft ?? "").trim();
const merged: Record<string, unknown> = { ...(peerMeta ?? {}) };
if (next) merged[DISPLAY_NAME_KEY] = next;
else delete merged[DISPLAY_NAME_KEY];
updatePeer.mutate({ metadata: merged }, { onSuccess: () => setNameDraft(null) });
}
const observeMe = (peer as { configuration?: { observe_me?: boolean } } | undefined)
?.configuration?.observe_me;
@@ -99,54 +71,14 @@ export function PeerDetail() {
return (
<div className="page-container page-container--xl">
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }}>
<Breadcrumb labels={{ [peerId]: displayName }} />
<Breadcrumb />
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2 mb-1">
<User className="w-5 h-5" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
{nameDraft === null ? (
<>
<PageTitle className={showsDisplayName ? "break-all" : "font-mono break-all"}>
{mask(displayName)}
</PageTitle>
<button
type="button"
onClick={() => setNameDraft(showsDisplayName ? displayName : "")}
className="shrink-0 p-1 rounded-md transition-colors hover:bg-[color:var(--surface)]"
style={{ color: "var(--text-4)" }}
title="Edit display name"
>
<Pencil className="w-3.5 h-3.5" strokeWidth={1.5} />
</button>
</>
) : (
<div className="flex items-center gap-1.5">
<Input
value={nameDraft}
onChange={(e) => setNameDraft(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter") saveDisplayName();
if (e.key === "Escape") setNameDraft(null);
}}
placeholder="Display name"
autoFocus
className="w-56"
/>
<Button
variant="surface"
onClick={saveDisplayName}
disabled={updatePeer.isPending}
title="Save display name"
>
<Check className="w-3.5 h-3.5" strokeWidth={2} />
</Button>
<Button variant="surface" onClick={() => setNameDraft(null)} title="Cancel">
<X className="w-3.5 h-3.5" strokeWidth={2} />
</Button>
</div>
)}
{nameDraft === null && observeMe !== undefined && (
<PageTitle className="font-mono break-all">{mask(peerId)}</PageTitle>
{observeMe !== undefined && (
<span
className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full font-mono"
style={{
@@ -164,40 +96,21 @@ export function PeerDetail() {
</span>
)}
</div>
{showsDisplayName && nameDraft === null && (
<MonoCaption className="break-all">{mask(peerId)}</MonoCaption>
)}
<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 }}
>
{session.metadata?.name ? `${session.metadata.name}` : 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 (

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