129 Commits

Author SHA1 Message Date
148766312e fix: use template literal for metadata.name -> ReactNode compat 2026-07-07 20:52:33 -04:00
97ec6ccbae fix: cast metadata.name to string for ReactNode compat 2026-07-07 20:49:54 -04:00
9d32d32d38 fix: TS errors in API queries and session list
- page_size -> size to match API schema
- reverse -> filters.reverse to match SessionGet body type
- session.name -> session.metadata?.name (Session type has no name field)
2026-07-07 20:42:45 -04:00
a8f0828727 "fix-sort-and-display-name" 2026-07-07 20:42:45 -04:00
github-actions[bot]
b250492925 chore(release): 0.16.0 [skip ci]
# [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](1c28cae3f2))
* **dashboard:** guard setMetricsById against same-value calls to end loop ([3b88a41](3b88a41afd))
* **dashboard:** remove lastSeen from metrics useMemo deps to break render loop ([f79cdaf](f79cdafba7))
* **dashboard:** use primitive deps in onMetrics effect to break render loop ([9cc8637](9cc8637dc7))
* **test:** remove unused within import from fleet.test.tsx ([36fb6ee](36fb6ee519))
* **web:** break metricsEqual lastSeen dep-loop; add staleTime to test ([173f096](173f096e33))
* **web:** reset serverFilter when the selected instance is removed ([699ec38](699ec38480))
* **web:** stop Sidebar re-render loop from cache-event subscriber ([7e529c8](7e529c8c44))

### Features

* **web:** merge Fleet into a server-filterable Dashboard ([e66f927](e66f927f89)), closes [#54](https://github.com/offendingcommit/openconcho/issues/54)
* **web:** redirect /fleet to Dashboard; update fleet tests ([da126b2](da126b2e74))
2026-06-10 12:20:29 +00:00
Offending Commit
01371db6ae Merge pull request #61 from offendingcommit/feat/dashboard-fleet-merge 2026-06-10 07:19:26 -05:00
Offending Commit
7e529c8c44 fix(web): stop Sidebar re-render loop from cache-event subscriber
setNow(Date.now()) was called on every query-cache event, including
events dispatched synchronously during ServerWorkspaceRows' render.
On CI, consecutive Date.now() calls cross millisecond boundaries so
each call returns a new value — React always re-renders Sidebar, which
re-renders the layout, which re-renders ServerWorkspaceRows, which
fires more cache events. After ~25 cycles React throws "Maximum update
depth exceeded."

Fix: remove setNow from the cache subscriber. setNow now fires only
from the 30s interval timer, where it correctly refreshes the "X ago"
display text without triggering a render loop.

Also adds a deterministic CI-repro test that mocks Date.now to return
incrementing values and asserts the filter-change path completes without
looping.
2026-06-03 18:11:30 -05:00
Offending Commit
3b88a41afd fix(dashboard): guard setMetricsById against same-value calls to end loop
Even with primitive useEffect deps, TanStack Query or React concurrent
rendering can cause onMetrics to fire with identical values mid-render-cycle.
Add a ref-based equality check in Dashboard.onMetrics: if all five metric
values are unchanged, skip setMetricsById entirely — no state update, no
Dashboard re-render, loop terminates.

Also fixes vi.fn<TFunction>() typing in server-workspace-rows.test.tsx to
satisfy tsc (Vitest 4 single-type-arg signature).
2026-06-03 17:48:22 -05:00
Offending Commit
9cc8637dc7 fix(dashboard): use primitive deps in onMetrics effect to break render loop
Using a metrics object as a useEffect dep causes the loop:
  onMetrics → setMetricsById → Dashboard re-renders → ServerWorkspaceRows
  re-renders → useQueries runs → TanStack Query cache subscriber fires
  (Sidebar's setNow) → query result objects are new references → metrics
  useMemo returns new object → effect dep changed → onMetrics again → ∞

Fix: depend on the five primitive values (workspaceCount, conclusionCount,
queueActive, queuePending, health) directly. React compares primitives by
value, so the effect only fires when actual data changes, not on reference
churn from re-renders.

Also adds server-workspace-rows.test.tsx with three focused unit tests:
correct health:ok report, stability after load (no re-fire), and health
transition coverage.
2026-06-03 17:48:21 -05:00
Offending Commit
e59bfd908a Merge branch 'main' into feat/dashboard-fleet-merge 2026-06-03 17:28:52 -05:00
Offending Commit
3c99af7143 refactor(dashboard): drop unused lastSeen local var after metrics fix 2026-06-03 17:27:55 -05:00
Offending Commit
f79cdafba7 fix(dashboard): remove lastSeen from metrics useMemo deps to break render loop
lastSeen tracked workspacesQ.dataUpdatedAt which updates on every background
refetch, creating a new metrics object reference each cycle. The useEffect
dep on metrics then fired unconditionally, calling onMetrics → setMetricsById
→ Dashboard re-render → another refetch → infinite loop.

computeFleetAggregates never reads lastSeen, so reporting it upward was
pointless. Hardcode null and drop it from the dep array entirely.
2026-06-03 17:27:42 -05:00
Offending Commit
36fb6ee519 fix(test): remove unused within import from fleet.test.tsx 2026-06-03 17:10:32 -05:00
Offending Commit
da126b2e74 feat(web): redirect /fleet to Dashboard; update fleet tests
/fleet now redirects to / so bookmarks and muscle-memory links land on the
unified Dashboard rather than a 404-style dead route. Fleet tests updated
to assert the redirect and the per-instance rows the Dashboard renders.
2026-06-03 17:10:02 -05:00
Offending Commit
699ec38480 fix(web): reset serverFilter when the selected instance is removed
If the user filters to a specific server and that instance is then deleted
(e.g. from Settings), shownInstances becomes [] — empty table, no message.
A useEffect resets the filter to ALL_SERVERS whenever the selected ID
disappears from the instances list.
2026-06-03 17:03:10 -05:00
Offending Commit
173f096e33 fix(web): break metricsEqual lastSeen dep-loop; add staleTime to test
Remove lastSeen from metricsEqual in ServerWorkspaceRows: computeFleetAggregates
never reads lastSeen, so comparing it in metricsEqual causes background TanStack
Query refetches (dataUpdatedAt changes) to trigger spurious onMetrics callbacks,
cascading into a Dashboard → ServerWorkspaceRows render loop that hits React's
100-render limit in CI.

Add staleTime: Infinity to the test QueryClient to prevent background refetches
from interfering with test assertions.
2026-06-03 17:01:19 -05:00
Offending Commit
1c28cae3f2 fix(ci): replace userEvent.selectOptions with fireEvent.change; bump setup-node to v6
userEvent.selectOptions hangs in jsdom when firing pointer + change event
sequences — fireEvent.change fires the React-controlled onChange directly
and is deterministic. Removes userEvent import (no longer used).

Bump actions/setup-node from v4 to v6 to clear the Node.js 20 deprecation
warning on GitHub Actions runners.
2026-06-03 16:41:40 -05:00
github-actions[bot]
2a6ddb9f78 chore(release): 0.15.0 [skip ci]
# [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](d5a65d73b5))
* **helm:** pdb mutual exclusion, ingress null rules guard, hpa nil utilization guard ([b4939bd](b4939bd57f))
* **helm:** pin busybox:1.36, add -T 10 timeout, use --spider, add activeDeadlineSeconds ([8fac5d0](8fac5d060f))
* **helm:** use http://json-schema.org/draft-07/schema# for Helm compatibility ([8d41455](8d41455e39))

### Features

* **helm:** add _helpers.tpl with name, label, and imageTag partials ([0268275](02682750ab))
* **helm:** add Deployment template with read-only FS, tmpfs, probes ([514e1d4](514e1d46c0))
* **helm:** add NOTES.txt with access instructions and NetworkPolicy/Ingress warning ([ce211df](ce211df48c))
* **helm:** add optional HPA, PDB, and NetworkPolicy templates ([b0b648b](b0b648bdcf))
* **helm:** add optional Ingress template ([9aa106c](9aa106cede))
* **helm:** add Service and ServiceAccount templates ([ee916ea](ee916eabc4))
* **helm:** add test-healthz and test-spa-root helm test jobs ([ee4630e](ee4630e79c))
* **helm:** chart scaffold — Chart.yaml, values, schema ([4112270](411227046a))
2026-06-03 21:37:08 +00:00
Offending Commit
11eec585fa Merge pull request #62 from offendingcommit/worktree-feat+helm-chart
feat(helm): add Helm chart for self-hosted openconcho web UI
2026-06-03 16:35:58 -05:00
Offending Commit
d81e7f17ac docs(helm): annotate values.yaml, add chart README, ArgoCD example, update root README and AGENTS.md 2026-06-03 16:32:28 -05:00
Offending Commit
4ebd4cc211 ci(helm): publish chart to ghcr oci on release tags 2026-06-03 11:29:04 -05:00
Offending Commit
8fac5d060f fix(helm): pin busybox:1.36, add -T 10 timeout, use --spider, add activeDeadlineSeconds 2026-06-03 11:22:19 -05:00
Offending Commit
ee4630e79c feat(helm): add test-healthz and test-spa-root helm test jobs 2026-06-03 11:16:27 -05:00
Offending Commit
ce211df48c feat(helm): add NOTES.txt with access instructions and NetworkPolicy/Ingress warning 2026-06-03 11:16:23 -05:00
Offending Commit
b4939bd57f fix(helm): pdb mutual exclusion, ingress null rules guard, hpa nil utilization guard 2026-06-03 11:14:47 -05:00
Offending Commit
b0b648bdcf feat(helm): add optional HPA, PDB, and NetworkPolicy templates 2026-06-03 11:07:11 -05:00
Offending Commit
9aa106cede feat(helm): add optional Ingress template 2026-06-03 11:07:03 -05:00
Offending Commit
ee916eabc4 feat(helm): add Service and ServiceAccount templates 2026-06-03 11:06:53 -05:00
Offending Commit
d5a65d73b5 fix(helm): guard tmpfs blocks when empty, cap volume names at 63 chars 2026-06-03 11:04:15 -05:00
Offending Commit
514e1d46c0 feat(helm): add Deployment template with read-only FS, tmpfs, probes 2026-06-03 11:00:54 -05:00
Offending Commit
02682750ab feat(helm): add _helpers.tpl with name, label, and imageTag partials 2026-06-03 10:59:04 -05:00
Offending Commit
8d41455e39 fix(helm): use http://json-schema.org/draft-07/schema# for Helm compatibility 2026-06-03 10:56:37 -05:00
Offending Commit
411227046a feat(helm): chart scaffold — Chart.yaml, values, schema 2026-06-03 10:51:38 -05:00
Offending Commit
e66f927f89 feat(web): merge Fleet into a server-filterable Dashboard
Implements Phase 1 of the UI navigation rework (docs/superpowers/specs/
2026-06-02-ui-navigation-rework.md): the Dashboard now lists every workspace
across every configured server as <workspace> (<server>), filterable by server,
with cross-server aggregate cards (reusing computeFleetAggregates). Opening a
workspace activates its server then drills into the existing detail route.
Per-server fan-out lives in a ServerWorkspaceRows child (rules-of-hooks safe,
mirrors FleetRow). Fleet removed from the sidebar nav; the /fleet route is left
intact for now (full removal deferred to avoid churning fleet.test.tsx while #54
is open).
2026-06-02 16:41:51 -05:00
Offending Commit
3677575f65 Merge pull request #57 from offendingcommit/chore/deps-consolidated
chore(deps): consolidate Dependabot PRs #34-53 + widen grouping
2026-06-02 15:41:21 -05:00
Offending Commit
765c618a76 ci(dependabot): widen grouping to cut PR volume
Collapse minor+patch bumps into one PR per ecosystem (npm/cargo) with majors in
their own grouped PR; add a github-actions group so action bumps batch instead of
one PR each. Replaces the many narrow per-family groups.
2026-06-02 15:38:23 -05:00
Offending Commit
85b56ca0f8 chore(deps): consolidate dependency bumps (supersedes #34-53)
npm (lockfile + catalog): tanstack, @tauri-apps/*, turbo 2.9.x, biome, vite,
@vitejs/plugin-react, vitest+coverage-v8 4.1.8 (lockstep), @playwright/test,
and jsdom 26->29 (major). cargo: tauri 2.11.2, tauri-plugin-http 2.5.9,
tauri-build 2.6.2, serde_json. github-actions: docker setup-qemu v4,
setup-buildx v4, build-push v7. Validated: make ci-web, cargo-check, actionlint.
2026-06-02 15:38:22 -05:00
github-actions[bot]
c474767ba1 chore(release): 0.14.0 [skip ci]
# [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](66b299a28e))
* **docker:** drop dead HONCHO_UPSTREAM and same-origin default ([a2854ab](a2854ab8ea))
* **web:** enforce upstream allowlist in vite dev proxy ([b4fac95](b4fac95f37))
* **web:** raise connection-test timeout for cold upstreams ([409d7d8](409d7d8be7))
* **web:** strip content-encoding from vite dev proxy responses ([6b602c0](6b602c05bb))

### Features

* **docker:** header-driven /api reverse proxy in nginx ([753c978](753c978f56))
* **docker:** render SSRF allowlist map from env ([0af1ad9](0af1ad923c))
* **docker:** split compose into dev-forward build and prod pull ([c9bd2db](c9bd2db07d))
* **web:** add dispatchFor transport helper for same-origin proxy ([9945e4c](9945e4cf14))
* **web:** dev /api proxy middleware mirroring nginx ([ab8a1ba](ab8a1ba866))
* **web:** route checkConnection and discovery through the proxy ([9893230](9893230cde))
* **web:** route web build through same-origin /api proxy ([0935099](0935099bc2))
2026-06-02 20:20:40 +00:00
Offending Commit
5a2543592a Merge pull request #54 from offendingcommit/feat/web-api-proxy
feat(web): eliminate browser CORS via header-driven /api proxy
2026-06-02 15:19:32 -05:00
Offending Commit
4349864234 Merge pull request #56 from offendingcommit/chore/env-specific-hardening
chore: harden against environment-specific leaks
2026-06-02 15:19:05 -05:00
Offending Commit
1aa1c0456f chore(hooks): flag tailnet hostnames and CGNAT IPs in the secret scan
Extend the pre-commit secret-scan to catch environment-specific values
(*.ts.net MagicDNS names and 100.64.0.0/10 tailnet IPs) so live infra can't be
committed into code, docs, or examples. Verified: detects leaks, no false
positive on 192.0.2.x or non-CGNAT 100.x, and the script does not self-trip.
2026-06-02 15:12:33 -05:00
Offending Commit
239eb3327a test(web): use a documentation IP instead of a tailnet-range fixture
Swap the 100.x CGNAT example for 192.0.2.10 (RFC 5737 TEST-NET-1) in the
token-transport guard tests — keeps the non-loopback-HTTP assertion, drops an
environment-specific address.
2026-06-02 15:11:17 -05:00
Offending Commit
08b77839b1 docs: scrub environment-specific endpoint from proxy spec
Replace the real tailnet hostname and IP with honcho.example.net / a generic
tailnet reference — specs and PRs should carry examples, not live endpoints.
2026-06-02 15:05:25 -05:00
Offending Commit
3ea6a73833 test(web): cover instance store CRUD and legacy migration
Closes the multi-instance coverage gap: add (first becomes active, later adds
don't steal focus, insertion order), switch (incl. unknown-id no-op), delete
(active->first-remaining fallback, non-active unchanged, last clears active),
update (patch + unknown-id no-op), active config, and legacy-key migration.
2026-06-02 14:36:23 -05:00
Offending Commit
96dff8900e refactor(web): use top-level z.url() over deprecated z.string().url()
Zod v4 deprecates the chained .url() in favor of the top-level format validator.
2026-06-02 14:31:40 -05:00
Offending Commit
409d7d8be7 fix(web): raise connection-test timeout for cold upstreams
A cold/idle self-hosted Honcho can take ~5s on its first request (DB pool, tunnel
wake); the hardcoded 5s budget aborted just before the response and reported a
live instance as 'Connection timed out'. Extract CONNECTION_TIMEOUT_MS (15s),
make checkConnection's timeout injectable, and cover the budget behavior.
2026-06-02 14:18:19 -05:00
Offending Commit
4ccf8f2746 refactor(docker): use compose profiles instead of a prod override file
Collapse docker-compose.yml + docker-compose.prod.yml into one file with dev/prod
profiles sharing a YAML anchor: dev builds from source, prod pulls ghcr latest.
make up/prod select the profile; down passes both so it stops either. Drops the
separate prod file and the !reset hack.
2026-06-02 14:02:36 -05:00
Offending Commit
4e4843ce1a refactor(make): rename compose targets to up/prod/down/clean
Concise verbs: make up (dev build+run), make prod (pull published image),
make down (stop regardless of mode), make clean (down + drop local image).
Updates all docs and compose-file comment headers to match.
2026-06-02 13:59:04 -05:00
Offending Commit
e1285c73ad docs: document dev/prod compose modes and make targets
README, docs/docker.md, and AGENTS.md now cover make compose-up (dev-forward,
builds from source) vs make compose-up-prod (pulls ghcr latest) and compose-down.
2026-06-02 13:51:23 -05:00
Offending Commit
c9bd2db07d feat(docker): split compose into dev-forward build and prod pull
docker-compose.yml now builds from source (dev-forward: run your local changes);
docker-compose.prod.yml overrides it to pull ghcr latest (build reset via !reset).
Adds make compose-up / compose-up-prod / compose-down. Env, ports, and extra_hosts
stay defined once in the base file; the prod override only swaps build -> image.
2026-06-02 13:50:27 -05:00
Offending Commit
56b3d18f40 test(docker): add hermetic /api proxy smoke test
make smoke-docker builds the image, stands up a stub upstream + the container
on a shared network, and asserts forward+prefix-strip, upstream-header cleared,
421 on missing header, and 403 + reject sentinel on allowlist miss. Self-
contained (no tailnet), idempotent, local-only (Docker) like cargo-check.
2026-06-02 13:43:07 -05:00
Offending Commit
66b299a28e fix(docker): derive nginx resolver from container DNS
Hardcoded resolver 127.0.0.11 only exists on user-defined networks, so a plain
docker run on the default bridge 502'd (DNS connection refused). Render the
resolver from /etc/resolv.conf at start so per-request proxy_pass resolves on
both the default bridge (host DNS) and compose networks (embedded DNS).
2026-06-02 13:33:50 -05:00
Offending Commit
6b602c05bb fix(web): strip content-encoding from vite dev proxy responses
undici fetch auto-decompresses the body, so re-sending the upstream
content-encoding/length would cause ERR_CONTENT_DECODING_FAILED if Honcho
gzips. Drop those and hop-by-hop headers when relaying. nginx is unaffected.
2026-06-02 13:28:32 -05:00
Offending Commit
b4fac95f37 fix(web): enforce upstream allowlist in vite dev proxy
Mirrors the nginx allowlist (spec section D) so make dev-web matches prod: when
OPENCONCHO_UPSTREAM_ALLOWLIST is set, non-matching upstreams get 403 +
X-Honcho-Proxy-Reject; unset stays open.
2026-06-02 13:25:12 -05:00
Offending Commit
a2854ab8ea fix(docker): drop dead HONCHO_UPSTREAM and same-origin default
The published image defaulted OPENCONCHO_DEFAULT_HONCHO_URL=same-origin, but the
sentinel was removed — a bare run seeded an invalid "same-origin" base. Default
to empty (configure in Settings); HONCHO_UPSTREAM is unused by the new nginx.
2026-06-02 13:25:11 -05:00
Offending Commit
7357072b9e docs(docker): drop stale same-origin sentinel from entrypoint comment 2026-06-02 13:17:25 -05:00
Offending Commit
9a35be7b15 docs: document the /api proxy contract and env vars
Retire HONCHO_UPSTREAM and the same-origin sentinel; document the per-request
X-Honcho-Upstream header model, OPENCONCHO_DEFAULT_HONCHO_URL seeding, and the
optional OPENCONCHO_UPSTREAM_ALLOWLIST SSRF guard across compose, README,
AGENTS.md, and docs/docker.md.
2026-06-02 13:16:30 -05:00
Offending Commit
ab8a1ba866 feat(web): dev /api proxy middleware mirroring nginx 2026-06-02 13:13:48 -05:00
Offending Commit
0af1ad923c feat(docker): render SSRF allowlist map from env 2026-06-02 13:12:29 -05:00
Offending Commit
753c978f56 feat(docker): header-driven /api reverse proxy in nginx 2026-06-02 11:57:55 -05:00
Offending Commit
b29fa240a6 refactor(web): drop same-origin sentinel from runtime config 2026-06-02 11:53:42 -05:00
Offending Commit
9893230cde feat(web): route checkConnection and discovery through the proxy 2026-06-02 11:52:17 -05:00
Offending Commit
90823d12a6 docs: amend proxy plan with absolute-base fix and task 4 test mocking
Records the post-execution design correction (absolute same-origin base) and
rewrites the checkConnection test to mock @/lib/http rather than globalThis.fetch.
2026-06-02 11:49:34 -05:00
Offending Commit
0935099bc2 feat(web): route web build through same-origin /api proxy
client.current and createScopedClient resolve transport via dispatchFor:
web -> absolute origin + /api base with an X-Honcho-Upstream header; Tauri ->
absolute instance URL + reqwest. Absolute base (not bare "/api") so openapi-fetch
can construct a Request under node/undici and in the browser alike. Fleet fan-out
is unchanged; fleet.test.tsx now asserts the proxy contract.
2026-06-02 11:48:13 -05:00
Offending Commit
9945e4cf14 feat(web): add dispatchFor transport helper for same-origin proxy 2026-06-02 11:38:58 -05:00
Offending Commit
d4452abcea refactor(web): extract isTauri into a leaf platform module 2026-06-02 11:36:21 -05:00
Offending Commit
3bb1150773 docs: add header-driven /api proxy implementation plan
Ten TDD tasks: dispatchFor helper, client/checkConnection/discovery routing,
runtime-config simplification, nginx header proxy, allowlist map, vite dev
parity, env + docs. Gated by make ci-web after each task.
2026-06-02 11:33:26 -05:00
Offending Commit
ff9b298116 docs: add header-driven /api proxy design spec
Same-origin reverse proxy removes browser CORS for the web build; upstream
named per-request via X-Honcho-Upstream header (frontend stays source of
truth). Tauri keeps reqwest-absolute. Optional SSRF allowlist, open by
default. Preserves existing Fleet aggregation; new aggregation deferred.
2026-06-02 11:26:50 -05:00
github-actions[bot]
239d70f2b6 chore(release): 0.13.1 [skip ci]
## [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](fde483657f))
2026-05-29 16:49:55 +00:00
Offending Commit
9ab9d52cae Merge pull request #49 from offendingcommit/fix/docker-compose-standalone
fix(docker): make docker-compose runnable standalone
2026-05-29 11:48:51 -05:00
Offending Commit
fde483657f fix(docker): make docker-compose runnable standalone
The service declared depends_on the Honcho 'api' service, which doesn't
exist in this standalone compose — 'docker compose up' failed with
'depends on undefined service api'. Drop the dependency, default
HONCHO_UPSTREAM to the host's Honcho (host.docker.internal:8000,
overridable), and add an extra_hosts mapping so the default also
resolves on Linux. Combined-stack instructions moved to comments.
2026-05-29 11:47:19 -05:00
Offending Commit
af83be0a32 docs(claude): make CLAUDE.md a shim that imports AGENTS.md
CLAUDE.md was a full, drifting duplicate of AGENTS.md. Replace it with a
one-line @AGENTS.md import so AGENTS.md is the single source of truth for
agent context. Removed content was a stale subset (old openconcho:config
key, older rules description) — AGENTS.md already has the current version.

Type docs → no version bump.
2026-05-29 11:39:38 -05:00
Offending Commit
8f8a64c984 Merge pull request #48 from offendingcommit/docs/refresh-agents-readme
docs: refresh AGENTS.md + README for v0.13.0 accuracy
2026-05-29 11:38:25 -05:00
Offending Commit
ad2d131ae4 docs: refresh AGENTS.md + README for v0.13.0 accuracy
- Correct localStorage key (openconcho:instances, not :config) in both
- AGENTS.md: note local husky pre-commit/pre-push gates
- README: fix prerequisites (Node >=22, pnpm 10)
- README: add shipped features (fleet, seed kits, multi-instance, dream
  viewer, dialectic playground, peer display names, demo mode)
- README: add .rpm to downloads + a Docker/Compose quick-start section
2026-05-29 11:37:03 -05:00
Offending Commit
e3190c0bd7 ci(docker): simplify publish to match working repo pattern
Revert the over-engineered linking attempts (provenance: false,
index annotations, dispatch tag input, Dockerfile source LABEL). Our
working repos (e.g. offendingcommit/infra) link GHCR packages with the
plain metadata-action labels + GITHUB_TOKEN push — none of the extras
helped or were needed. Match that.

Package<->repo linking is handled by the one-time Connect Repository
step in package settings.

Type ci → no version bump.
2026-05-29 11:27:58 -05:00
Offending Commit
3a22f8e9c8 Merge pull request #46 from offendingcommit/build/dockerfile-source-label
build(docker): bake image source label for GHCR repo linking
2026-05-29 11:08:04 -05:00
Offending Commit
e5e930d381 build(docker): bake org.opencontainers.image.source into the image
The canonical, build-tool-independent way GHCR links a container package
to its repo is a Dockerfile LABEL baked into the image config — not
buildx/metadata-action annotations. Add it so freshly-created packages
auto-connect.

Note: GHCR evaluates the source at package CREATION, so this links new
packages; an already-orphaned package needs a one-time manual connect
(or delete + re-publish) regardless.

Type build → no version bump.
2026-05-29 11:05:30 -05:00
Offending Commit
7354879596 Merge pull request #44 from offendingcommit/ci/ghcr-repo-link
ci(docker): link GHCR package to repo via index annotation
2026-05-29 10:43:35 -05:00
Offending Commit
5bd2e8a1f0 Merge branch 'main' into ci/ghcr-repo-link 2026-05-29 10:43:25 -05:00
Offending Commit
526c45d9ce ci(docker): annotate image index so GHCR links the package to the repo
The publish step passed labels but not annotations, so
org.opencontainers.image.source landed only on per-platform configs, not
the multi-arch index. GHCR reads the source from the index annotation to
auto-link a package to its repo, so the package stayed orphaned.

- Set DOCKER_METADATA_ANNOTATIONS_LEVELS=index + pass annotations to the
  build, putting source/url/revision on the index.
- provenance: false keeps the pushed artifact a clean multi-arch index.
- workflow_dispatch now takes a 'tag' input to (re)publish a specific
  version; tags derived from the ref so dispatch and release both work.

Type ci → no version bump; republish v0.13.0 via dispatch.
2026-05-29 10:42:58 -05:00
Offending Commit
ec1a1b3665 Merge pull request #33 from offendingcommit/dependabot/github_actions/actions/checkout-6
chore(ci)(deps): bump actions/checkout from 4 to 6
2026-05-29 10:31:57 -05:00
github-actions[bot]
801555222e chore(release): 0.13.0 [skip ci]
# [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](282ba1b76c))
* **web:** add Fleet dashboard view for cross-instance observability ([12712bb](12712bb0b0))
* **web:** configurable peer display name ([3de6832](3de6832a5d))
2026-05-28 22:10:34 +00:00
Offending Commit
04104d1f92 Merge pull request #43 from offendingcommit/feat/docker-compose-support
feat(docker): full self-hosted Compose support
2026-05-28 17:09:23 -05:00
Offending Commit
ab69b0045f Merge pull request #42 from offendingcommit/feat/fleet-dashboard
feat(web): add Fleet dashboard for cross-instance observability
2026-05-28 17:08:35 -05:00
Offending Commit
472b1405a6 Merge pull request #41 from offendingcommit/feat/peer-display-name
feat(web): configurable peer display name
2026-05-28 17:07:50 -05:00
Offending Commit
282ba1b76c feat(docker): full self-hosted Compose support
Make the web image drop-in for a Honcho docker-compose stack:
- nginx reverse-proxies /v3 and /health to $HONCHO_UPSTREAM (variable +
  Docker resolver so it starts even before the upstream resolves), giving
  the SPA a same-origin path to Honcho with no browser CORS.
- Runtime config: an entrypoint writes config.js from
  OPENCONCHO_DEFAULT_HONCHO_URL, so one prebuilt image targets any backend
  ("same-origin" | absolute URL | empty). SPA seeds a first-run default
  instance from it (additive; no-op in dev/desktop).
- docker-compose.yml example service + GHCR multi-arch publish workflow on
  release.
- nginx.conf -> envsubst template; docs rewritten.

Closes #21. Closes #31.
2026-05-28 16:06:03 -05:00
Agents
37eb9bdf59 test(web): add E2E for Fleet route nav + per-instance row render
Two focused Playwright tests, mirroring the existing sidebar.spec
pattern (no backend dependency — instances point at unreachable ports
and we only assert on rendered names + row count, not health):

- Fleet link in the sidebar navigates to /fleet
- /fleet renders one row per configured instance under the table role

Also adds a Fleet link assertion to the existing sidebar.spec so the
nav entry is covered on the dashboard route alongside the other top
links. Both new tests run under the existing `pnpm test:e2e` (not
gated in CI by design — matches the current Compare/Dashboard E2E
posture).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:57:17 -05:00
Agents
e90d20893d docs(fleet): add Fleet dashboard screenshots
Adds dark, light, and mixed-health screenshots used in the PR description.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:57:17 -05:00
Agents
12712bb0b0 feat(web): add Fleet dashboard view for cross-instance observability
Adds a new /fleet route that shows a fleet-wide overview of all
configured Honcho instances. Each row renders per-instance metrics
(workspace count, total conclusions, queue activity, last seen, health)
by fanning out scoped fetches via createScopedClient, and aggregates
into top-level metric cards.

Reuses the Phase 2 scoped-client pattern. Extends compareQueries.ts
with useScopedQueueStatus and useScopedConclusionsCount, plus option
builders so useQueries can fan out per-workspace requests inside
FleetRow without duplicating query logic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:57:17 -05:00
Offending Commit
3de6832a5d feat(web): configurable peer display name
Human peers surface as raw ids (WhatsApp `…-lid`, UUIDs). Read an
optional `display_name` from peer metadata and prefer it over the id in
the peer header and breadcrumb, with the raw id kept as a sub-caption.
An inline edit on the header writes `display_name` via useUpdatePeer
(merging existing metadata); blank clears it. Falls back to the id when
unset.

Closes #32.
2026-05-28 15:56:42 -05:00
github-actions[bot]
dd2edbae99 chore(release): 0.12.1 [skip ci]
## [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](52a7f09ce6))
2026-05-28 20:33:28 +00:00
Offending Commit
afcece29c5 Merge pull request #40 from offendingcommit/fix/release-trigger-on-published
fix(ci): trigger release build on release publish, not tag push
2026-05-28 15:32:20 -05:00
Offending Commit
52a7f09ce6 fix(ci): trigger release build on release publish, not tag push
semantic-release creates the tag via the GitHub Releases API
(@semantic-release/github). API-created tags don't emit the
refs/tags/* push event that 'on: push: tags' listens for, so the
artifact build never auto-ran — every release so far was built by
manual workflow_dispatch.

The PAT-published release does fire 'release: published', so trigger on
that. workflow_dispatch stays as a manual fallback.
2026-05-28 15:23:38 -05:00
github-actions[bot]
5fa894dcd7 chore(release): 0.12.0 [skip ci]
# [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](7355884051))
* **web:** add dialectic reasoning playground ([2340e65](2340e65028))
* **web:** add Dream Output Viewer ([f318555](f318555c82))
* **web:** add live dream progress panel with adaptive polling ([17f8a5a](17f8a5a7bf))
* **web:** add Peer Card Seed Kits ([650bfd7](650bfd7b28))
* **web:** show sidebar last updated status ([7cf94aa](7cf94aa03f))
2026-05-28 19:27:43 +00:00
Offending Commit
0d86a96560 Merge pull request #28 from offendingcommit/feat/dialectic-playground
feat(web): add dialectic reasoning playground
2026-05-28 14:26:39 -05:00
Offending Commit
f52961a112 Merge pull request #27 from offendingcommit/feat/dream-viewer
feat(web): add dream output viewer
2026-05-28 14:25:45 -05:00
Offending Commit
a17b5776be Merge pull request #20 from offendingcommit/feat/peer-card-seed-kits
feat(web): add peer card seed kits
2026-05-28 14:24:48 -05:00
Offending Commit
7d2f7209ea Merge pull request #29 from offendingcommit/feat/live-dream-progress
feat(web): add live dream progress panel with adaptive polling
2026-05-28 14:23:55 -05:00
Offending Commit
aa46d47fc8 Merge pull request #30 from offendingcommit/feat/desktop-auto-discover
feat(desktop): auto-discover Honcho instances on localhost
2026-05-28 14:23:49 -05:00
Offending Commit
972fd8323a Merge pull request #18 from offendingcommit/feat/sidebar-last-updated
feat(web): show sidebar last-updated status
2026-05-28 14:22:54 -05:00
Offending Commit
edc498de7b Merge pull request #26 from offendingcommit/chore/vitest-coverage
test(web): add v8 coverage with truthful baseline floor
2026-05-28 14:22:21 -05:00
github-actions[bot]
8e16dc4f78 chore(release): 0.11.0 [skip ci]
# [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](5cfbae6248))

### Features

* **docker:** add containerized web build with CORS guidance ([cfe07f1](cfe07f1900))
2026-05-28 19:21:59 +00:00
Offending Commit
94c423522a test(web): add v8 coverage with truthful baseline floor
Adds @vitest/coverage-v8 (catalog-pinned), a test:coverage script wired
through turbo (inputs + coverage/** output), and coverage thresholds set
to a floor measured on main (lines 18 / funcs 10 / branches 10 / stmts
17 against current 19.3/11.4/11.7/18.9). Ratchet upward as tests grow.

Adapts the coverage config from @BenSheridanEdwards's fork; the multi-job
CI fan-out from that branch was intentionally left out.

Co-authored-by: Ben Sheridan-Edwards <BenSheridanEdwards@users.noreply.github.com>
2026-05-28 14:21:42 -05:00
dependabot[bot]
793ed230db chore(ci)(deps): bump actions/checkout from 4 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-28 19:21:19 +00:00
Offending Commit
ac20ab6271 Merge pull request #22 from offendingcommit/fix/web-security-hardening
fix: harden token and URL transport security
2026-05-28 14:20:51 -05:00
Offending Commit
62a5085202 Merge pull request #24 from offendingcommit/chore/local-prepush-gates
chore(hooks): local pre-push gate + secret scan
2026-05-28 14:20:45 -05:00
Offending Commit
a26b028c5c Merge pull request #25 from offendingcommit/chore/dependabot
ci(deps): add grouped Dependabot config
2026-05-28 14:20:38 -05:00
Offending Commit
a33891982b Merge pull request #23 from offendingcommit/feat/docker-web
feat(docker): containerized web build + CORS guidance
2026-05-28 14:20:32 -05:00
github-actions[bot]
7a478ebbc8 chore(release): 0.10.0 [skip ci]
# [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](de8db4b7aa))
2026-05-28 19:20:20 +00:00
Offending Commit
5d71d1e7c5 Merge pull request #19 from offendingcommit/feat/scoped-client-foundation
feat(api): add scoped multi-instance query client
2026-05-28 14:19:22 -05:00
Ben Sheridan-Edwards
7355884051 feat(desktop): auto-discover Honcho instances on localhost (#1)
* feat(desktop): auto-discover Honcho instances on localhost

First-launch and on-demand discovery of running Honcho instances on
127.0.0.1:8000-8100. Desktop-only — the browser can't port-scan due to
CORS, so the scan runs in the Tauri Rust shell.

- New Rust command `discover_honcho_instances(start_port?, end_port?)`
  in src-tauri/src/discover.rs: parallel TCP probe of each port with
  150ms connect timeout + 250ms total request budget, looking for a
  /health endpoint returning `{"status":"ok"}`.
- New TS helper `discoverHonchoInstances()` in src/lib/discovery.ts:
  detects Tauri runtime; web build degrades to an empty result.
- `suggestNameForInstance()` fetches the first workspace and derives a
  friendly name from its id ("neo-personal" -> "Neo"). Used to seed the
  Name field for each discovered instance.
- `DiscoveredInstances` component: list of found instances with
  editable suggested names + per-row checkbox + "Add N instances"
  button. Rows are pre-checked by default and filter out already-
  configured baseUrls.
- Wired into `InstancesManager`:
  - First launch (no instances): renders above the connection-type
    chooser with autoRun=true. User sees results immediately.
  - With instances: adds a "Discover instances" button to the list
    view that opens the discovery flow on demand.
  - Both paths gate on `isTauri()`; the web build keeps its existing
    behaviour unchanged.

Adds tokio (with net + io-util + time + rt + macros) and futures to
the Tauri shell to get async TCP + join_all.

Tests:
- deriveNameFromWorkspaceId handles hyphenated, multi-segment, and
  no-hyphen ids

* test(desktop): add probe tests including live Hermes stack integration

- rejects_inverted_port_range
- ignores_ports_with_no_listener
- finds_live_hermes_stacks (#[ignore]d; opt-in with --ignored)

The ignored test exercises discover_honcho_instances against ports
8000-8010, expecting exactly [8001, 8002, 8003, 8004, 8005]. Verified
locally — the probe finds all 5 stacks in <10ms.

---------

Co-authored-by: Agents <agents@Jarviss-Mac-mini.local>
2026-05-28 14:09:45 -05:00
Agents
247ab846e9 docs(web): link panel to plastic-labs/honcho#724
Filed the upstream feature request asking /queue/status to surface
per-work-unit observer/observed, specialist phase, and token telemetry.
Panel now links to the concrete tracking issue instead of a pre-filled
new-issue form. Re-captured the showcase screenshots so the visual
state matches.
2026-05-28 14:08:57 -05:00
Agents
5f61769adc docs(web): add Dream Progress screenshots + capture script
Wires up the DEV-only /dream-progress showcase to a Playwright script
that captures idle/active/stalled variants for documentation. Also
regenerates routeTree.gen.ts to include the new queue + showcase routes
and folds in Biome formatter fixes.

Run `node scripts/screenshot-dream-progress.mjs` from packages/web with
the dev server up to refresh the images.
2026-05-28 14:08:57 -05:00
Agents
93c117b16b test(web): cover queue polling and stale-dream detection
- pickQueueRefetchInterval picks 2.5s when work is active, 10s otherwise
- useStaleQueueDetection only flags after 30 min without forward progress
- stall anchor resets when completed advances or all work finishes
2026-05-28 14:08:56 -05:00
Agents
17f8a5a7bf feat(web): add live dream progress panel with adaptive polling
Watching dreams was a 5-15min black box. Now a dedicated
/workspaces/:id/queue route polls /v3/workspaces/:id/queue/status
every 2.5s while work is in-flight (10s when idle) and surfaces a
warning when in-progress work hasn't advanced the completed count
for >30 minutes.

The Honcho API only exposes aggregate counts (no observer/observed
pair, specialist phase, or token telemetry per work-unit), so the
panel links out to an upstream issue for those.

- queries: adaptive refetchInterval via TanStack Query callback form
- hooks/useStaleQueueDetection: client-side stall detection
- routes/workspaces_.\$workspaceId_.queue: dedicated live view
- routes/_dev.dream-progress: DEV-only showcase for screenshots
- WorkspaceDetail: new "Queue & dreams" nav card
2026-05-28 14:08:56 -05:00
Ben Sheridan Edwards
2340e65028 feat(web): add dialectic reasoning playground
Lets users fire one query at every Honcho reasoning level (minimal / low /
medium / high / max) in parallel and compare answers + latency side-by-side
in a 5-column grid.

- New route: /workspaces/:ws/peers/:peer/playground
- New component: DialecticPlayground with per-column state, level checkboxes
  to skip expensive levels, and a single Run button that fans out via
  Promise.all
- Extends useChat() to accept an optional reasoning_level parameter (was
  hardcoded to "low")
- Adds a Playground button next to Chat on the peer detail page
- Tests cover the parallel fan-out (peak concurrency = 5, no serial
  awaiting) and per-column latency measurement
- Screenshot Playwright spec (idle / mid-flight / settled) drives the
  images in docs/screenshots/ — regen with PLAYWRIGHT_BASE_URL=... pnpm
  exec playwright test e2e/playground.screenshots.spec.ts
2026-05-28 14:08:08 -05:00
Agents
e549200f5d test(web): add playwright e2e for Dreams + docs screenshots
- packages/web/e2e/dreams.spec.ts: 4 tests covering sidebar entry,
  heading/breadcrumb resolution, mocked-API clustering into rows with
  count chips, and the premise-tree expansion path. Uses a function
  matcher for context.route so the trailing ?page=&page_size= query
  string doesn't break a glob.
- docs/screenshots/: dark + light mode list and expanded-detail PNGs
  captured at 2880x1800 (retina) via playwright against the dev
  server with a tiny mock Honcho.
- DreamList.tsx: add a literal space to CountChip's label fragment so
  the rendered text reads "3 explicit" instead of "3explicit" (gap-1
  styles the visual layout but doesn't add a text-node space). Better
  for screen readers, copy/paste, and assertion via toContainText.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 14:07:22 -05:00
Agents
f318555c82 feat(web): add Dream Output Viewer
Dreams are currently a black box — to benchmark Tier L models on dreamer
induction quality we need to see what each model produced. This adds a
workspace-scoped /dreams route that surfaces every dream run as a
group of conclusions split into explicit / deductive / inductive
columns, with click-to-expand premise trees.

What's here:

- `lib/dreams.ts`: pure helpers. `clusterConclusionsIntoDreams` groups a
  raw conclusions list into per-pair bursts using a configurable time
  window (default 60s). `expandPremiseTree` walks `reasoning_tree` first
  and falls back to a flat `premises` ID list, with cycle detection and
  a depth cap. Defines an `ExtendedConclusion` type that augments the
  generated schema with `conclusion_type`, `premises`, and
  `reasoning_tree` — Honcho's migration f1a2b3c4d5e6 added those
  columns but `schema.d.ts` doesn't expose them yet, so the UI degrades
  gracefully (unknown types fall into "explicit").

- `api/queries.ts`: new `useDreams` hook that paginates the conclusions
  list endpoint up to a configurable cap (default 400) and hands the
  raw list to the UI for clustering. New `dreams` query key.

- `components/dreams/`:
  - `DreamList.tsx` — route entry. Shows recent dreams as rows with
    timestamp, observer→observed pair, and per-type counts. Selecting
    a row expands an inline detail panel above the list.
  - `DreamDetail.tsx` — three-column view (explicit / deductive /
    inductive). Each inductive conclusion has a "Show premises" button
    that expands the reasoning chain.
  - `PremiseTree.tsx` — recursive premise renderer with type badges,
    cycle indicator, and graceful handling of premises that fall
    outside the loaded page.

- Routing: `routes/workspaces_.$workspaceId_.dreams.tsx` registers the
  page; routeTree.gen.ts regenerated.

- Sidebar: new "Dreams" entry between Conclusions and Webhooks
  (MoonStar icon to distinguish from the dark-mode Moon).

- Breadcrumb: "dreams" added to SECTION_LABELS so the trail resolves.

Tests (vitest, 14 new):
- clustering: empty input; same-pair burst within window; gap exceeds
  threshold → split; different pairs don't merge even with overlapping
  timestamps; custom gap window; newest-first ordering; counts default
  unknown types to explicit.
- premise tree: empty children for no premises; flat list → direct
  children; multi-level reasoning_tree recursion; missing premise
  flagged (not in loaded page); cycle detection halts recursion;
  maxDepth cap; reasoning_tree preferred over flat premises.

Verified manually in the preview: route mounts, sidebar/breadcrumb
resolve, error path renders cleanly, typecheck + lint + dream tests
all clean. The unrelated `app.test.tsx` failure is pre-existing on
main.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 14:06:46 -05:00
BenSheridanEdwards
e809409fa3 ci(deps): add grouped Dependabot config
Weekly npm (pnpm workspace), cargo (Tauri shell), and github-actions
updates, grouped by family (TanStack, Tauri, React, test stack, build
tooling, semantic-release, commitlint) to keep related bumps in one PR.

Co-authored-by: Ben Sheridan-Edwards <BenSheridanEdwards@users.noreply.github.com>
2026-05-28 14:02:08 -05:00
BenSheridanEdwards
86bb9d6be0 chore(hooks): pre-push gate, secret scan, pr:evidence drafter
Phase A of the quality-gate rollout — local + pre-push gates that stop
slop before it leaves the laptop. Preventative controls, per the
architect playbook.

- .husky/pre-commit — adds scripts/secret-scan.sh as the first check
  before the existing Biome format/lint. Blocks the commit when a
  likely secret is found in staged additions. Existing Biome behaviour
  is preserved.
- .husky/pre-push (new) — runs `pnpm check` (lint + typecheck + test)
  before the branch leaves the laptop. Mirrors the `check` job in
  .github/workflows/ci.yml so PR-blocking issues surface locally first.
  Bypassable for genuine emergencies with `git push --no-verify`.
- scripts/secret-scan.sh (new) — regex scan over staged additions for
  the common accidents: AWS keys, Anthropic/OpenAI/GitHub/Slack/Google/
  Stripe tokens, JWTs, PEM private key blocks, hardcoded password
  literals. Validated against synthetic leaks of each type. No external
  tool dependency — pure bash + grep, runs in <100ms typical.
- scripts/pr-evidence.sh (new) — drafts a PR_BODY.md from the diff vs
  origin/main: file lists (added/modified/deleted), commit summaries,
  and the QA checklist. Flags screenshots as REQUIRED when the diff
  touches packages/web/src/{components,routes} or packages/desktop.
  Wired as `pnpm pr:evidence` so it runs from anywhere in the
  workspace.
- .gitignore — adds PR_BODY.md so drafts don't get committed by
  accident.

Pre-commit stays fast (<2s typical). Heavy checks (typecheck, full
test run) live in pre-push.
2026-05-28 14:01:20 -05:00
Offending Commit
cfe07f1900 feat(docker): add containerized web build with CORS guidance
Multi-stage Dockerfile (node:22-alpine + pnpm build -> nginx-unprivileged
serve) producing a non-root image that runs under read-only fs + cap_drop
ALL. nginx.conf does SPA-fallback routing, /healthz, immutable asset cache,
and gzip, plus an opt-in same-origin reverse-proxy block.

docs/docker.md documents build/run and the two ways to handle browser CORS
on the web build (configure Honcho's CORSMiddleware, or the nginx proxy).

The Dockerfile and nginx.conf are adapted from @zmarakjanbangash's fork.

Co-authored-by: zmarakjanbangash <zmarakjanbangash@users.noreply.github.com>
2026-05-28 13:59:47 -05:00
batumilove
5cfbae6248 fix: harden token and URL handling
* fix: harden token and URL handling

* test: restore full web test suite

---------

Co-authored-by: batumilove <batumilove@users.noreply.github.com>
2026-05-28 13:54:48 -05:00
Agents
866dab39fe docs(seed-kits): add dark-mode screenshots alongside light
- Moves docs/seed-kits/*.png into docs/seed-kits/light/.
- Adds docs/seed-kits/dark/ with the same three panels rendered in
  the dark theme.
- Updates the capture script to loop over both themes by setting
  openconcho:theme in localStorage and the matching colorScheme on
  the browser context.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 13:53:51 -05:00
Agents
650bfd7b28 feat(web): add Peer Card Seed Kits
Adds a /seed-kits page where users can author named "kits" of card
lines (e.g. "Personal core", "Work context") and one-click apply them
to any peer across any registered instance. Removes the chore of
manually re-typing the same identity facts into multiple agents.

Built-in starter kits are read-only but forkable. User kits live in
localStorage. The apply flow shows a side-by-side merge preview that
dedupes by the line's prefix (text before the first ":"), so applying
a kit with "Name: Ben Sheridan-Edwards" to a peer whose card already
has "Name: Ben" replaces the line rather than duplicating it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 13:53:50 -05:00
BenSheridanEdwards
de8db4b7aa feat(api): add scoped multi-instance query client
Adds createScopedClient() — an openapi-fetch client bound to a specific
Instance (rather than the active one in localStorage) — and the
useScoped* TanStack Query hooks (workspaces, peers, peer representation,
peer card) with per-instance query-key isolation so caches never collide
across instances.

Foundation for multi-instance features (seed kits, desktop
auto-discover) that read from non-active instances.

Co-authored-by: Ben Sheridan-Edwards <BenSheridanEdwards@users.noreply.github.com>
2026-05-28 13:52:15 -05:00
HunterSThompson
7cf94aa03f feat(web): show sidebar last updated status 2026-05-28 13:46:48 -05:00
github-actions[bot]
2d7937e275 chore(release): 0.9.0 [skip ci]
# [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](e490d911fc))
* **web:** add shared Breadcrumb component for workspace pages ([c6afc80](c6afc80fda))
* **web:** add workspace contextual sub-nav to sidebar ([62cae68](62cae68d05))
2026-05-28 18:46:42 +00:00
Offending Commit
6960bf4ffe Merge pull request #17 from offendingcommit/test/wrap-harness-metadata-provider
test(web): wrap test harness in MetadataProvider to match production
2026-05-28 13:45:46 -05:00
Offending Commit
1094904a03 test(web): wrap test harness in MetadataProvider to match production
main.tsx wraps the app in DemoProvider > MetadataProvider, but the
app.test.tsx render helpers only wrapped with DemoProvider. Since
e490d91 added useMetadata() to Sidebar, the routed app threw
"useMetadataContext must be used within MetadataProvider" in tests;
the error boundary swallowed the tree and the first-load assertion
failed. CI has been red on main since that commit.

Mirror the production provider nesting in both render sites.
2026-05-28 13:43:31 -05:00
145 changed files with 10631 additions and 1595 deletions

12
.dockerignore Normal file
View File

@@ -0,0 +1,12 @@
**/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@v4
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: pnpm

84
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,84 @@
# 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@v4
- uses: actions/checkout@v6
- uses: ./.github/actions/setup
@@ -27,7 +27,7 @@ jobs:
issues: write
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
token: ${{ secrets.RELEASE_TOKEN }}

77
.github/workflows/docker-publish.yml vendored Normal file
View File

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

6
.gitignore vendored
View File

@@ -41,3 +41,9 @@ 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,4 +1,14 @@
#!/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

8
.husky/pre-push Executable file
View File

@@ -0,0 +1,8 @@
#!/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,6 +16,11 @@ 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` |
@@ -33,6 +38,7 @@ 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 |
@@ -63,7 +69,9 @@ Before pushing any change under `packages/desktop/**` or `packages/desktop/src-t
## Key Constraints
- **No hardcoded URLs** — all connection config lives in `localStorage` under `openconcho:config`
- **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.
- **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,3 +1,130 @@
# [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,62 +1 @@
# 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
@AGENTS.md

47
Dockerfile Normal file
View File

@@ -0,0 +1,47 @@
# 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,7 +4,8 @@
.PHONY: bootstrap dev dev-web dev-desktop \
build test test-e2e lint lint-fix typecheck check \
ci-web ci-desktop install help
ci-web ci-desktop smoke-docker \
up prod down clean install help
help:
@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS=":.*?## "}; {printf " \033[36m%-14s\033[0m %s\n", $$1, $$2}'
@@ -47,5 +48,20 @@ 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,13 +29,20 @@ 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 |
@@ -47,15 +54,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` |
| Linux | `openconcho_*_amd64.deb` / `openconcho_*_amd64.AppImage` / `OpenConcho-*.x86_64.rpm` |
| Windows | `OpenConcho_*_x64-setup.exe` / `OpenConcho_*_x64_en-US.msi` |
## Quick Start
### Prerequisites
- [Node.js](https://nodejs.org/) ≥ 20
- [pnpm](https://pnpm.io/) ≥ 9
- [Node.js](https://nodejs.org/) ≥ 22
- [pnpm](https://pnpm.io/) 10 (pinned via `packageManager`; `corepack enable` picks it up)
- A running [Honcho](https://github.com/plastic-labs/honcho) instance (local or remote)
### Web app
@@ -80,6 +87,61 @@ 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`)
@@ -131,7 +193,7 @@ pnpm --filter @openconcho/web generate:api
## Privacy
- Base URL and token stored in `localStorage` under `openconcho:config`
- Connection details (base URL + token, one or more instances) stored in `localStorage` under `openconcho:instances`
- Theme preference stored in `localStorage` under `openconcho:theme`
- No telemetry, no analytics, no external requests beyond your configured Honcho instance

View File

@@ -0,0 +1,16 @@
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

229
charts/openconcho/README.md Normal file
View File

@@ -0,0 +1,229 @@
# 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

@@ -0,0 +1,40 @@
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

@@ -0,0 +1,46 @@
{{- 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

@@ -0,0 +1,84 @@
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

@@ -0,0 +1,24 @@
{{- 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

@@ -0,0 +1,39 @@
{{- 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

@@ -0,0 +1,20 @@
{{- 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

@@ -0,0 +1,17 @@
{{- 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

@@ -0,0 +1,15 @@
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

@@ -0,0 +1,13 @@
{{- 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

@@ -0,0 +1,25 @@
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

@@ -0,0 +1,24 @@
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

@@ -0,0 +1,67 @@
{
"$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

@@ -0,0 +1,178 @@
# 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: {}

48
docker-compose.yml Normal file
View File

@@ -0,0 +1,48 @@
# 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

@@ -0,0 +1,42 @@
#!/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

@@ -0,0 +1,69 @@
# 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;
}

82
docker/smoke-test.sh Executable file
View File

@@ -0,0 +1,82 @@
#!/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

104
docs/docker.md Normal file
View File

@@ -0,0 +1,104 @@
# 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.

After

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 338 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

View File

@@ -0,0 +1,782 @@
# 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

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "openconcho"
version = "0.8.0"
version = "0.16.0"
edition = "2021"
[lib]
@@ -17,3 +17,5 @@ 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,8 +8,10 @@
{
"identifier": "http:default",
"allow": [
{ "url": "http://*" },
{ "url": "http://*:*" },
{ "url": "http://localhost" },
{ "url": "http://localhost:*" },
{ "url": "http://127.0.0.1" },
{ "url": "http://127.0.0.1:*" },
{ "url": "https://*" },
{ "url": "https://*:*" }
]

View File

@@ -0,0 +1,98 @@
//! 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,9 +1,12 @@
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": null
"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'"
}
},
"plugins": {

View File

@@ -0,0 +1,148 @@
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

@@ -0,0 +1,51 @@
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

@@ -0,0 +1,131 @@
/**
* 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,6 +17,7 @@ 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,6 +6,8 @@
<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,6 +10,7 @@
"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"
},
@@ -54,6 +55,7 @@
"@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

@@ -0,0 +1,4 @@
// 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

@@ -0,0 +1,99 @@
#!/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

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

View File

@@ -0,0 +1,128 @@
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,5 +32,8 @@ 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, page_size: pageSize } },
params: { query: { page, size: pageSize } },
body: {},
});
return data ?? err(error);
@@ -85,6 +85,22 @@ 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),
@@ -96,7 +112,7 @@ export function useQueueStatus(workspaceId: string) {
return data ?? err(error);
},
enabled: Boolean(workspaceId),
refetchInterval: 10_000,
refetchInterval: (query) => pickQueueRefetchInterval(query.state.data),
});
}
@@ -123,7 +139,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, page_size: pageSize } },
params: { path: { workspace_id: workspaceId }, query: { page, size: pageSize } },
body: {},
},
);
@@ -237,7 +253,7 @@ export function usePeerSessions(workspaceId: string, peerId: string, page = 1, p
{
params: {
path: { workspace_id: workspaceId, peer_id: peerId },
query: { page, page_size: pageSize },
query: { page, size: pageSize },
},
body: {},
},
@@ -263,7 +279,21 @@ export function useSearchPeer(workspaceId: string, peerId: string) {
});
}
export function useChat(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",
) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (message: string) => {
@@ -271,7 +301,7 @@ export function useChat(workspaceId: string, peerId: string) {
"/v3/workspaces/{workspace_id}/peers/{peer_id}/chat",
{
params: { path: { workspace_id: workspaceId, peer_id: peerId } },
body: { query: message, stream: false, reasoning_level: "low" },
body: { query: message, stream: false, reasoning_level: reasoningLevel },
},
);
return data ?? err(error);
@@ -293,9 +323,9 @@ export function useSessions(workspaceId: string, page = 1, pageSize = 20) {
{
params: {
path: { workspace_id: workspaceId },
query: { page, page_size: pageSize },
query: { page, size: pageSize },
},
body: { reverse: true },
body: { filters: { reverse: true } },
},
);
return data ?? err(error);
@@ -382,7 +412,7 @@ export function useSessionMessages(
{
params: {
path: { workspace_id: workspaceId, session_id: sessionId },
query: { page, page_size: pageSize },
query: { page, size: pageSize },
},
body: {},
},
@@ -612,7 +642,7 @@ export function useConclusions(
{
params: {
path: { workspace_id: workspaceId },
query: { page, page_size: pageSize, reverse },
query: { page, size: pageSize, reverse },
},
body: filters,
},
@@ -690,6 +720,50 @@ 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

@@ -0,0 +1,16 @@
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,159 +1,101 @@
import { Link } from "@tanstack/react-router";
import { Link, useNavigate } from "@tanstack/react-router";
import { motion } from "framer-motion";
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 { 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 { COLOR } from "@/lib/constants";
import { formatCount } from "@/lib/utils";
import { ServerWorkspaceRows } from "./ServerWorkspaceRows";
type QueueStatus = components["schemas"]["QueueStatus"];
// ─── Per-workspace queue row ─────────────────────────────────────────────────
function WorkspaceQueueRow({ workspaceId }: { workspaceId: string }) {
const { mask } = useDemo();
const { data, isLoading } = useQueueStatus(workspaceId);
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;
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>
<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 }}
>
{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 ───────────────────────────────────────────────────────────
const ALL_SERVERS = "all";
/**
* 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 [page] = useState(1);
const { data, isLoading, error } = useWorkspaces(page, 50);
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>>({});
const workspaces =
(data as { items?: Array<{ id: string; created_at?: string }> } | undefined)?.items ?? [];
const total = (data as { total?: number } | undefined)?.total ?? 0;
useEffect(() => {
if (serverFilter !== ALL_SERVERS && !instances.find((i) => i.id === serverFilter)) {
setServerFilter(ALL_SERVERS);
}
}, [instances, serverFilter]);
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 onOpenWorkspace = useCallback(
(instance: Instance, workspaceId: string) => {
if (instance.id !== activeId) activate(instance.id);
navigate({ to: "/workspaces/$workspaceId", params: { workspaceId } as never });
},
[activeId, activate, navigate],
);
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)",
}}
>
<SettingsIcon className="w-4 h-4" strokeWidth={1.5} />
Go to Settings
</Link>
}
/>
</div>
);
}
return (
<div className="page-container page-container--xl">
@@ -165,163 +107,136 @@ export function Dashboard() {
strokeWidth={1.5}
/>
<PageTitle>Dashboard</PageTitle>
{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>
)}
<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>
</div>
<Body className="leading-none">Overview of your Honcho instance</Body>
<Body className="leading-none">Workspaces across every configured server</Body>
</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.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>
{!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">
<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)" }}
>
<Skeleton accent className="h-4 w-4 rounded" />
<Skeleton className="h-4 w-28 rounded" />
<Skeleton className="ml-1 h-3 w-32 rounded" />
<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>
)}
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr style={{ background: "var(--bg-3)" }}>
{Array.from({ length: 6 }).map((_, index) => (
<th key={index} className="px-4 py-2 text-left">
<Skeleton className="h-3 w-14 rounded" />
</th>
))}
<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>
</tr>
</thead>
<tbody>
{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>
{shownInstances.map((inst) => (
<ServerWorkspaceRows
key={inst.id}
instance={inst}
onOpenWorkspace={onOpenWorkspace}
onMetrics={onMetrics}
/>
))}
</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

@@ -0,0 +1,191 @@
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

@@ -0,0 +1,242 @@
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

@@ -0,0 +1,241 @@
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

@@ -0,0 +1,143 @@
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

@@ -0,0 +1,177 @@
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

@@ -0,0 +1,192 @@
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

@@ -0,0 +1,43 @@
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,6 +6,7 @@ const SECTION_LABELS: Record<string, string> = {
peers: "Peers",
sessions: "Sessions",
conclusions: "Conclusions",
dreams: "Dreams",
webhooks: "Webhooks",
chat: "Chat",
};
@@ -14,7 +15,11 @@ 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): Segment[] {
function buildSegments(
pathname: string,
mask: (v: string) => string,
labels: Record<string, string>,
): Segment[] {
if (!pathname.startsWith("/workspaces")) return [];
const rest = pathname.slice("/workspaces".length); // "" | "/wid" | "/wid/peers" | ...
@@ -45,14 +50,20 @@ function buildSegments(pathname: string, mask: (v: string) => string): Segment[]
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: mask(subId), href: null, mono: true });
segments.push({ label: subLabel, href: null, mono: subMono });
return segments;
}
segments.push({
label: mask(subId),
label: subLabel,
href: `/workspaces/${wid}/${section}/${subId}`,
mono: true,
mono: subMono,
});
const subSection = parts[3];
@@ -63,10 +74,10 @@ function buildSegments(pathname: string, mask: (v: string) => string): Segment[]
return segments;
}
export function Breadcrumb() {
export function Breadcrumb({ labels = {} }: { labels?: Record<string, string> } = {}) {
const { state } = useRouter();
const { mask } = useDemo();
const segments = buildSegments(state.location.pathname, mask);
const segments = buildSegments(state.location.pathname, mask, labels);
if (segments.length <= 1) return null;

View File

@@ -1,3 +1,4 @@
import { useQueryClient } from "@tanstack/react-query";
import { Link, useMatchRoute } from "@tanstack/react-router";
import { AnimatePresence, motion } from "framer-motion";
import {
@@ -8,10 +9,12 @@ import {
ChevronsUpDown,
Eye,
EyeOff,
Layers,
LayoutDashboard,
Lightbulb,
MessageSquare,
Moon,
MoonStar,
Settings,
Sun,
Users,
@@ -29,6 +32,7 @@ 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 },
];
@@ -36,9 +40,51 @@ 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();
@@ -46,6 +92,7 @@ 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);
@@ -121,6 +168,9 @@ 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,6 +1,18 @@
import { useNavigate, useParams } from "@tanstack/react-router";
import { AnimatePresence, motion } from "framer-motion";
import { Eye, EyeOff, MessageCircle, Save, Search, User, Users, X } from "lucide-react";
import {
Check,
Eye,
EyeOff,
FlaskConical,
MessageCircle,
Pencil,
Save,
Search,
User,
Users,
X,
} from "lucide-react";
import { useState } from "react";
import {
usePeer,
@@ -9,6 +21,7 @@ import {
usePeerRepresentation,
useSearchPeer,
useSetPeerCard,
useUpdatePeer,
} from "@/api/queries";
import { Breadcrumb } from "@/components/layout/Breadcrumb";
import { Badge } from "@/components/shared/Badge";
@@ -31,6 +44,7 @@ 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();
@@ -59,6 +73,20 @@ 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;
@@ -71,14 +99,54 @@ export function PeerDetail() {
return (
<div className="page-container page-container--xl">
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }}>
<Breadcrumb />
<Breadcrumb labels={{ [peerId]: displayName }} />
<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} />
<PageTitle className="font-mono break-all">{mask(peerId)}</PageTitle>
{observeMe !== undefined && (
{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 && (
<span
className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full font-mono"
style={{
@@ -96,21 +164,40 @@ 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>
<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 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>
</div>
</motion.div>

View File

@@ -0,0 +1,376 @@
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

@@ -0,0 +1,311 @@
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

@@ -0,0 +1,121 @@
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

@@ -0,0 +1,365 @@
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.name || mask(session.id)}
{session.metadata?.name ? `${session.metadata.name}` : mask(session.id)}
</span>
<div className="flex items-center gap-2 shrink-0 ml-2">
{session.is_active && (

View File

@@ -0,0 +1,209 @@
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,12 +1,24 @@
import { AnimatePresence, motion } from "framer-motion";
import { Check, ChevronRight, Cloud, Pencil, Plus, Server, Sparkles, Trash2 } from "lucide-react";
import {
Check,
ChevronRight,
Cloud,
Pencil,
Plus,
Radar,
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";
@@ -14,7 +26,8 @@ type Mode =
| { kind: "list" }
| { kind: "choose-type" }
| { kind: "create"; preset: ConnectionPreset }
| { kind: "edit"; id: string };
| { kind: "edit"; id: string }
| { kind: "discover" };
interface InstancesManagerProps {
onActivated?: () => void;
@@ -23,16 +36,44 @@ 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 (
<ConnectionTypeChooser
onPick={(preset) => setMode({ kind: "create", preset })}
onCancel={isFirstRun ? undefined : () => setMode({ kind: "list" })}
/>
<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>
);
}
@@ -82,6 +123,18 @@ 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,6 +24,7 @@ import {
isCloudInstance,
} from "@/lib/config";
import { COLOR } from "@/lib/constants";
import { tokenTransportError } from "@/lib/security";
export type ConnectionPreset = "cloud" | "self-hosted";
@@ -81,6 +82,13 @@ 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);
@@ -112,6 +120,11 @@ 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

@@ -0,0 +1,310 @@
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,8 +13,12 @@ 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" });
const urlSchema = z
.string()
.url({ message: "Must be a valid URL" })
.refine(isHttpOrHttpsUrl, { message: "Webhook URL must use http or https" });
interface Props {
workspaceId: string;
@@ -50,6 +54,15 @@ 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 (
@@ -161,7 +174,7 @@ export function WebhookManager({ workspaceId }: Props) {
</span>
<button
type="button"
onClick={() => void open((wh as { url: string }).url)}
onClick={() => void handleOpenWebhook((wh as { url: string }).url)}
className="flex-shrink-0"
style={{
color: "var(--text-4)",

View File

@@ -1,6 +1,7 @@
import { Link, useNavigate, useParams } from "@tanstack/react-router";
import { AnimatePresence, motion } from "framer-motion";
import {
Activity,
Boxes,
ChevronDown,
CircleDot,
@@ -50,6 +51,12 @@ 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() {
@@ -107,7 +114,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-4 gap-3">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 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