Compare commits

..
14 Commits
Author SHA1 Message Date
zooqueen ba571999a2 the Discord invite on the deployed console is dead — recover the fix
Hanzo CI/CD / cicd (push) Successful in 3m13s
CI/CD / cicd (push) Successful in 3m13s
Four places pointed at discord.gg/hanzo. Discord's API answers "Unknown
Invite" for that code, so every Community / Join Discord control on the
deployed console led nowhere. discord.gg/CJCyAsm9Vr resolves to the real Hanzo
guild; verified both against https://discord.com/api/v10/invites/<code> rather
than by following the redirect, which returns 301 for a dead invite exactly as
it does for a live one.

Recovered from origin/main 03ed5663 ("Point every Discord invite at the current
Hanzo server"), stranded by the two-lineage split like the static image before
it — the fix existed and had simply never reached the branch that ships.

Also takes bff3be33's comment rename, EVENT_INGEST_KEY -> PUBLISHABLE_KEY: one
line, and it is the "do not fix this by reading NEXT_PUBLIC_…" warning, so a
stale variable name there points the next reader at an env var that no longer
exists.

The third stranded commit (10be3234, "topbar: the theme toggle comes from the
package, not a deleted file") is deliberately NOT taken. This lineage solved it
differently and better — the toggle left the topbar entirely and lives in the
account menu, so there is no import to repoint and no local ThemeToggle to
delete. Stranded is not the same as missing.
2026-08-05 21:18:25 -07:00
zooqueen 594314d351 console: the servable static image, recovered from the other lineage
Hanzo CI/CD / cicd (push) Successful in 3m8s
CI/CD / cicd (push) Successful in 3m9s
Cherry-picked in substance from origin/main 72870474 ("a servable static
image, so a frontend change needs no cloud release", 2026-08-04) and a4df740e
("one image, and the console is what it is", 2026-08-05), by hanzo-dev. Neither
had ever reached the DEPLOYED lineage, because this repo carries two histories
with NO common ancestor — 1643 commits on forge, 1549 on origin, and no merge
base at all.

The work was stranded, not superseded, and it is worth recovering because it
fixes something that cost two production outages TODAY. console.hanzo.ai is
answered by the cloud binary, which go:embeds the bundle, so every console
change needs a cloud image, a universe pin and a cloud roll — and cloud runs
`strategy: Recreate`, which makes each one a ~2-minute total API outage. Two
console fixes this afternoon cost exactly that, twice.

Dockerfile now builds the SPA export behind hanzoai/static and serves itself on
3000 — which is precisely the port the standalone `console` Deployment already
declares and has been waiting for. Dockerfile.embed is BYTE-IDENTICAL between
the two lineages and is untouched here, so the path that currently serves
console.hanzo.ai does not move. This adds a capability; it changes no route.

Flipping console.hanzo.ai from `service: cloud` to `service: console` in
universe hanzo-domains.yaml is the follow-up that actually collects the benefit,
and it is deliberately NOT in this commit: it changes how the console is served
and belongs to a decision, not to a file recovery.

Reconciling by SUBJECT rather than by hash is what made this tractable — 875 of
~890 subjects are shared, so the split is a re-import, not divergent work. Only
8 subjects exist solely on origin and 20 solely on forge, and of origin's 8, two
are git stash entries and one is the same fix carrying a "(#1)" PR suffix.
2026-08-05 21:16:35 -07:00
zooqueen c709b1f96a playground: every example suggests a model the gateway serves
Hanzo CI/CD / cicd (push) Successful in 4m55s
CI/CD / cicd (push) Successful in 5m2s
All six starters chipped `zen-omni` or `zen-coder`. The gateway carries neither
and never did — zen's text models are `zen5`, `zen5-mini`, `zen5-flash`,
`zen5-coder`, `zen5-pro`, while `zen-<noun>` names a modality (embedding, image,
video, rerank, voice, vl, guard).

Nothing threw, which is why it survived: applying a starter falls back to the
currently-selected model when the suggestion is absent from the live catalog
(`models.byId.has(...)`), so the card simply advertised a model that could never
be the one that ran. The model-id placeholder gave the same dead example.

`examples.test.ts` tests the SHAPE, not a list of ids — a hardcoded catalog would
rot exactly the way the suggestions did — so a modality SKU or a retired id
cannot come back. Same defect the agent builder's `defaultModel` had, where the
consequence was worse: it silently selected `zen-embedding`.
2026-08-05 19:41:42 -07:00
zooqueen 54d6ce9959 agents: one door to the builder — New Agent goes to the quickstart, the side-pane form is deleted
Hanzo CI/CD / cicd (push) Successful in 7m12s
CI/CD / cicd (push) Successful in 8m57s
2026-08-05 19:04:59 -07:00
zooqueen 8a7ae9be23 test(quickstart): the template card takes focus, rings, and Enter picks it
Hanzo CI/CD / cicd (push) Successful in 7m37s
CI/CD / cicd (push) Successful in 8m0s
2026-08-05 18:57:02 -07:00
zooqueen 19cebcb92a docs: the quickstart, the tool plane that was already live, and the rail that stopped drilling
Hanzo CI/CD / cicd (push) Successful in 5m48s
CI/CD / cicd (push) Successful in 6m27s
2026-08-05 18:55:50 -07:00
zooqueen 8b0a96c952 release: 8.5.61 — the quickstart and the rail that expands in place
CI/CD / cicd (push) Successful in 8m15s
Hanzo CI/CD / cicd (push) Successful in 6m43s
2026-08-05 18:53:25 -07:00
zooqueen ad9789b072 agents: a quickstart whose every step is a real call, and a rail that expands in place
CI/CD / cicd (push) Successful in 7m27s
Hanzo CI/CD / cicd (push) Successful in 7m4s
The builder was a form in a side pane. It is now reachable the way someone with
no agents actually starts: describe what you want, or take a template, then
configure, run and integrate. Four steps, and each is an endpoint —
`/v1/chat/completions` drafts the spec, `POST /v1/agents` creates it, `POST
/v1/agents/:ref/run` runs it, and the last step prints the request that just
worked. A step whose loader is absent says so; none of them draws a checkmark it
did not earn.

Templates are presets, never promises: every field one carries is something the
create body already expresses, and a test pins that. None names a tool, because
tools are per-org — a hardcoded `web.search` would fail at the agent's FIRST
invocation rather than here. The real ones come from `/v1/tools`, which has been
bound and serving all along while the loader's comment claimed no such endpoint
existed; it is wired now, and honest when an org has activated nothing. The
proxy admits discovery and refuses `/v1/tools/call` — running a tool belongs to
whatever runs an agent, not to a browser tab.

Two things were quietly wrong and are fixed here:

`defaultModel` named `zen-omni`, which the live catalog does not carry. So the
exact-match arm never fired and the fallback ran instead — `^zen[-.]` over a
sorted catalog, which selects `zen-embedding`. Every agent created without
touching the model field was pointed at an embeddings SKU that cannot hold a
conversation. The family test is `zen5…` now, because zen's naming splits
cleanly: `zen5*` is text, `zen-<noun>` is a modality. The placeholders were
advertising the same dead id and two invented tool names; both now say things
that exist.

The rail no longer drills. Clicking a product used to swap the whole sidebar for
that product's sub-nav behind "Back to all products"; the options were identical
either way, and what the drill took was every OTHER product — precisely what you
need when the reason you opened the rail was to go somewhere else. Sub-pages
expand beneath their own row instead. The label navigates, the chevron only
opens and closes, and that choice persists; the product you are in is open
unless you closed it. A pinned product appears twice, so exactly one copy owns
the sub-list — otherwise it is two navs painting at once, which is the thing
this rail exists to avoid.

The level-2 e2e now asserts the catalog is still on screen, which is the
invariant the drill could never have satisfied.
2026-08-05 18:51:48 -07:00
zooqueen 7e4e052694 we launch with chat, the builder and the console — the rest is beta
Hanzo CI/CD / cicd (push) Failing after 3m5s
CI/CD / cicd (push) Failing after 3m5s
LAUNCH_PRODUCTS is an allow-list of what a new signup sees tonight: the
console home, the AI plane those two products run on (chat, models,
playground, API keys, usage, logs), the money surfaces, org and account,
and the beta door itself. Everything else in the catalog — the whole
cloud and the app suite — is present, routable, and invisible until an
org holds the beta flag; a superadmin always sees it all.

Allow-list ON PURPOSE, replacing the 21 per-entry stamps: a product
added to the catalog tomorrow is hidden the day it lands, rather than
leaking onto a customer's first screen because nobody remembered to
stamp it. A test pins exactly that default.  survives as
the force-dark override for a launch surface that is not ready.
2026-08-05 16:07:39 -07:00
zooqueen 3cbaf7beb6 the tour card renders where it can be read
Hanzo CI/CD / cicd (push) Successful in 5m29s
CI/CD / cicd (push) Successful in 5m29s
Step one of every guide tour rendered clipped off the right edge: the
checklist walk hardcoded placement right, a checklist row spans the
whole card, and right of the row IS the viewport edge. Rows place
BELOW now, and the engine stops trusting placements at all: a side
with no room flips to the opposite side and falls through to
below/above, every axis clamps into the viewport, the anchor lookup
takes the first VISIBLE match (a hidden twin's zero box stranded the
spotlight), a scrolled-away target is brought back before measuring,
and an anchor bigger than most of the viewport is a container, not a
target — those center. No step, whatever its author assumed, can
render off-screen again.
2026-08-05 15:52:41 -07:00
zooqueen 298accf659 the apps are a beta, and search is discovery
Hanzo CI/CD / cicd (push) Successful in 7m4s
CI/CD / cicd (push) Successful in 7m4s
Every customer Apps surface (21 of them — CRM, Cap Table, Company,
Marketing, Ads, Social, and the rest) now carries beta: true and hides
from the rail, the pins, the palette, the All-products panel and search
until the caller's ORG holds the apps beta on the enablement plane
(kind feature, id apps) — the same self-service opt-in the Beta
features module already manages, scoped server-side. One predicate
(filterBeta, beside filterEntitled), fail-closed default, superadmins
always see, and Beta features itself is never behind its own flag —
the door to opt in must stay visible.

And the sidebar's product filter searches ALL of Hanzo: a typed query
opens the entitlement scope to the whole catalog — the point of
searching is finding what you do not have yet — while the admin and
beta gates keep holding. The resting rail stays scoped to the org's
enabled set, and the palette's typed search follows the same rule.
2026-08-05 15:48:35 -07:00
zooqueen 12d061f579 the org's own logo is the mark, and the mark row stops repeating it
Hanzo CI/CD / cicd (push) Successful in 4m50s
CI/CD / cicd (push) Successful in 4m50s
Settings' logo field gains Upload: the file becomes a compact data URL
in the same field (SVG verbatim, rasters downscaled to 64px on a
canvas, a cap refusing anything that would ride every IAM org read as
dead weight) — one value, one save path, one preview. The context
switcher renders that logo in the slot the org name held, height-capped
to the row, keeping the project beside it and the full text in the
aria-label. And the expanded rail drops its separate brand row: with
the switcher carrying the org's identity first, the H above it said
the same thing twice. The collapsed icon rail keeps its mark — there
is no switcher to carry the identity there.
2026-08-05 13:52:33 -07:00
zooqueen ff87cdbd98 playground: the response answers under the tabs
Hanzo CI/CD / cicd (push) Successful in 5m5s
CI/CD / cicd (push) Successful in 5m5s
The Response panel moves from a 1/3 right rail to a full-width row
directly under the surface tabs — the answer is the first thing on
every screen size, and the builder row follows. A YStack's order IS
the vertical order at every width, and dropping the rail's 320px
minimum from the wrap row removes the one squeeze it had; the builder
row keeps its proven phone-safe wrap. A render spec pins the geometry
(tabs, then Response, then composer; no horizontal overflow at 390
through 1920), gated on the fixture server like its siblings.
2026-08-05 13:26:04 -07:00
zooqueen 0f6cec3315 playground: offer only what routes, gate frontier prices behind a plan
Hanzo CI/CD / cicd (push) Successful in 5m1s
CI/CD / cicd (push) Successful in 5m6s
Picking Claude Haiku 4.5 errored with 'model anthropic/claude-haiku-4.5
is not available' — the bundle's openrouter spelling reached the run
while the gateway routes the bare id. The catalog now resolves each
row's ROUTING id against the live set by tail alias, so the picker
submits exactly what the gateway serves and haiku 4.5 simply works;
rows live under no spelling stop being offered at all (outage degrades
to the full catalog so the picker never renders empty).

Premium is derived where the bundle omits it — input >= $5/Mtok or
output >= $25/Mtok, the Opus class at both its price points, Sonnet
and Haiku under both bars — and an org without a standing subscription
is not offered premium rows (usePlanGate over the org's own scoped
/billing proxy, failing open on billing outage). The gateway's 402
remains the enforcement point; the picker stops promising what it
would refuse. Seeding draws from the same offered pool, so a free org
never boots onto a premium default.
2026-08-05 11:19:05 -07:00
50 changed files with 2613 additions and 353 deletions
+50 -47
View File
@@ -1,48 +1,51 @@
# console2Hanzo Cloud Console (Next.js 15 + @hanzo/gui). MIT OR Apache-2.0.
# NEXT_PUBLIC_* are inlined at build time (browser config), so they are build args.
FROM public.ecr.aws/docker/library/node:24-alpine AS build
WORKDIR /app
# Exact commit for a deterministic Next build id (next.config.mjs generateBuildId).
# The alpine image has no git binary, so CI passes the SHA as a build arg -> ENV,
# baked into .next/BUILD_ID so every replica of this image shares ONE build id.
ARG SOURCE_COMMIT=""
ENV SOURCE_COMMIT=$SOURCE_COMMIT
# Copy ALL source FIRST, then install — order matters under Kaniko --single-snapshot:
# a `COPY` that FOLLOWS the install in the same stage drops that RUN's freshly
# created node_modules (the 'next not found' cause — the install's own `test -f next`
# passed, then `COPY . .` wiped node_modules before the build RUN). Putting COPY
# before install means node_modules is created by the LAST RUNs and nothing clobbers
# it. (Layer-cache for deps is moot here — the on-cluster build runs --cache=false.)
COPY . .
# public/ may be empty (git doesn't track empty dirs) — ensure it exists for the runner COPY.
RUN mkdir -p public
# corepack installs the exact pnpm from package.json's `packageManager`, so the
# builder and a laptop resolve identically. --frozen-lockfile is the whole reason
# this repo is on pnpm: the old `npm install` here could not be `npm ci`, because
# @hanzo/gui's react-native tree resolves its platform/optional packages differently
# across npm versions and a lockfile written by one npm failed under another. pnpm
# records every platform in the lockfile, so the build installs exactly what is
# committed and fails loudly instead of quietly resolving something else.
RUN corepack enable && pnpm install --frozen-lockfile
# ONE brand-agnostic image: brand (IAM org/issuer/app + wordmark) is resolved at
# RUNTIME from the request hostname (src/config/index.ts), and /v1 is same-origin
# per host. Baking NEXT_PUBLIC_* here would inline a single brand and break that.
# Next 15 + @hanzo/gui (large RN dep tree) overflows Node's default heap → OOMKill
# (exit 137); cap the heap generously (chat uses 4096).
ENV NEXT_TELEMETRY_DISABLED=1 NODE_OPTIONS=--max-old-space-size=6144
RUN pnpm build
# hanzoai/console — the console image. It serves itself.
#
# The console is a static SPA export; this puts hanzoai/static in front of it. That
# itself: hanzoai/static in front of the bundle. It exists so a console change
# can reach production without a cloud release.
#
# Today console.hanzo.ai is answered by the cloud binary, which go:embeds the
# bundle (webui/console.go `//go:embed all:dist`). That couples a frontend change
# to a backend release: the bundle must be published, its tag pinned in cloud's
# Dockerfile, and a whole cloud image rebuilt and rolled out. The pin commit that
# preceded this one says what that costs — "four changes that could not reach
# production".
#
# Nothing about the request path changes when this serves instead. The embedded
# console is already a static export talking to the SAME origin's /v1, and cloud's
# catch-all only ever answered paths that no API route claimed (its apiPrefixes
# list is exactly "/v1/", "/api/", "/zap", "/healthz", "/readyz"). So the split is
# the one the ingress already expresses for admin.lux.cloud: /v1 + /zap to cloud,
# everything else here. Same bytes, same origin, same cookie — one fewer release
# in the way.
#
# -spa, not a 404 page: every unknown path IS a client-side route for an app shell
# (/models, /billing/budgets, a deep link someone pasted). The marketing site takes
# the opposite setting for the opposite reason — there a miss is a mistake.
FROM public.ecr.aws/docker/library/node:24-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 PORT=4000
RUN addgroup -S app && adduser -S app -G app
COPY --from=build /app/.next ./.next
COPY --from=build /app/public ./public
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json ./package.json
COPY --from=build /app/next.config.mjs ./next.config.mjs
# next.config.mjs imports this at load time (build AND standalone runtime); copy it or the server ERR_MODULE_NOT_FOUND-crashes on boot.
COPY --from=build /app/src/config/build-id.mjs ./src/config/build-id.mjs
USER app
EXPOSE 4000
CMD ["node", "node_modules/next/dist/bin/next", "start", "-p", "4000"]
FROM public.ecr.aws/docker/library/node:24-alpine AS build
RUN apk add --no-cache git
WORKDIR /console
# Heap headroom so the full @hanzo/gui static export never OOMs into a stub; telemetry off.
ENV NEXT_TELEMETRY_DISABLED=1 NODE_OPTIONS=--max-old-space-size=8192
# The console.hanzo.ai analytics property (public per-site id, not a KMS secret) —
# the same default Dockerfile.embed bakes, so a bundle served from here reports
# identically to one served from inside cloud.
ARG NEXT_PUBLIC_ANALYTICS_WEBSITE_ID=7dce54ee-41f6-4751-96bf-fe005067c7c7
ENV NEXT_PUBLIC_ANALYTICS_WEBSITE_ID=$NEXT_PUBLIC_ANALYTICS_WEBSITE_ID
COPY . .
RUN corepack enable && pnpm install --frozen-lockfile
# FAIL-HARD: the export MUST emit a real bundle, never a placeholder shell. An
# empty index.html would serve a blank page on every route with a 200, which is
# indistinguishable from a working deploy until someone opens it.
RUN pnpm build:embed && [ -s out/index.html ] && [ -d out/_next ] \
&& echo ">> servable REAL console bundle: $(wc -c < out/index.html)-byte index.html, $(du -sh out/_next | cut -f1) _next/"
# hanzoai/static, digest-pinned: a base image is pinned by digest so the bytes
# cannot change under a rebuild. (The console's OWN release is named by semver in
# the values file — that is the version a human reads.)
FROM ghcr.io/hanzoai/static@sha256:346ad30dc7f762c508b4467c2801b3d7e9ec201ec9b257bc7a38b60d59cecc05
COPY --from=build /console/out/ /srv/
EXPOSE 3000
ENTRYPOINT ["/static"]
CMD ["-root=/srv", "-spa", "-port=3000"]
+90 -2
View File
@@ -3588,8 +3588,8 @@ and the rail hid them.
rail, `SubNav`, and ⌘K all say the same word. The eight products missing `subpages`
now declare them; the icons the strips were carrying moved onto the declarations.
- **`components/ui/SubNav.tsx` is the ONE strip**, rendered from `productSubpages` and
hidden at `lg+` (`$lg={{ display: 'none' }}`) because the sidebar's `DrillNav` owns
level 2 there. One declaration, two mounts — never two navs painting at once. It
hidden at `lg+` (`$lg={{ display: 'none' }}`) because the sidebar owns level 2
there (then `DrillNav`; now `SubRows` — see "The rail stopped drilling" below). One declaration, two mounts — never two navs painting at once. It
takes an optional `href` for a product whose tabs carry URL state (Containers keeps
its `?cluster=` selection across tabs). `subpageIcon` moved here and `dashboard.tsx`
imports it, so the sub-page icon defaults exist once.
@@ -3833,3 +3833,91 @@ reports signed out and the app sits on its loader forever. It emits base64URL no
which is what a JWT segment actually is. And the assistant's composer carries its own
mic with the same `Talk to Hanzo` label, mounted-but-hidden until the panel opens, so
a bare attribute locator matches that one first: scope to `getByTestId('assistant-fab')`.
## The agent quickstart, and the rail that stopped drilling (v8.5.62)
Two changes that share a shape: something that looked finished was standing in for
the thing itself.
**The builder had no way in.** `AgentBuilder` — the canonical, host-agnostic one —
was reachable only as a form in a side pane, from a board you first had to have
agents to be looking at. `agents/quickstart` is the way someone with none starts:
describe what you want in a sentence, or take a template, then configure, run and
integrate.
- **Every step is an endpoint**, which is the whole design constraint. Describe →
`POST /v1/chat/completions` (`draftAgent`) turns a sentence into a spec; Configure →
the SAME `AgentBuilder`, seeded; Run → `POST /v1/agents/:ref/run` executes it and
shows the RECORDED run; Integrate → prints the request that just worked. A ladder
of steps is a promise about what happens, and a step that only draws a checkmark
turns the promise into decoration. Steps 1 and 3 are optional by construction —
their loaders may be absent, and the step then says exactly what is missing.
- **`components/agent-builder/templates.ts`** — eight presets, pure data. A template is
a PRESET, never a promise: it may only carry fields `toCreateBody` already expresses
(`name`, `description`, `systemPrompt`, and the real `AgentConfig` knobs), and a test
pins exactly that. **None names a tool.** Tools are per-org, so a hardcoded
`web.search` would name something that may not exist and would fail at the agent's
FIRST invocation rather than in the form. What a template CAN say truthfully is
`useTools` / `webSearch`, which are real switches in the agent contract.
- **The tool plane was live the whole time.** `loaders.ts` said "No live tool catalog
endpoint on this deployment yet" and left the field typeable-only. `GET /v1/tools` is
bound and serving — one flat set spanning connector actions, functions, zap-service
routes, agents, skills and the org's own MCP servers, deduplicated by name, each
flagged `activated`. `lib/api/tools.ts` reads it, `proxy-allow` admits the head, and
`/v1/tools/call` is REFUSED there: running a tool belongs to whatever runs an agent,
never to a browser tab. An org with nothing activated gets `{"tools":[]}` — a real
empty answer, shown honestly rather than papered over.
- **`defaultModel` was picking an embeddings model.** It named `zen-omni` as its exact
match, and the live catalog does not carry that id — so the exact arm never fired and
the fallback ran instead: `^zen[-.]` over an alphabetically sorted catalog, which
selects `zen-embedding`. Every agent created without touching the model field was
pointed at a SKU that cannot hold a conversation, and nothing caught it because the
dead exact-match read like the rule. The family test is `^zen\d` now, because zen's
naming splits cleanly: **`zen5*` are the text models; `zen-<noun>` names a MODALITY**
(embedding, image, video, rerank, voice, vl, guard). The model and tool placeholders
were advertising the same dead id and two invented tool names; both now say things
that exist.
**The rail stopped drilling.** Clicking a product used to swap the ENTIRE sidebar for
that product's sub-nav, behind a "Back to all products" button. The options were
identical either way — what the drill took away was every OTHER product, which is
precisely what someone needs when the reason they opened the rail was to go somewhere
else. `SubRows` replaces `DrillNav`: a product's sub-pages expand beneath its own row,
indented on a hairline, `inert` when collapsed.
- **The label navigates; the chevron only opens and closes.** One target doing both
would make "show me what is in here" and "take me there" the same gesture.
- `productIsOpen` / `toggleProduct` in `nav-accordion.ts`, beside the category pair and
keyed apart from it. The default is the OPPOSITE of a category's, deliberately:
categories are few and describe the catalog, so they open; products are many and each
brings four to eight rows, so opening them all would bury the catalog under its own
detail. The product you are IN is open unless you closed it, and that choice persists.
- **A pinned product appears twice** — once under Pinned, once in its category — and
exactly ONE copy may carry the sub-list. Two copies is two navs painting at once,
which is the thing this rail exists to avoid, and it doubles the rail's height for no
information. The pinned copy owns it.
**Verification.** `tsc --noEmit` clean; `vitest` **3259 passed / 8 skipped** (262 files;
+draft/handle parsing, +templates, +the product accordion, +the tool-plane allow/refuse
pair, and a `defaultModel` test that goes red on the `zen-embedding` regression).
RENDER-proven: `e2e/agent-quickstart.spec.ts` (3 tests) asserts the ladder, that the
gallery sits to the RIGHT of the composer by measured geometry at 1440, that searching
narrows it, that picking Deep researcher carries its handle and prompt into step 2, and
that 390 stacks without the body scrolling sideways. `e2e/level-2-nav.spec.ts` (5/5) was
retargeted, not deleted: it now asserts "All products" is still on screen while a
product is open — the invariant the drill could never have satisfied — and that no
"Back to all products" button exists on any of the 18 converted products.
**ONE door.** The board's New-Agent button opened the builder in a side pane —
the same component, reached by a different shape, with no templates, no drafting,
and nowhere to run what it made. It goes to the quickstart now and
`NewAgentForm` is deleted; two entrances to one builder is two things to keep in
step, and the pane was the lesser of them. A spec clicks the board's CTA and
asserts the URL lands on `/agents/quickstart`.
One placement note that cost a debug cycle: the quickstart branch must return BEFORE
`AgentsModule`'s loading/error/empty states. Building an agent does not depend on
reading the ones that exist, and the moments you most need the quickstart — no agents
yet, or the registry not answering — are exactly the ones those early returns swallow
it in.
+165
View File
@@ -0,0 +1,165 @@
/**
* e2e: the agent quickstart.
*
* The surface in the screenshot: a step ladder, "What do you want to build?" with a
* composer, and a searchable template gallery beside it. These are assertions only a
* browser can make — that the two columns actually paint side by side at desktop,
* stack on a phone without the body scrolling sideways, and that picking a template
* carries its preset into the builder.
*
* Run: BASE_URL=http://localhost:4000 npx playwright test agent-quickstart
*/
import { test, expect, type Route, type Page } from '@playwright/test'
import { mkdirSync } from 'node:fs'
import { join } from 'node:path'
import { requireFixtureServer } from './_fixture'
import { primeSession } from './_session'
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4000'
requireFixtureServer()
const SHOTS = join(process.cwd(), 'e2e-shots')
const ACCOUNT = { owner: 'hanzo', name: 'z', email: 'z@hanzo.ai', displayName: 'Z Admin', isAdmin: true }
const API_RE = /\/(v1|cloud|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations|auth\/refresh)(\/|$|\?)/
const json = (route: Route, body: unknown, status = 200) =>
route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
/** Every backend 401s — this spec is about the SURFACE, not data. */
async function mock(route: Route) {
const req = route.request()
if (req.resourceType() === 'document') return route.continue()
const url = new URL(req.url())
if (url.pathname.startsWith('/auth/')) return json(route, { ok: true })
const sameOrigin = url.origin === new URL(BASE_URL).origin
if (sameOrigin && !API_RE.test(url.pathname)) return route.continue()
return json(route, { error: 'Sign in to use Hanzo Cloud.' }, 401)
}
async function open(page: Page) {
await page.route('**/*', mock)
await primeSession(page, ACCOUNT)
await page.goto(`${BASE_URL}/agents/quickstart`, { waitUntil: 'domcontentloaded' })
await page.locator('[data-testid="product-content"]').first().waitFor({ state: 'attached', timeout: 30_000 })
await page.waitForTimeout(1500)
}
test.beforeAll(() => mkdirSync(SHOTS, { recursive: true }))
test('desktop: the ladder, the composer and the gallery', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const page = await ctx.newPage()
await open(page)
await expect(page.getByText('What do you want to build?')).toBeVisible()
await expect(page.getByLabel('Describe your agent')).toBeVisible()
await expect(page.getByText('Browse templates')).toBeVisible()
// Step 1 is current; later steps are present but not yet reachable.
await expect(page.getByRole('button', { name: /Step 1: Describe/ })).toBeVisible()
await expect(page.getByRole('button', { name: /Step 3: Run/ })).toBeDisabled()
// The two columns sit SIDE BY SIDE — geometry, not source.
const composer = await page.getByLabel('Describe your agent').boundingBox()
const gallery = await page.getByText('Browse templates').boundingBox()
expect(composer && gallery).toBeTruthy()
expect(gallery!.x, 'the gallery is to the right of the composer').toBeGreaterThan(composer!.x + composer!.width - 1)
await page.screenshot({ path: join(SHOTS, 'agent-quickstart-desktop.png'), fullPage: false })
await ctx.close()
})
test('the gallery searches, and picking a template carries its preset into the builder', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const page = await ctx.newPage()
await open(page)
await expect(page.getByRole('button', { name: 'Start from Deep researcher' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Start from Code reviewer' })).toBeVisible()
await page.getByLabel('Search templates').fill('extract')
await page.waitForTimeout(400)
await expect(page.getByRole('button', { name: 'Start from Structured extractor' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Start from Deep researcher' })).toHaveCount(0)
await page.getByLabel('Search templates').fill('')
await page.waitForTimeout(300)
await page.getByRole('button', { name: 'Start from Deep researcher' }).click()
await page.waitForTimeout(700)
// Step 2: the ONE builder, carrying the template's preset — the handle and the
// prompt the template declares, not an empty form.
await expect(page.getByRole('button', { name: /Step 2: Configure/ })).toBeVisible()
await expect(page.locator('input[value="researcher"]').first()).toBeVisible()
await expect(page.getByText(/You research questions/).first()).toBeVisible()
await page.screenshot({ path: join(SHOTS, 'agent-quickstart-configure.png'), fullPage: false })
await ctx.close()
})
test('phone: it stacks and the body never scrolls sideways', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 390, height: 844 } })
const page = await ctx.newPage()
await open(page)
await expect(page.getByText('What do you want to build?')).toBeVisible()
await expect(page.getByLabel('Describe your agent')).toBeVisible()
const scrolls = await page.evaluate(
() => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
)
expect(scrolls, 'body must not scroll horizontally').toBe(false)
await page.screenshot({ path: join(SHOTS, 'agent-quickstart-phone.png'), fullPage: true })
await ctx.close()
})
test('a template card is reachable and operable by keyboard, and it rings', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const page = await ctx.newPage()
await open(page)
const card = page.getByRole('button', { name: 'Start from Deep researcher' })
await card.focus()
await expect(card).toBeFocused()
// The focus law lives in globals.css and keys off [tabindex] among others — a card
// that takes focus and shows nothing is worse than one that cannot be reached.
const ring = await card.evaluate((el) => {
const s = getComputedStyle(el)
return { width: s.outlineWidth, style: s.outlineStyle, color: s.outlineColor }
})
expect(ring.style, 'the focused card draws an outline').not.toBe('none')
expect(parseFloat(ring.width), 'the outline has real width').toBeGreaterThan(0)
// Enter picks it — the same thing a click does.
await page.keyboard.press('Enter')
await page.waitForTimeout(700)
await expect(page.getByRole('button', { name: /Step 2: Configure/ })).toBeVisible()
await expect(page.locator('input[value="researcher"]').first()).toBeVisible()
await ctx.close()
})
test('the board\'s New Agent button is the SAME door as the quickstart', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const page = await ctx.newPage()
await page.route('**/*', mock)
await primeSession(page, ACCOUNT)
await page.goto(`${BASE_URL}/agents`, { waitUntil: 'domcontentloaded' })
await page.locator('[data-testid="product-content"]').first().waitFor({ state: 'attached', timeout: 30_000 })
await page.waitForTimeout(1200)
// Whichever New-Agent affordance the board is showing (header button or empty
// state), it must LAND on the quickstart — not open a second, differently-shaped
// create form in a side pane.
const cta = page.getByRole('button', { name: /New Agent/i }).filter({ visible: true }).first()
await cta.click()
await page.waitForTimeout(900)
expect(new URL(page.url()).pathname).toBe('/agents/quickstart')
await expect(page.getByText('What do you want to build?')).toBeVisible()
await ctx.close()
})
+24 -13
View File
@@ -2,9 +2,15 @@
* e2e: ONE level-2 nav.
*
* Clicking into a product must reveal ITS options rather than replacing the screen,
* and there must be exactly ONE such nav on screen — not the sidebar's drill-down AND
* a competing tab strip in the content, which is what `/models` used to do (eight
* items in the rail, four in the content, disagreeing on the index's own name).
* and there must be exactly ONE such nav on screen — not the sidebar's level 2 AND a
* competing tab strip in the content, which is what `/models` used to do (eight items
* in the rail, four in the content, disagreeing on the index's own name).
*
* "Rather than replacing the screen" is now literal on both axes: the product's
* sub-pages expand BENEATH its row and the rest of the catalog stays put. The rail
* used to swap itself for the product's sub-nav behind a "Back to all products"
* button, so these specs assert the other products are still there — that is the
* whole point of the change, and the part a future drill would silently undo.
*
* These are assertions only a browser can make. They read COMPUTED style and
* GEOMETRY, not source: a strip hidden by a `$lg` media style prop is still in the
@@ -76,8 +82,12 @@ test('desktop: the sidebar owns level 2 — the content strip is not a second na
const page = await ctx.newPage()
await open(page, '/models')
// The rail drilled into Models and shows the product's own options.
await expect(page.getByRole('button', { name: 'Back to all products' })).toBeVisible()
// The rail expanded Models in place — and did NOT swap itself for it.
await expect(page.getByRole('button', { name: 'Back to all products' })).toHaveCount(0)
// The rest of the catalog is still there — "All products" sits at the FOOT of the
// product list, so its presence proves the list was never swapped away. This is the
// assertion the drill could not have passed.
await expect(page.getByRole('button', { name: 'All products' }).first()).toBeVisible()
// The index is named what the PRODUCT calls it — Models' index is the Catalog,
// not a generic "Overview". This is the registry's `indexLabel`, read by the nav.
@@ -166,10 +176,10 @@ test('back returns a level without losing pinned state', async ({ browser }) =>
await page.waitForTimeout(900)
expect(new URL(page.url()).pathname).toBe('/models')
// Still drilled into Models with the same options — Back moved the LEVEL, it did
// not throw the user out to the product list.
await expect(page.getByRole('button', { name: 'Back to all products' })).toBeVisible()
// Models is still expanded with the same options — browser Back moved the ROUTE,
// and the rail followed it without collapsing what the user was looking at.
await expect(visibleTab(page, 'Catalog').first()).toBeVisible()
await expect(page.getByRole('button', { name: 'Back to all products' })).toHaveCount(0)
expect(await page.evaluate(() => localStorage.getItem('hanzo.preferences.cache'))).toBe(pinsBefore)
await ctx.close()
@@ -177,9 +187,10 @@ test('back returns a level without losing pinned state', async ({ browser }) =>
/**
* Every product that used to carry its own `const TABS` — the whole conversion, in
* one sweep. For each: the page renders, the rail drills into it, and the content
* strip is present but PAINTS NOTHING at lg+. That is the "no second nav" invariant,
* and it is the thing that regresses the moment someone adds a tab bar back.
* one sweep. For each: the page renders, the rail expands it in place, and the
* content strip is present but PAINTS NOTHING at lg+. That is the "no second nav"
* invariant, and it is the thing that regresses the moment someone adds a tab bar
* back.
*/
const CONVERTED = [
'models', 'evals', 'ai-accounts', 'containers', 'analytics', 'finetuning', 'team',
@@ -200,8 +211,8 @@ test('no product paints a second level-2 nav at lg+', async ({ browser }) => {
).toBe('none')
await expect(
page.getByRole('button', { name: 'Back to all products' }),
`${id}: the rail drilled in`,
).toBeVisible()
`${id}: the rail expands in place — it must never swap itself for one product`,
).toHaveCount(0)
}
await ctx.close()
+75
View File
@@ -0,0 +1,75 @@
/**
* Playground layout: the Response panel sits UNDER the surface tabs — above
* the composer — at every width. Render-proven on the local dev server with a
* fully mocked network (no gateway, no billing, no catalog): what is asserted
* is GEOMETRY, which mocks cannot fake.
*
* Run: BASE_URL=http://localhost:4000 npx playwright test playground-responsive
*/
import { test, expect, type Route } from '@playwright/test'
import { requireFixtureServer } from './_fixture'
import { primeSession } from './_session'
import { mkdirSync } from 'node:fs'
import { join } from 'node:path'
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4000'
// The module shell resolves the product registry from the local fixture server,
// like every other module render spec; skip cleanly when it is down.
requireFixtureServer()
const SHOTS = join(process.cwd(), 'e2e-shots')
const WIDTHS = [
{ name: 'phone', width: 390, height: 844 },
{ name: 'tablet', width: 834, height: 1112 },
{ name: 'laptop', width: 1440, height: 900 },
{ name: 'desktop', width: 1920, height: 1080 },
]
// Minimal honest bodies for everything the page asks the backend.
const mock = async (route: Route) => {
const url = route.request().url()
const json = (body: unknown) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) })
if (url.includes('/pricing/models')) return json({ models: [] })
if (url.includes('/v1/models'))
return json({ object: 'list', data: [{ id: 'zen5-flash', owned_by: 'Hanzo' }] })
if (url.includes('/billing/subscriptions')) return json({ subscriptions: [] })
if (url.includes(':4000') || url.startsWith(BASE_URL)) return route.continue()
return json({})
}
for (const vp of WIDTHS) {
test(`response renders under the tabs at ${vp.name} (${vp.width}px)`, async ({ page }) => {
await page.setViewportSize({ width: vp.width, height: vp.height })
await page.route('**/*', mock)
await primeSession(page)
await page.goto(`${BASE_URL}/ai/playground`, { waitUntil: 'domcontentloaded' })
// The three landmarks: the surface tabs, the Response panel, the composer.
const tabs = page.getByRole('button', { name: 'Completions' }).first()
const response = page.getByText('Response', { exact: true }).first()
const composer = page.getByText('System prompt', { exact: true }).first()
await expect(tabs).toBeVisible({ timeout: 20000 })
await expect(response).toBeVisible()
await expect(composer).toBeVisible()
const [tabsBox, respBox, compBox] = await Promise.all([
tabs.boundingBox(),
response.boundingBox(),
composer.boundingBox(),
])
if (!tabsBox || !respBox || !compBox) throw new Error('a landmark has no box')
// ORDER: tabs, then Response, then the composer — at every width.
expect(respBox.y, 'Response sits below the tabs').toBeGreaterThan(tabsBox.y)
expect(compBox.y, 'the composer sits below the Response panel top').toBeGreaterThan(respBox.y)
// RESPONSIVE: nothing forces a horizontal scroll.
const scrollW = await page.evaluate(() => document.documentElement.scrollWidth)
expect(scrollW, 'no horizontal overflow').toBeLessThanOrEqual(vp.width + 1)
mkdirSync(SHOTS, { recursive: true })
await page.screenshot({ path: join(SHOTS, `playground-${vp.name}-${vp.width}.png`) })
})
}
+30 -22
View File
@@ -1,34 +1,42 @@
# Canonical CI config for hanzoai/console — read by the hanzoai/ci reusable
# (.hanzo/workflows/cicd.yml) and platform.hanzo.ai.
# (.hanzo/workflows/cicd.yml) and platform.hanzo.ai. hanzoai/ci pushes to `repo:`
# (GHCR) and server-side-mirrors to registry.hanzo.ai automatically.
#
# Publishes the console STATIC EMBED artifact (SPA static export at /dist) as a
# versioned immutable image. hanzoai/cloud consumes it via `FROM ... AS console`
# + `COPY --from=console /dist/`, so it never rebuilds npm+Next on a cloud release.
# hanzoai/ci pushes to `repo:` (GHCR) and server-side-mirrors to registry.hanzo.ai
# automatically.
# TWO artifacts, one bundle. The console is a static SPA export; what differs is
# only who serves it:
#
# BOTH console images are declared here — one config, read by whichever runner
# executes it. The Next.js SERVER image (admin.hanzo.ai, operator CR
# universe:crs/console.yaml) was built by .github/workflows/build-image.yml until
# that file was neutralized on 2026-07-24 in favour of a native pipeline that
# could not run: hanzoai/console had the forge Actions unit DISABLED
# (`has_actions: false`, zero runs), so nothing built it — v8.5.23 and 8.5.24
# shipped no image, and the CR still pins the last one built, v8.5.22. Declaring
# both images here puts them on the ONE pipeline, wherever it executes.
# console-embed the bundle alone at /dist. hanzoai/cloud does
# `COPY --from=console /dist/` so a cloud release never rebuilds
# npm+Next. Needed only while cloud go:embeds the console.
# console the bundle behind hanzoai/static, serving itself. This is how
# a console change ships WITHOUT a cloud release: move image.tag
# in a universe values file and cd rolls it.
#
# Tag shape changes with the builder, deliberately: the shared builder publishes
# the immutable `sha-<sha7>-amd64` per main push (plus the bare semver on a v*
# tag), not the `:v<X.Y.Z>` receipt the old bespoke workflow minted. Pin the CR to
# the sha tag — that is what hanzoai/cloud does, and an immutable digest-shaped
# tag cannot be re-pushed to different bytes the way `:v8.4.118` once was.
# The Next.js SERVER image that used to be the second entry is gone. It was
# already doing nothing a file server could not — every host it served sent /v1
# and /zap to cloud-api at the ingress, so its BFF was never reached — and its own
# auth routes stopped mattering when identity became a client-held IAM token.
#
# TAGS: the shared builder publishes `sha-<sha7>-amd64` on every main push AND the
# bare semver on a cut v* tag. PIN THE SEMVER — it says which console RELEASE a
# deployment carries, which a sha cannot. The discipline that keeps that honest is
# that a cut tag is never re-pointed (`:v8.4.118` once was): cut the next patch
# instead.
images:
- name: console-embed
context: .
dockerfile: Dockerfile.embed
repo: ghcr.io/hanzoai/console-embed
# The brand-agnostic Next.js server image: brand resolves at RUNTIME from the
# request hostname, so no NEXT_PUBLIC_* may be baked (baking pins the image to
# one brand). SOURCE_COMMIT is the only build arg it ever took.
# The console. It is static — that is not a variant, it is what the console IS,
# so the image is `console` and there is no adjective in the name. Dockerfile
# builds the SPA export and puts hanzoai/static in front of it.
#
# This REPLACES the Next.js server image that used to be published here. It was
# already doing nothing a file server could not: every host it serves
# (admin.lux.cloud, admin.lux.network, admin.zoo.cloud) sends /v1 and /zap to
# cloud-api at the ingress, so the server's BFF at /v1/* was never reached on any
# of them. Its own auth routes went the same way when identity became a
# client-held IAM token. One console, one image.
- name: console
context: .
dockerfile: Dockerfile
+2 -2
View File
@@ -1,11 +1,11 @@
{
"name": "@hanzo/console",
"version": "8.5.50",
"version": "8.5.62",
"packageManager": "pnpm@11.17.0",
"private": true,
"license": "MIT OR Apache-2.0",
"author": "Hanzo AI <dev@hanzo.ai>",
"description": "Hanzo Cloud Console \u2014 unified admin console for Hanzo Cloud and all cloud products.",
"description": "Hanzo Cloud Console unified admin console for Hanzo Cloud and all cloud products.",
"scripts": {
"dev": "next dev -p 4000",
"build": "next build",
+3 -1
View File
@@ -20,6 +20,7 @@ import { Button, Input, Text, XStack, YStack } from '@hanzo/gui'
import { Activity, Plus, Search, Star } from '@hanzogui/lucide-icons-2'
import { visibleCatalogByCategory, type CatalogEntry, type ProductIcon } from '~/lib/products/registry'
import { useAppsBeta } from '~/lib/products/beta'
import { useIsSuperAdmin } from '~/lib/auth/admin'
import { usePins, useProductColors } from '~/lib/products/pins'
import { openProduct } from '~/lib/products/open'
@@ -189,7 +190,8 @@ export function AddProductPanel() {
// Source = the FULL catalog the viewer may see (ungated → both pinned and unpinned
// appear), grouped by category.
const groups = useMemo(() => visibleCatalogByCategory(showAdmin, null), [showAdmin])
const showBeta = useAppsBeta(showAdmin)
const groups = useMemo(() => visibleCatalogByCategory(showAdmin, null, showBeta), [showAdmin, showBeta])
// Literal, case-insensitive substring match over label/description/id — NOT a
// compiled RegExp of user input.
+3 -1
View File
@@ -77,6 +77,7 @@ import { assistantState, commandBarSystemPrompt, hanzoAssistantSystemPrompt } fr
import { searchDestinations, type Destination } from '~/lib/products/search'
import { DEFAULT_GROUP_LABEL, pinnedFirst } from '~/lib/products/pins-core'
import { usePins, useProductColors } from '~/lib/products/pins'
import { useAppsBeta } from '~/lib/products/beta'
import { ProductIcon } from '~/components/ui/ProductIcon'
import { openProduct } from '~/lib/products/open'
import { currentOrg, switchOrg } from '~/lib/org-scope'
@@ -364,6 +365,7 @@ function PaletteDialog({
const router = useRouter()
const { signOut } = useSession()
const showAdmin = useIsSuperAdmin()
const showBeta = useAppsBeta(showAdmin)
const { colorOf } = useProductColors()
const pins = usePins()
const { current, resolvedTheme, set: setTheme } = useThemeSetting()
@@ -439,7 +441,7 @@ function PaletteDialog({
// a search, so the ranked branch is left strictly alone.
const destResults = useMemo(() => {
if (mode !== 'catalog') return []
const found = searchDestinations(query, showAdmin, null)
const found = searchDestinations(query, showAdmin, null, showBeta)
if (sub) return found.slice(0, 50)
return pinnedFirst(found, (d) => (d.kind === 'product' ? d.entry.id : ''), pins.pinnedIds)
}, [mode, query, sub, showAdmin, pins.pinnedIds])
+25 -3
View File
@@ -105,9 +105,31 @@ export function ContextSwitcher() {
iconAfter={<ChevronsUpDown size={13} opacity={0.6} />}
aria-label={`Organization and project — ${contextLabel(orgLabel, scope.project)}`}
>
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1} flex={1}>
{contextLabel(orgLabel, scope.project)}
</Text>
{org.logo ? (
// The org's own logo IS the label — the uploaded mark takes the
// slot the name held, height-capped to the row so any aspect fits.
// A scoped project keeps its text beside it; the full text stays
// in the aria-label either way. Arbitrary tenant URL/data URL, so
// a raw <img> (next/image would need a per-tenant remote
// allow-list) — same call BrandLogo makes.
<XStack items="center" gap="$2" flex={1} minW={0}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={org.logo}
alt={orgLabel}
style={{ height: 22, width: 'auto', maxWidth: 140, objectFit: 'contain', display: 'block' }}
/>
{scope.project ? (
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1} flex={1}>
/ {scope.project}
</Text>
) : null}
</XStack>
) : (
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1} flex={1}>
{contextLabel(orgLabel, scope.project)}
</Text>
)}
</Button>
</Popover.Trigger>
+32 -7
View File
@@ -58,18 +58,32 @@ const CUSTOM = '__custom__'
export function AgentBuilder({
loaders,
initial,
onCreated,
onCancel,
submitLabel = 'Create agent',
}: {
loaders: AgentBuilderLoaders
/** Called after a successful create (the host reloads its list + closes the form). */
onCreated: () => void
/**
* A spec to start from — a template's preset, or what a description drafted.
* Read ONCE, at mount: the form is the user's from that point on, so a seed can
* never overwrite something they have already typed. A host that swaps seeds
* (the quickstart, when a different template is picked) remounts with a `key`,
* which states the intent — a new starting point — instead of hiding it in an
* effect that races the user's keystrokes.
*/
initial?: Partial<AgentSpec>
/**
* Called after a successful create, with the NAME the agent was created under
* (the handle every `/v1/agents/:ref` route is keyed by) so the host can go
* straight to running it rather than looking it back up.
*/
onCreated: (name: string) => void
/** Called when the user cancels (optional — omit for an always-open form). */
onCancel?: () => void
submitLabel?: string
}) {
const [spec, setSpec] = useState<AgentSpec>(emptySpec)
const [spec, setSpec] = useState<AgentSpec>(() => ({ ...emptySpec(), ...initial }))
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const [unavailable, setUnavailable] = useState(false)
@@ -165,8 +179,9 @@ export function AgentBuilder({
setError(null)
setUnavailable(false)
try {
await loaders.createAgent(toCreateBody(spec))
onCreated()
const body = toCreateBody(spec)
await loaders.createAgent(body)
onCreated(body.name)
} catch (e) {
const c = classifyBuilderError(e)
if (c.kind === 'unavailable') setUnavailable(true)
@@ -202,7 +217,10 @@ export function AgentBuilder({
loading={models.phase === 'loading'}
error={models.phase === 'error' ? `Model catalog unavailable — type a model id. (${models.message})` : null}
onRetry={loadModels}
placeholder="zen-omni · gpt-4o-mini · claude-sonnet-4-5"
// A placeholder is an example, and an example that does not exist is a lie
// the user only discovers at the agent's first run. These are ids the live
// catalog actually serves; the field itself offers the real list.
placeholder="zen5 · zen5-mini · claude-sonnet-5"
/>
</FieldRow>
@@ -239,7 +257,14 @@ export function AgentBuilder({
loading={tools.phase === 'loading'}
error={tools.phase === 'error' ? `Tool catalog unavailable — type a tool id.` : null}
onRetry={loadTools}
placeholder="add a tool — e.g. web.search, code.exec"
// No invented examples here either: the tool plane is per-org, so nobody
// can name a tool that is certain to exist. The field offers what the org
// has actually activated, and stays typeable for what it has not.
placeholder={
tools.phase === 'ready' && tools.options.length === 0
? 'No tools activated yet — type one to use it anyway'
: 'Search your tools'
}
emptyText="Press Add to include what you typed."
/>
<XStack gap="$2">
+532
View File
@@ -0,0 +1,532 @@
'use client'
/**
* AgentQuickstart — the guided way into the ONE builder: describe an agent in your
* own words or start from a template, configure it, run it, and take the call away.
*
* FOUR STEPS, AND EVERY ONE IS A REAL CALL. That is the whole design constraint. A
* ladder of steps is a promise about what happens; a step that only draws a checkmark
* turns the promise into decoration. So:
*
* 1 Describe → `POST /v1/chat/completions` drafts a spec from a sentence
* (`draftAgent`), or a template fills the form with a preset
* 2 Configure → the SAME `AgentBuilder` every other surface uses, seeded
* 3 Run → `POST /v1/agents/:ref/run` executes it and shows the recorded run
* 4 Integrate → the request that just worked, as code
*
* Steps 1 and 3 are OPTIONAL by construction: their loaders (`draftAgent`, `runAgent`)
* may be absent, and the step then says exactly what is missing instead of miming it.
* Step 2 is the only one that cannot be skipped, because creating the agent is the
* point and the builder is the one thing that does it.
*
* Host-agnostic like the rest of the module: everything arrives through
* `AgentBuilderLoaders`, so chat, app and bot mount this over the same `/v1/agents`.
*/
import { useMemo, useState } from 'react'
import { Button, Card, Input, ScrollView, Spinner, Text, TextArea, XStack, YStack } from '@hanzo/gui'
import { ArrowRight, Bot, Check, Play, Search, Terminal, X } from '@hanzogui/lucide-icons-2'
import { AgentBuilder } from './AgentBuilder'
import { defaultConfig, emptySpec, proposeName } from './logic'
import { AGENT_TEMPLATES, searchTemplates, specFromTemplate, type AgentTemplate } from './templates'
import type { AgentBuilderLoaders, AgentRunResult, AgentSpec } from './types'
/** The four steps, in order. The id is what the component switches on. */
const STEPS = [
{ id: 'describe', label: 'Describe', endpoint: 'POST /v1/agents' },
{ id: 'configure', label: 'Configure', endpoint: '' },
{ id: 'run', label: 'Run', endpoint: 'POST /v1/agents/:ref/run' },
{ id: 'integrate', label: 'Integrate', endpoint: '' },
] as const
type StepId = (typeof STEPS)[number]['id']
/**
* The step ladder. A step reached earlier is a real link back — going back to change
* the prompt is the most common thing a person wants here, and a ladder you cannot
* climb down is a worse version of a heading.
*/
function StepLadder({ current, onGo }: { current: StepId; onGo: (s: StepId) => void }) {
const index = STEPS.findIndex((s) => s.id === current)
return (
<XStack items="center" gap="$2" flexWrap="wrap" role="list" aria-label="Quickstart steps">
{STEPS.map((s, i) => {
const done = i < index
const active = i === index
return (
<XStack key={s.id} items="center" gap="$2" role="listitem">
{i > 0 ? <XStack width={20} height={1} bg="$borderColor" $md={{ width: 32 }} /> : null}
<Button
size="$2"
chromeless
px="$2"
disabled={i > index}
onPress={() => onGo(s.id)}
opacity={i > index ? 0.45 : 1}
aria-current={active ? 'step' : undefined}
aria-label={`Step ${i + 1}: ${s.label}${done ? ' (done)' : ''}`}
>
<XStack items="center" gap="$2">
<XStack
width={20}
height={20}
rounded="$10"
items="center"
justify="center"
bg={done || active ? '$color12' : 'transparent'}
borderWidth={done || active ? 0 : 1}
borderColor="$borderColor"
>
{done ? (
<Check size={12} color="$color1" />
) : (
<Text fontSize="$1" fontWeight="700" color={active ? '$color1' : '$color10'}>
{i + 1}
</Text>
)}
</XStack>
<Text fontSize="$2" fontWeight={active ? '700' : '500'} color={active ? '$color12' : '$color10'}>
{s.label}
</Text>
{active && s.endpoint ? (
<Text fontSize="$1" color="$color9" fontFamily="$mono" display="none" $md={{ display: 'flex' }}>
{s.endpoint}
</Text>
) : null}
</XStack>
</Button>
</XStack>
)
})}
</XStack>
)
}
/** One template card in the gallery. The whole card is the control. */
function TemplateCard({ template, onPick }: { template: AgentTemplate; onPick: () => void }) {
return (
<YStack
onPress={onPick}
cursor="pointer"
role="button"
tabIndex={0}
focusable
onKeyDown={(e: { key?: string; preventDefault?: () => void }) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault?.()
onPick()
}
}}
gap="$1.5"
p="$3"
rounded="$4"
borderWidth={1}
borderColor="$borderColor"
bg="$color2"
hoverStyle={{ bg: '$color3', borderColor: '$color8' }}
aria-label={`Start from ${template.title}`}
>
<Text fontSize="$3" fontWeight="700" color="$color12">
{template.title}
</Text>
<Text fontSize="$2" color="$color11">
{template.summary}
</Text>
</YStack>
)
}
/** A short, quiet note — used wherever a step has to say what is missing. */
function Note({ children }: { children: React.ReactNode }) {
return (
<Text fontSize="$2" color="$color10">
{children}
</Text>
)
}
export function AgentQuickstart({
loaders,
onFinished,
apiBase = 'https://api.hanzo.ai',
}: {
loaders: AgentBuilderLoaders
/** Called when the user leaves the quickstart with an agent created (host reloads). */
onFinished?: (name: string) => void
/** The API origin the integrate snippet should show. */
apiBase?: string
}) {
const [step, setStep] = useState<StepId>('describe')
const [seed, setSeed] = useState<Partial<AgentSpec>>({})
// Bumped whenever a NEW starting point is chosen, so the builder remounts on it
// rather than an effect racing whatever the user has already typed.
const [seedKey, setSeedKey] = useState(0)
const [created, setCreated] = useState<string | null>(null)
// ── Step 1: describe ──────────────────────────────────────────────────────
const [description, setDescription] = useState('')
const [drafting, setDrafting] = useState(false)
const [draftError, setDraftError] = useState<string | null>(null)
const [query, setQuery] = useState('')
const templates = useMemo(() => searchTemplates(query), [query])
const start = (next: Partial<AgentSpec>) => {
setSeed(next)
setSeedKey((k) => k + 1)
setStep('configure')
}
const pickTemplate = (t: AgentTemplate) => start(specFromTemplate(t, emptySpec(), defaultConfig()))
const describe = async () => {
const text = description.trim()
if (!text || drafting) return
// Whatever happens next, the user's own words are already worth something: they
// are the description, and they propose the handle. A draft only ever ADDS to
// this, so a failed or absent draft still lands them in a part-filled form.
const fallback: Partial<AgentSpec> = { description: text, name: proposeName(text) }
if (!loaders.draftAgent) {
start(fallback)
return
}
setDrafting(true)
setDraftError(null)
try {
const drafted = await loaders.draftAgent(text)
start({ ...fallback, ...drafted })
} catch (e) {
// Say why, and still go — being stranded on a spinner is worse than writing
// the prompt yourself.
setDraftError(e instanceof Error ? e.message : 'Could not draft this one — write the prompt yourself.')
start(fallback)
} finally {
setDrafting(false)
}
}
// ── Step 3: run ───────────────────────────────────────────────────────────
const [input, setInput] = useState('')
const [running, setRunning] = useState(false)
const [run, setRun] = useState<AgentRunResult | null>(null)
const [runError, setRunError] = useState<string | null>(null)
const doRun = async () => {
const text = input.trim()
if (!text || !created || !loaders.runAgent || running) return
setRunning(true)
setRunError(null)
setRun(null)
try {
setRun(await loaders.runAgent(created, text))
} catch (e) {
// A failed run answers 502 with the RUN as its body, so this message is the
// run's own reason — not a generic transport failure.
setRunError(e instanceof Error ? e.message : 'The run did not complete.')
} finally {
setRunning(false)
}
}
const snippet = useMemo(
() =>
[
`curl ${apiBase}/v1/agents/${created ?? 'your-agent'}/run \\`,
` -H "Authorization: Bearer $HANZO_API_KEY" \\`,
` -H "Content-Type: application/json" \\`,
` -d '{"input":"${(input.trim() || 'your message here').replace(/'/g, "'\\''").replace(/"/g, '\\"')}"}'`,
].join('\n'),
[apiBase, created, input],
)
return (
<YStack gap="$4">
<StepLadder current={step} onGo={setStep} />
{/* ── 1 · Describe ─────────────────────────────────────────────────── */}
{step === 'describe' ? (
<XStack gap="$4" items="flex-start" flexWrap="wrap">
<YStack flex={2} minW={320} gap="$3" py="$6">
<YStack gap="$2" items="center" py="$4">
<Text fontSize="$8" fontWeight="800" color="$color12" style={{ textAlign: 'center' }}>
What do you want to build?
</Text>
<Text fontSize="$3" color="$color11" style={{ textAlign: 'center' }}>
Describe your agent, or start from a template.
</Text>
</YStack>
<YStack
bg="$color2"
borderWidth={1}
borderColor="$borderColor"
rounded="$7"
px="$3"
py="$2.5"
gap="$2"
data-field-box
>
<XStack gap="$2" items="flex-end">
<TextArea
flex={1}
value={description}
onChangeText={setDescription}
placeholder="Describe your agent…"
numberOfLines={3}
disabled={drafting}
borderWidth={0}
bg="transparent"
px="$1"
py="$1"
aria-label="Describe your agent"
// Enter sends, Shift+Enter is a newline, and a key mid-IME-composition
// is never a send — an open candidate window must not submit the turn.
onKeyDown={(e) => {
const ev = e as unknown as {
key?: string
shiftKey?: boolean
preventDefault?: () => void
nativeEvent?: { isComposing?: boolean }
}
if (ev.key === 'Enter' && !ev.shiftKey && !ev.nativeEvent?.isComposing) {
ev.preventDefault?.()
void describe()
}
}}
/>
<Button
size="$2"
circular
theme="light"
disabled={!description.trim() || drafting}
onPress={() => void describe()}
icon={drafting ? undefined : <ArrowRight size={16} />}
aria-label="Draft this agent"
>
{drafting ? <Spinner size="small" /> : undefined}
</Button>
</XStack>
</YStack>
{!loaders.draftAgent ? (
<Note>
Drafting isnt connected here, so your words become the agents description and handle and you
write the prompt in the next step.
</Note>
) : null}
{draftError ? (
<Text fontSize="$2" color="$red10">
{draftError}
</Text>
) : null}
</YStack>
{/* Templates — a real gallery, searchable, each card a preset the builder
can already express. */}
<YStack flex={1} minW={280} gap="$2.5" p="$3" rounded="$5" borderWidth={1} borderColor="$borderColor">
<Text fontSize="$4" fontWeight="700" color="$color12">
Browse templates
</Text>
<XStack
items="center"
gap="$2"
px="$2.5"
height={34}
rounded="$3"
borderWidth={1}
borderColor="$borderColor"
bg="$color2"
data-field-box
>
<Search size={14} opacity={0.6} />
<Input
flex={1}
unstyled
value={query}
onChangeText={setQuery}
placeholder="Search templates"
fontSize="$3"
color="$color12"
autoCapitalize="none"
autoCorrect={false}
aria-label="Search templates"
/>
{query ? (
<Button size="$1" chromeless icon={<X size={13} />} onPress={() => setQuery('')} aria-label="Clear search" />
) : null}
</XStack>
<ScrollView maxH={520}>
<YStack gap="$2">
{templates.map((t) => (
<TemplateCard key={t.id} template={t} onPick={() => pickTemplate(t)} />
))}
{templates.length === 0 ? (
<Note>No template matches {query.trim()}. Describe it instead that always works.</Note>
) : null}
</YStack>
</ScrollView>
</YStack>
</XStack>
) : null}
{/* ── 2 · Configure ────────────────────────────────────────────────── */}
{step === 'configure' ? (
<YStack gap="$3" maxW={720}>
<AgentBuilder
key={seedKey}
loaders={loaders}
initial={seed}
onCancel={() => setStep('describe')}
onCreated={(name) => {
setCreated(name)
setStep('run')
onFinished?.(name)
}}
/>
</YStack>
) : null}
{/* ── 3 · Run ──────────────────────────────────────────────────────── */}
{step === 'run' && created ? (
<YStack gap="$3" maxW={720}>
<XStack items="center" gap="$2">
<Bot size={16} />
<Text fontSize="$5" fontWeight="800" color="$color12">
{created}
</Text>
<Text fontSize="$2" color="$color10">
is live
</Text>
</XStack>
<Text fontSize="$2" color="$color11">
Send it something. This runs the agent for real and bills the run to your organization.
</Text>
{loaders.runAgent ? (
<>
<YStack
bg="$color2"
borderWidth={1}
borderColor="$borderColor"
rounded="$5"
px="$3"
py="$2.5"
data-field-box
>
<XStack gap="$2" items="flex-end">
<TextArea
flex={1}
value={input}
onChangeText={setInput}
placeholder="Your message to the agent…"
numberOfLines={3}
disabled={running}
borderWidth={0}
bg="transparent"
px="$1"
py="$1"
aria-label="Message to the agent"
onKeyDown={(e) => {
const ev = e as unknown as {
key?: string
shiftKey?: boolean
preventDefault?: () => void
nativeEvent?: { isComposing?: boolean }
}
if (ev.key === 'Enter' && !ev.shiftKey && !ev.nativeEvent?.isComposing) {
ev.preventDefault?.()
void doRun()
}
}}
/>
<Button
size="$2"
theme="light"
disabled={!input.trim() || running}
onPress={() => void doRun()}
icon={running ? undefined : <Play size={15} />}
>
{running ? <Spinner size="small" /> : 'Run'}
</Button>
</XStack>
</YStack>
{runError ? (
<Card gap="$1.5" p="$3" rounded="$4" bg="$color2" borderWidth={1} borderColor="$borderColor">
<Text fontSize="$3" fontWeight="700" color="$red10">
The run failed
</Text>
<Text fontSize="$2" color="$color11">
{runError}
</Text>
</Card>
) : null}
{run ? (
<Card gap="$2" p="$3" rounded="$4" bg="$color2" borderWidth={1} borderColor="$borderColor">
<XStack items="center" gap="$2" flexWrap="wrap">
<Text fontSize="$2" fontWeight="700" color={run.status === 'ok' ? '$green10' : '$red10'}>
{run.status === 'ok' ? 'ok' : run.status || 'error'}
</Text>
{run.model ? (
<Text fontSize="$1" color="$color10">
{run.model}
</Text>
) : null}
{run.durationMs != null ? (
<Text fontSize="$1" color="$color10">
{run.durationMs} ms
</Text>
) : null}
</XStack>
<Text fontSize="$3" color="$color12">
{run.output || run.error || 'The run recorded no output.'}
</Text>
</Card>
) : null}
</>
) : (
<Card gap="$1.5" p="$3" rounded="$4" bg="$color2" borderWidth={1} borderColor="$borderColor">
<XStack items="center" gap="$2">
<Terminal size={14} />
<Text fontSize="$3" fontWeight="700" color="$color12">
Running from here isnt connected on this deployment
</Text>
</XStack>
<Text fontSize="$2" color="$color11">
The agent exists and `POST /v1/agents/{created}/run` is its endpoint the next step shows the
call.
</Text>
</Card>
)}
<XStack gap="$2">
<Button flex={1} theme="light" iconAfter={<ArrowRight size={15} />} onPress={() => setStep('integrate')}>
Integrate
</Button>
</XStack>
</YStack>
) : null}
{/* ── 4 · Integrate ────────────────────────────────────────────────── */}
{step === 'integrate' && created ? (
<YStack gap="$3" maxW={720}>
<Text fontSize="$5" fontWeight="800" color="$color12">
Call it from your code
</Text>
<Text fontSize="$2" color="$color11">
The same request the Run step just made. Mint a key under API keys and set it as `HANZO_API_KEY`.
</Text>
<YStack p="$3" rounded="$4" bg="$color2" borderWidth={1} borderColor="$borderColor">
<Text fontSize="$2" fontFamily="$mono" color="$color12" style={{ whiteSpace: 'pre-wrap' }}>
{snippet}
</Text>
</YStack>
<Note>
It answers with the recorded run its id, status, model, output and duration. A model failure comes
back as a run with `status: "error"` and the reason, never as silence.
</Note>
</YStack>
) : null}
</YStack>
)
}
+8
View File
@@ -9,10 +9,12 @@
* lifts cleanly into a published `@hanzo/agent-builder` package.
*/
export { AgentBuilder } from './AgentBuilder'
export { AgentQuickstart } from './Quickstart'
export type {
AgentSpec,
AgentConfig,
AgentCreateBody,
AgentRunResult,
ReasoningEffort,
AgentBuilderLoaders,
BuilderOption,
@@ -34,4 +36,10 @@ export {
promptBodyFromRow,
promptOptions,
classifyBuilderError,
draftInstruction,
parseDraft,
proposeName,
toHandle,
} from './logic'
export { AGENT_TEMPLATES, matchTemplate, searchTemplates, templateById, specFromTemplate } from './templates'
export type { AgentTemplate } from './templates'
+82 -5
View File
@@ -14,6 +14,9 @@ import {
promptBodyFromRow,
promptOptions,
classifyBuilderError,
proposeName,
toHandle,
parseDraft,
} from './logic'
import type { AgentConfig, AgentSpec, BuilderOption, BuilderPrompt } from './types'
@@ -32,13 +35,21 @@ describe('defaultModel', () => {
expect(defaultModel([])).toBe('')
})
it('prefers the exact zen-omni default when present', () => {
expect(defaultModel([opt('gpt-4o'), opt('zen-omni'), opt('claude')])).toBe('zen-omni')
it('prefers the exact zen5 default when present', () => {
expect(defaultModel([opt('gpt-4o'), opt('zen5'), opt('claude')])).toBe('zen5')
})
it('falls back to the first Zen-family model (prefix or provider hint)', () => {
expect(defaultModel([opt('gpt-4o'), opt('zen-coder')])).toBe('zen-coder')
expect(defaultModel([opt('gpt-4o'), opt('some-model', 'Zen')])).toBe('some-model')
it('falls back to another Zen TEXT model', () => {
expect(defaultModel([opt('gpt-4o'), opt('zen5-coder')])).toBe('zen5-coder')
})
// The defect this rule exists to prevent: a catalog arrives sorted, so a loose
// `^zen[-.]` test selected `zen-embedding` — an embeddings SKU that cannot hold a
// conversation — as the default model for every new agent.
it('never defaults to a modality SKU over a text model', () => {
const live = [opt('zen-embedding'), opt('zen-image'), opt('zen-rerank'), opt('zen-vl'), opt('zen5'), opt('zen5-mini')]
expect(defaultModel(live)).toBe('zen5')
expect(defaultModel(live.filter((o) => o.value !== 'zen5'))).toBe('zen5-mini')
})
it('falls back to the first catalog id when no Zen model exists', () => {
@@ -189,3 +200,69 @@ describe('classifyBuilderError', () => {
expect(classifyBuilderError('boom')).toEqual({ kind: 'error', message: 'Could not create the agent.' })
})
})
describe('proposeName', () => {
it('makes a handle out of the words a person actually typed', () => {
expect(proposeName('An agent that triages support tickets')).toBe('triages-support-tickets')
})
it('drops noise words and punctuation', () => {
expect(proposeName('The agent for our billing!! questions')).toBe('billing-questions')
})
it('is empty when there is nothing usable', () => {
expect(proposeName(' ')).toBe('')
expect(proposeName('a the it')).toBe('')
})
it('caps the length so the handle stays a handle', () => {
expect(proposeName('extraordinarily verbose descriptive nomenclature').length).toBeLessThanOrEqual(32)
})
})
describe('toHandle', () => {
it('reshapes without re-wording — a handle survives intact', () => {
expect(toHandle('support-agent')).toBe('support-agent')
expect(toHandle('Support Triage Bot!')).toBe('support-triage-bot')
})
it('collapses runs and trims the edges', () => {
expect(toHandle(' --a // b-- ')).toBe('a-b')
})
it('caps the length and never ends on a hyphen', () => {
const h = toHandle('extraordinarily verbose descriptive nomenclature here')
expect(h.length).toBeLessThanOrEqual(32)
expect(h.endsWith('-')).toBe(false)
})
})
describe('parseDraft', () => {
it('reads the three fields it asked for', () => {
const d = parseDraft('{"name":"support-triage","description":"Triages tickets.","systemPrompt":"You triage."}')
expect(d).toEqual({ name: 'support-triage', description: 'Triages tickets.', systemPrompt: 'You triage.' })
})
// The two things models actually do to JSON.
it('survives a code fence and surrounding prose', () => {
const answer = 'Sure! Here you go:\n```json\n{"name":"helper","systemPrompt":"You help."}\n```\nHope that works.'
expect(parseDraft(answer)).toEqual({ name: 'helper', systemPrompt: 'You help.' })
})
it('normalizes a handle the backend would refuse', () => {
expect(parseDraft('{"name":"Support Triage Bot!"}')?.name).toBe('support-triage-bot')
})
it('accepts the snake_case and bare spellings of the prompt', () => {
expect(parseDraft('{"system_prompt":"You help."}')?.systemPrompt).toBe('You help.')
expect(parseDraft('{"prompt":"You help."}')?.systemPrompt).toBe('You help.')
})
// A creative answer may only ever produce LESS than asked, never a field the
// builder cannot express.
it('drops every key it does not recognize', () => {
const d = parseDraft('{"name":"a-b","model":"gpt-9","tools":["rm -rf"],"webhook":"http://evil"}')
expect(d).toEqual({ name: 'a-b' })
})
it('is null when there is no object, or only empty fields', () => {
expect(parseDraft('I could not do that.')).toBeNull()
expect(parseDraft('{ not json }')).toBeNull()
expect(parseDraft('{"name":" ","description":""}')).toBeNull()
})
})
+121 -9
View File
@@ -23,23 +23,30 @@ export function defaultConfig(): AgentConfig {
return { temperature: 0.7, topP: 1, topK: 0, stream: true, thinking: false, useTools: true, webSearch: false }
}
/** The default Zen model to preselect when the catalog offers one. */
const ZEN_DEFAULT = 'zen-omni'
/** The Zen text model to preselect when the catalog offers it. */
const ZEN_DEFAULT = 'zen5'
/**
* Pick a sensible default model from a live catalog: the Zen default if present,
* else the first Zen (`hanzo`-owned / `zen-` prefixed) model, else the first
* catalog id, else '' (nothing to default to — the field stays empty/typeable).
* PURE. Never invents an id — only returns one the catalog actually lists.
* else another model from the Zen TEXT family, else the first catalog id, else ''
* (nothing to default to — the field stays empty/typeable). PURE. Never invents an
* id — only returns one the catalog actually lists.
*
* The text-family test is `zen5…`, and that specificity is load-bearing. Zen's naming
* splits cleanly: `zen5`, `zen5-mini`, `zen5-flash`, `zen5-coder`, `zen5-pro` are the
* text models, while `zen-<noun>` names a MODALITY — zen-embedding, zen-image,
* zen-video, zen-rerank, zen-voice, zen-vl. A looser `^zen[-.]` test matched both, and
* since a catalog arrives sorted it selected `zen-embedding`: every agent created
* without touching the model field was pointed at an embeddings SKU that cannot hold a
* conversation. (It went unnoticed because the exact-match arm named `zen-omni`, which
* the live catalog does not carry, so the fallback was always the arm that ran.)
*/
export function defaultModel(options: BuilderOption[]): string {
if (options.length === 0) return ''
const exact = options.find((o) => o.value === ZEN_DEFAULT)
if (exact) return exact.value
const zen = options.find(
(o) => /^zen[-.]/i.test(o.value) || (o.hint ?? '').toLowerCase().includes('zen'),
)
return (zen ?? options[0]).value
const zenText = options.find((o) => /^zen\d/i.test(o.value))
return (zenText ?? options[0]).value
}
/** True iff the spec can be submitted (a non-empty trimmed name is the only requirement). */
@@ -150,6 +157,111 @@ export function promptOptions(prompts: BuilderPrompt[]): BuilderOption[] {
return prompts.map((p) => ({ value: p.name, label: p.label ?? p.name, hint: p.hint }))
}
// ── Drafting an agent from a sentence ───────────────────────────────────────
//
// The quickstart lets someone describe an agent in their own words. That is a
// model call, so the EFFECT is an injected loader (`draftAgent`) like every other;
// what lives here is the pure half — the instruction we send, and the parse of what
// comes back. Both are pure so the fragile part (reading a model's JSON) is tested
// against real malformed answers rather than trusted.
/**
* The instruction that turns a description into a spec. It asks for the three
* fields a person would otherwise type and NOTHING else — deliberately not `model`
* or `tools`: a model id must exist in the org's live catalog and a tool must exist
* in its tool plane, and a model asked to name one will happily invent it. Those two
* fields stay with the pickers that know the real answers. PURE.
*/
export function draftInstruction(): string {
return [
'You turn a description of an agent into its definition.',
'',
'Reply with ONE JSON object and nothing else — no prose, no code fence. Keys:',
' "name" a short lowercase handle, words joined by hyphens (e.g. support-triage)',
' "description" one sentence on what the agent does',
' "systemPrompt" the agent\'s own instructions, written in the second person',
'',
'The system prompt is the real work: state what the agent does, what it must not do,',
'and how it should behave when it is unsure. Write it as instructions to the agent,',
'not as a description of it.',
].join('\n')
}
/** Stop-words that carry no meaning in a handle. */
const NOISE = new Set(['a', 'an', 'the', 'that', 'this', 'my', 'our', 'for', 'to', 'of', 'and', 'is', 'it', 'agent'])
/**
* Put any string into handle FORM: lowercase, letters and digits kept, everything
* else a hyphen, no repeated or trailing hyphens, capped. It reshapes and never
* re-words — `support-agent` stays `support-agent`. PURE.
*/
export function toHandle(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 32)
.replace(/-+$/, '')
}
/**
* A handle proposed from PROSE — the user's own sentence — so the form is never left
* with an empty required field even when the draft call fails. Drops the words that
* carry no meaning in a handle, keeps the first three that do, and puts the result in
* handle form. Returns '' when the text carries nothing usable.
*
* Distinct from `toHandle` on purpose, and the two must not be confused: this one
* REWORDS, which is right for a sentence and wrong for a handle. Running it over an
* already-formed handle silently renames it — `support-agent` would come back as
* `support`, because "agent" is noise in a sentence and load-bearing in a name. PURE.
*/
export function proposeName(description: string): string {
const words = description
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, ' ')
.split(/[\s-]+/)
.filter((w) => w.length > 1 && !NOISE.has(w))
return toHandle(words.slice(0, 3).join('-'))
}
/**
* Read a drafted spec out of a model's answer. Tolerant of the two things models
* actually do — wrapping the object in a ```json fence, and adding a sentence before
* or after it — by taking the outermost braces. Every field is validated and
* anything unrecognized is DROPPED, so a creative answer can only ever produce less
* than asked, never a field the builder does not understand. Returns null when there
* is no object at all. PURE.
*/
export function parseDraft(answer: string): Partial<AgentSpec> | null {
const start = answer.indexOf('{')
const end = answer.lastIndexOf('}')
if (start < 0 || end <= start) return null
let raw: unknown
try {
raw = JSON.parse(answer.slice(start, end + 1))
} catch {
return null
}
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
const r = raw as Record<string, unknown>
const text = (v: unknown): string | undefined => (typeof v === 'string' && v.trim() ? v.trim() : undefined)
const out: Partial<AgentSpec> = {}
const name = text(r.name)
// A handle the backend would refuse is worse than none, so reshape it — but with
// `toHandle`, which only changes the FORM. `proposeName` would also re-word it, and
// the model was asked for a handle, not a sentence.
if (name) {
const handle = toHandle(name)
if (handle) out.name = handle
}
const description = text(r.description)
if (description) out.description = description
const systemPrompt = text(r.systemPrompt) ?? text(r.system_prompt) ?? text(r.prompt)
if (systemPrompt) out.systemPrompt = systemPrompt
return Object.keys(out).length ? out : null
}
/**
* Classify a create failure. A 404 (or an explicit "unavailable" BackendState kind)
* means the `/v1/agents` route isn't bound on this deployment — an honest "not
@@ -0,0 +1,110 @@
import { describe, it, expect } from 'vitest'
import { AGENT_TEMPLATES, matchTemplate, searchTemplates, specFromTemplate, templateById } from './templates'
import { defaultConfig, emptySpec, toCreateBody } from './logic'
describe('AGENT_TEMPLATES', () => {
it('has unique ids and a handle for every entry', () => {
const ids = AGENT_TEMPLATES.map((t) => t.id)
expect(new Set(ids).size).toBe(ids.length)
for (const t of AGENT_TEMPLATES) {
expect(t.name.trim()).not.toBe('')
expect(t.title.trim()).not.toBe('')
expect(t.summary.trim()).not.toBe('')
}
})
it('leads with the blank one — starting from nothing is the honest default', () => {
expect(AGENT_TEMPLATES[0].id).toBe('blank')
expect(AGENT_TEMPLATES[0].systemPrompt).toBe('')
})
// The whole point of the module doc: a template is a preset, never a promise. It
// may only carry fields the create body can already express, so picking one can
// never produce an agent the builder itself could not.
it('carries nothing the create body cannot express', () => {
const allowed = new Set(['id', 'title', 'summary', 'name', 'systemPrompt', 'config'])
for (const t of AGENT_TEMPLATES) {
for (const key of Object.keys(t)) expect(allowed.has(key)).toBe(true)
}
})
// A hardcoded tool id would name something the org may not have activated, and it
// would fail at the agent's FIRST invocation rather than here. Tools come from the
// live tool plane or not at all.
it('names no tools — those come from the live tool plane', () => {
for (const t of AGENT_TEMPLATES) expect(t).not.toHaveProperty('tools')
})
it('every template produces a submittable body', () => {
for (const t of AGENT_TEMPLATES) {
const body = toCreateBody(specFromTemplate(t, emptySpec(), defaultConfig()))
expect(body.name).toBe(t.name)
expect(body.description).toBe(t.summary)
}
})
})
describe('matchTemplate / searchTemplates', () => {
const t = AGENT_TEMPLATES.find((x) => x.id === 'researcher')!
it('an empty query matches everything', () => {
expect(matchTemplate(t, '')).toBe(true)
expect(matchTemplate(t, ' ')).toBe(true)
expect(searchTemplates('')).toHaveLength(AGENT_TEMPLATES.length)
})
it('matches title, summary and id, case-insensitively', () => {
expect(matchTemplate(t, 'DEEP')).toBe(true)
expect(matchTemplate(t, 'sources')).toBe(true)
expect(matchTemplate(t, 'researcher')).toBe(true)
})
it('returns nothing for a query nothing carries', () => {
expect(searchTemplates('quantum tuba')).toEqual([])
})
it('keeps gallery order', () => {
const found = searchTemplates('a').map((x) => x.id)
expect(found).toEqual(AGENT_TEMPLATES.filter((x) => matchTemplate(x, 'a')).map((x) => x.id))
})
})
describe('templateById', () => {
it('finds one, and is null for an unknown id', () => {
expect(templateById('blank')?.title).toBe('Blank agent')
expect(templateById('nope')).toBeNull()
})
})
describe('specFromTemplate', () => {
const t = AGENT_TEMPLATES.find((x) => x.id === 'extractor')!
it('fills name, description and prompt from the template', () => {
const s = specFromTemplate(t, emptySpec(), defaultConfig())
expect(s.name).toBe(t.name)
expect(s.description).toBe(t.summary)
expect(s.systemPrompt).toBe(t.systemPrompt)
})
// The template owns the agent's character; the MODEL is the org's own decision and
// its tool list is the org's too, so neither is overwritten by picking one.
it('keeps a model and tools the user already chose', () => {
const current = { ...emptySpec(), model: 'zen5-pro', tools: ['already.picked'] }
const s = specFromTemplate(t, current, defaultConfig())
expect(s.model).toBe('zen5-pro')
expect(s.tools).toEqual(['already.picked'])
})
it('merges the template config over the defaults, leaving the rest alone', () => {
const s = specFromTemplate(t, emptySpec(), defaultConfig())
expect(s.config?.temperature).toBe(0)
expect(s.config?.stream).toBe(defaultConfig().stream)
})
it('posts no config for a template that needs none', () => {
const blank = templateById('blank')!
expect(specFromTemplate(blank, emptySpec(), defaultConfig()).config).toBeUndefined()
expect(toCreateBody(specFromTemplate(blank, emptySpec(), defaultConfig()))).not.toHaveProperty('config')
})
})
+175
View File
@@ -0,0 +1,175 @@
/**
* Agent templates — starting points for the ONE builder, shared by every surface.
*
* A template is a PRESET, never a promise: every field it carries maps to something
* `POST /v1/agents` already accepts (`name`, `description`, `systemPrompt`, and the
* `config` knobs in `AgentConfig`). Picking one fills the builder and nothing else
* happens — the user still sees, edits and submits the same form, so a template can
* never create an agent the builder itself could not.
*
* Deliberately NO tool ids. Tools come from the live tool plane (`GET /v1/tools`),
* which knows what an org has actually activated; a hardcoded `web.search` here would
* name something that may not exist and would fail on the agent's first invocation.
* What a template CAN say about tools is the truth: `useTools` and `webSearch` are
* real switches in the agent contract, so a template that needs them turns them on
* and the builder's live tool picker fills in the specifics.
*
* Pure data + pure helpers — no React, no I/O — so this lifts into
* `@hanzo/agent-builder` with the rest of the module.
*/
import type { AgentConfig, AgentSpec } from './types'
/** A named starting point: what it is, and the spec it fills the builder with. */
export type AgentTemplate = {
/** Stable id — the URL/search key. */
id: string
/** What it is called in the gallery. */
title: string
/** One line on what the agent does. Shown on the card and searched. */
summary: string
/** The seed handle; the user renames freely before submitting. */
name: string
/** The system prompt this template starts from ('' for the blank one). */
systemPrompt: string
/** Only the knobs this template genuinely needs; the rest stay at their defaults. */
config?: Partial<AgentConfig>
}
/**
* The gallery, in display order. `blank` leads because starting from nothing is the
* honest default — everything after it is a real, specific job.
*/
export const AGENT_TEMPLATES: readonly AgentTemplate[] = [
{
id: 'blank',
title: 'Blank agent',
summary: 'A starting point with nothing assumed — name it, pick a model, write the prompt.',
name: 'my-agent',
systemPrompt: '',
},
{
id: 'researcher',
title: 'Deep researcher',
summary: 'Researches a question across the web and answers with the sources it used.',
name: 'researcher',
systemPrompt:
'You research questions and report what you found.\n\n' +
'Work in steps: decide what you need to know, search for it, read the results, and only then answer. ' +
'Prefer primary sources over summaries of them.\n\n' +
'Every claim that came from a source carries that source. When sources disagree, say so and give both. ' +
'When you could not find something, say that plainly instead of filling the gap — an honest gap is more ' +
'useful than a confident guess.',
config: { webSearch: true, thinking: true, reasoningEffort: 'high' },
},
{
id: 'extractor',
title: 'Structured extractor',
summary: 'Reads unstructured text and returns one typed JSON object, or says which fields were absent.',
name: 'extractor',
systemPrompt:
'You turn unstructured text into one JSON object matching the schema the caller gives you.\n\n' +
'Return the object and nothing else — no prose, no code fence, no explanation.\n\n' +
'Copy values from the text; never infer one that is not there. A field the text does not support is null, ' +
'and a guessed value is a defect. If the schema is ambiguous about a field, choose the reading that the ' +
'text supports literally.',
config: { temperature: 0, topP: 1 },
},
{
id: 'support',
title: 'Support answerer',
summary: 'Answers product questions from your own material, and escalates the ones it cannot.',
name: 'support',
systemPrompt:
'You answer product questions for customers, using the material available to you.\n\n' +
'Answer from that material only. When it does not cover the question, say so and hand off rather than ' +
'improvising — a wrong answer costs more than a slow one.\n\n' +
'Lead with the answer, then the steps. Keep it short enough to act on. Never promise a behaviour, a date ' +
'or a refund you cannot point to in the material.',
config: { useTools: true, temperature: 0.3 },
},
{
id: 'reviewer',
title: 'Code reviewer',
summary: 'Reads a diff and reports what will actually break, most severe first.',
name: 'reviewer',
systemPrompt:
'You review code changes.\n\n' +
'Report only defects you can name concretely: the input or state that triggers them, and the wrong output ' +
'or crash that results. Correctness and security first, then clarity. Rank by severity.\n\n' +
'Style preferences are not findings. Neither is a concern you cannot demonstrate — if you are unsure a ' +
'thing is real, say you are unsure rather than listing it as a defect. Finding nothing is a valid review.',
config: { thinking: true, reasoningEffort: 'high', temperature: 0.2 },
},
{
id: 'analyst',
title: 'Data analyst',
summary: 'Explains a dataset — what is in it, what stands out, and what to check next.',
name: 'analyst',
systemPrompt:
'You explain datasets to people who have to make a decision from them.\n\n' +
'Start with the shape: how many rows, which columns, what period, and what is missing. Then the two or ' +
'three things that genuinely stand out. Then what you would check next and why.\n\n' +
'Every number you state comes from the data. Distinguish what the data shows from what you suspect, and ' +
'name the limits — a sample too small to conclude from is the finding, not an obstacle to one.',
config: { useTools: true, temperature: 0.2 },
},
{
id: 'summarizer',
title: 'Meeting summarizer',
summary: 'Turns a transcript into decisions, owners and the questions still open.',
name: 'summarizer',
systemPrompt:
'You turn meeting transcripts into something the people who missed it can act on.\n\n' +
'Three sections: decisions made, actions with their owner, and questions left open. Nothing else.\n\n' +
'Only record a decision that was actually reached — a topic discussed without resolution belongs under ' +
'open questions. Attribute an action to a person only when the transcript names them; otherwise leave the ' +
'owner unassigned and say so.',
config: { temperature: 0.2 },
},
{
id: 'triage',
title: 'Incident triager',
summary: 'Classifies an incoming report by severity and area, and drafts the first reply.',
name: 'triage',
systemPrompt:
'You triage incoming incident reports.\n\n' +
'For each one give: severity, the area it belongs to, what is affected, and a first reply to the reporter.\n\n' +
'Severity follows blast radius, not tone — a calm report of data loss outranks an urgent one about a ' +
'typo. When the report lacks what you need to classify it, the first reply asks for exactly that and the ' +
'severity stays provisional. Never guess an area to avoid leaving one blank.',
config: { temperature: 0.2, reasoningEffort: 'medium' },
},
]
/** Case-insensitive, whitespace-tolerant match over the fields a person would type. */
export function matchTemplate(t: AgentTemplate, query: string): boolean {
const q = query.trim().toLowerCase()
if (!q) return true
return `${t.title} ${t.summary} ${t.id}`.toLowerCase().includes(q)
}
/** The templates matching a query, in gallery order. */
export function searchTemplates(query: string, templates: readonly AgentTemplate[] = AGENT_TEMPLATES): AgentTemplate[] {
return templates.filter((t) => matchTemplate(t, query))
}
/** The template with this id, or null. */
export function templateById(id: string, templates: readonly AgentTemplate[] = AGENT_TEMPLATES): AgentTemplate | null {
return templates.find((t) => t.id === id) ?? null
}
/**
* The builder state a template starts from. Merged over the CURRENT spec so a model
* the user already chose survives picking a template — the template owns the prompt
* and the character of the agent, never the model, which is the org's own decision.
*/
export function specFromTemplate(t: AgentTemplate, current: AgentSpec, defaults: AgentConfig): AgentSpec {
return {
...current,
name: t.name,
description: t.summary,
systemPrompt: t.systemPrompt,
tools: current.tools,
config: t.config ? { ...defaults, ...t.config } : undefined,
}
}
+33
View File
@@ -138,6 +138,24 @@ export type AgentBuilderLoaders = {
loadPromptBody?: (name: string) => Promise<string>
/** The live tool catalog. Rejects → typeable-only tools. */
loadTools?: () => Promise<BuilderOption[]>
/**
* Draft a spec from a plain-English description — the quickstart's "describe your
* agent" box. A model call, so it is an effect like the rest; the instruction and
* the parse of the answer are pure (`draftInstruction`, `parseDraft`) and shared.
* Absent → the quickstart still works: the description seeds the handle and the
* description field, and the user writes the prompt. Rejects → the same fallback,
* with the reason shown, so a drafting failure never blocks building an agent.
*/
draftAgent?: (description: string) => Promise<Partial<AgentSpec>>
/**
* Run the agent once (`POST /v1/agents/:ref/run`) and return the RECORDED run.
* The quickstart's third step — proving the thing that was just created actually
* answers, which is the only step that can prove it. Absent → the step says so and
* points at the endpoint instead of pretending. THIS SPENDS: the backend authorizes
* the org's balance before any inference, so an unfunded org is refused rather than
* given free compute.
*/
runAgent?: (name: string, input: string) => Promise<AgentRunResult>
/**
* Create the agent from the pruned body (`toCreateBody(spec)`). This is the ONE
* mutation — it MUST target the unified agent backend (`POST /v1/agents`), which
@@ -147,6 +165,21 @@ export type AgentBuilderLoaders = {
createAgent: (body: AgentCreateBody) => Promise<unknown>
}
/**
* One recorded run, as the quickstart needs it. Deliberately the small half of the
* backend's run view: what happened, which model did it, and what came out. A
* `status` other than `ok` is a run that REALLY failed — the backend records the
* failure as a run rather than hiding it — so `error` is a fact about the execution,
* not a transport problem to guess at.
*/
export type AgentRunResult = {
status: string
model?: string
output?: string
error?: string
durationMs?: number
}
/** The reason a create failed, distinguished so the UI reacts correctly. */
export type BuilderErrorKind =
/** The `/v1/agents` route isn't bound on this deployment yet (404). */
+42 -28
View File
@@ -14,12 +14,13 @@
* facade (`AgentsApi.metrics`); until that route is bound they show a truthful "not
* connected" note, never a placeholder trend. When the org has ZERO agents (or the
* `/v1/agents` route isn't bound yet) the board is replaced by a polished
* "create your first agent" empty state with the real New-Agent flow — never the
* mockup's sample data.
* "create your first agent" empty state that opens the QUICKSTART — the one way to
* create an agent here — never the mockup's sample data.
*
* Style props use the @hanzo/gui v5 shorthand set (bg/p/px/py/gap/rounded/items/…).
*/
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { Button, Card, Input, Spinner, Text, XStack, YStack } from '@hanzo/gui'
import { useAnalytics } from '@hanzo/event/react'
import { EVENTS } from '@hanzo/event'
@@ -77,7 +78,9 @@ import {
TopAgents,
VersionBadge,
} from './agents/parts'
import { AgentDetailView, NewAgentForm } from './agents/forms'
import { AgentDetailView } from './agents/forms'
import { agentBuilderLoaders } from './agents/loaders'
import { AgentQuickstart } from '~/components/agent-builder'
import { BackendStateCard, DataTable, EmptyState, PageHeader, classifyBackend, type BackendState, type Column } from '@hanzo/ui/product'
const PAGE_SIZE = 8
@@ -134,6 +137,7 @@ function useAgents() {
}
export function AgentsModule(props: { params: Record<string, string> }) {
const router = useRouter()
const detail = useDetailPane()
const { agents, loading, error, live, activity, reload, setAgents } = useAgents()
@@ -226,26 +230,11 @@ export function AgentsModule(props: { params: Record<string, string> }) {
const analytics = useAnalytics()
const openNew = useCallback(
() =>
detail.open({
title: 'New agent',
subtitle: 'Define a model, prompt, and tools',
icon: Bot,
iconColor: agentColor,
content: (
<NewAgentForm
onCancel={detail.close}
onCreated={() => {
analytics.capture(EVENTS.AGENT_CREATED)
detail.close()
void reload()
}}
/>
),
}),
[detail, agentColor, reload, analytics],
)
// ONE way to create an agent, and it is the quickstart. This used to open the
// builder in a side pane — the same component, reached by a different shape, with
// no templates, no drafting and nowhere to run what it made. Two entrances to one
// builder is two things to keep in step; the pane was the lesser of them.
const openNew = useCallback(() => router.push('/agents/quickstart'), [router])
const header = (
<PageHeader
@@ -279,6 +268,36 @@ export function AgentsModule(props: { params: Record<string, string> }) {
/>
)
// Owned sub-pages: Status/Logs/Metrics render focused slices of the agents' OWN
// runs (from /v1/agents), so they are never the empty generic o11y/ledger subpage.
// Overview ('') shows everything. Metrics = counts + invocation trend + resource;
// Status = health donut + agents table; Logs = the invocation activity feed.
const routeTab = props.params?.tab ?? ''
// ── Quickstart ──────────────────────────────────────────────────────────────
// Its own surface, and it answers BEFORE the list's loading/error/empty states on
// purpose: building an agent does not depend on reading the ones that exist, and
// the moments you most need it — no agents yet, or the registry not answering —
// are exactly the ones those early returns would have swallowed it in.
if (routeTab === 'quickstart') {
return (
<>
<PageHeader
title="Build an agent"
subtitle="Describe what you want, or start from a template. Four steps, and every one is a real call."
/>
<AgentQuickstart
loaders={agentBuilderLoaders}
apiBase={config.apiUrl}
onFinished={() => {
analytics.capture(EVENTS.AGENT_CREATED)
void reload()
}}
/>
</>
)
}
// ── Initial loading ─────────────────────────────────────────────────────────
if (loading && agents.length === 0 && !error) {
return (
@@ -397,11 +416,6 @@ export function AgentsModule(props: { params: Record<string, string> }) {
const tabs: StatusTab[] = ['all', ...AGENT_STATUSES]
const tabCount = (t: StatusTab): number => (t === 'all' ? agents.length : health[t])
// Owned sub-pages: Status/Logs/Metrics render focused slices of the agents' OWN
// runs (from /v1/agents), so they are never the empty generic o11y/ledger subpage.
// Overview ('') shows everything. Metrics = counts + invocation trend + resource;
// Status = health donut + agents table; Logs = the invocation activity feed.
const routeTab = props.params?.tab ?? ''
const showMetrics = routeTab === '' || routeTab === 'metrics'
const showStatus = routeTab === '' || routeTab === 'status'
const showLogs = routeTab === '' || routeTab === 'logs'
+2 -2
View File
@@ -59,8 +59,8 @@ const CHANNELS: Channel[] = [
icon: MessageCircle,
title: 'Join Our Discord',
body: 'Chat live with developers and other users from our community.',
href: 'https://discord.gg/hanzo',
cta: 'discord.gg/hanzo',
href: 'https://discord.gg/CJCyAsm9Vr',
cta: 'discord.gg/CJCyAsm9Vr',
},
{
icon: Linkedin,
+63 -3
View File
@@ -13,7 +13,7 @@
*/
import { SubNav } from '~/components/ui/SubNav'
import { productSubpageSlug } from '~/lib/products/match'
import { useCallback, useEffect, useState, type ReactNode } from 'react'
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
import { useRouter } from 'next/navigation'
import { Button, Card, Spinner, Text, XStack, YStack } from '@hanzo/gui'
import { Check, ExternalLink, Lock, Users } from '@hanzogui/lucide-icons-2'
@@ -27,6 +27,37 @@ import { useIsSuperAdmin } from '~/lib/auth/admin'
import { ErrorState, asApiError, type HonestCopy } from '~/components/ui/States'
import { FieldRow, FieldSwitch, FieldText, PageHeader } from '@hanzo/ui/product'
/**
* Read a chosen logo file into a compact data URL the IAM `logo` string can
* carry. An SVG passes through verbatim (it is already small and scales);
* a raster is downscaled to 64px tall on a canvas — twice the largest render
* (the 28px settings preview, the 22px switcher row) — so a 4MB photo becomes
* a few KB. The cap refuses anything that still encodes large, because a
* megabyte logo would ride EVERY IAM org read from then on.
*/
const LOGO_DATA_CAP = 140 * 1024
async function fileToLogoDataUrl(file: File): Promise<string> {
if (file.type === 'image/svg+xml') {
const text = await file.text()
const url = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(text)))}`
if (url.length > LOGO_DATA_CAP) throw new Error('That SVG is too large for a logo — simplify it or host it and paste the URL')
return url
}
const bitmap = await createImageBitmap(file)
const h = Math.min(64, bitmap.height)
const w = Math.round((bitmap.width / bitmap.height) * h)
const canvas = document.createElement('canvas')
canvas.width = w
canvas.height = h
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('Could not read the image')
ctx.drawImage(bitmap, 0, 0, w, h)
const url = canvas.toDataURL('image/png')
if (url.length > LOGO_DATA_CAP) throw new Error('That image is too complex for a logo — use a simpler mark or host it and paste the URL')
return url
}
const IAM_COPY: HonestCopy = {
notFound:
'IAM (/org/iam) is not routed on this host yet. It appears automatically once the deployment proxies it to Hanzo IAM.',
@@ -191,6 +222,7 @@ function BrandingForm({ org, canEdit, onSaved }: { org: Organization; canEdit: b
const [displayName, setDisplayName] = useState(org.displayName ?? '')
const [websiteUrl, setWebsiteUrl] = useState(org.websiteUrl ?? '')
const [logo, setLogo] = useState(org.logo ?? '')
const logoFileRef = useRef<HTMLInputElement>(null)
const [favicon, setFavicon] = useState(org.favicon ?? '')
const [colorPrimary, setColorPrimary] = useState(org.themeData?.colorPrimary ?? '')
const [themeEnabled, setThemeEnabled] = useState(!!org.themeData?.isEnabled)
@@ -252,9 +284,37 @@ function BrandingForm({ org, canEdit, onSaved }: { org: Organization; canEdit: b
<FieldRow label="Website">
<FieldText value={websiteUrl} onChange={(v) => onEdit(() => setWebsiteUrl(v))} disabled={ro} placeholder="https://…" />
</FieldRow>
<FieldRow label="Logo URL">
<FieldRow label="Logo">
<YStack gap="$2">
<FieldText value={logo} onChange={(v) => onEdit(() => setLogo(v))} disabled={ro} placeholder="https://…/logo.svg" />
<XStack gap="$2" items="center" flexWrap="wrap">
<YStack flex={1} minW={220}>
<FieldText value={logo} onChange={(v) => onEdit(() => setLogo(v))} disabled={ro} placeholder="https://…/logo.svg" />
</YStack>
{/* Upload: the file becomes a compact data URL in the SAME field,
so one value, one save path, one preview serve it either way. */}
<Button
size="$2"
disabled={ro}
onPress={() => logoFileRef.current?.click()}
aria-label="Upload a logo image"
>
Upload
</Button>
<input
ref={logoFileRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/svg+xml"
style={{ display: 'none' }}
onChange={(e) => {
const f = e.target.files?.[0]
e.target.value = ''
if (!f) return
fileToLogoDataUrl(f)
.then((url) => onEdit(() => setLogo(url)))
.catch((err) => setSave({ phase: 'error', err: asApiError(err) }))
}}
/>
</XStack>
{logo.trim() ? (
// Arbitrary external org logo URL — raw <img> (next/image would need a
// per-tenant remote allow-list). Matches BrandLogo's own preview.
+10 -20
View File
@@ -1,16 +1,17 @@
'use client'
/**
* Agents — the New-Agent form and the per-agent detail view, both rendered inside
* the shared right-side `DetailPane`.
* Agents — the per-agent detail view, rendered inside the shared right-side
* `DetailPane`. It shows the agent's REAL facts, its REAL cost from the charged
* commerce ledger, and a best-effort activity feed from `GET /v1/agents/:name`;
* never fabricated telemetry.
*
* The New-Agent form is now a THIN adapter over the CANONICAL, shareable
* `AgentBuilder` (`~/components/agent-builder`) — the ONE agent builder across every
* Hanzo surface. console2 supplies its live `/v1` sources via `agentBuilderLoaders`
* (model catalog → `/v1/models`, saved prompts → `/v1/prompts`, create →
* `/v1/agents`); the builder owns the form, the LIVE model + prompt dropdowns, and
* the honest states. The detail view renders the agent's REAL facts (+ best-effort
* recent activity from `GET /v1/agents/:name`), never fabricated telemetry.
* CREATING an agent does not live here. It used to — a thin `NewAgentForm` adapter
* that opened the canonical `AgentBuilder` in this same pane — and that made two
* differently-shaped entrances to one builder, only one of which could offer
* templates, drafting, or anywhere to run what it made. The quickstart
* (`/agents/quickstart`) is the one way now, and the board's New-Agent button goes
* there.
*/
import { useEffect, useState } from 'react'
import { Spinner, Text, XStack, YStack } from '@hanzo/gui'
@@ -29,7 +30,6 @@ import {
} from '~/lib/api/agents'
import { fetchUsageRecords, agentUsageFor, type AgentUsage } from '~/lib/api/aimetrics'
import { AgentBuilder } from '~/components/agent-builder'
import { agentBuilderLoaders } from './loaders'
import { StatusPill, ActivityFeed } from './parts'
const DASH = '—'
@@ -48,16 +48,6 @@ function Fact({ label, value }: { label: string; value: string }) {
)
}
/**
* The New-Agent form — the canonical `AgentBuilder` wired to console2's live `/v1`
* sources. On a successful `POST /v1/agents` it calls `onCreated` (the board
* reloads + the pane closes); a 404/unavailable backend degrades to the builder's
* own honest "not connected — create with the CLI" note.
*/
export function NewAgentForm({ onCreated, onCancel }: { onCreated: () => void; onCancel: () => void }) {
return <AgentBuilder loaders={agentBuilderLoaders} onCreated={onCreated} onCancel={onCancel} />
}
/**
* The per-agent detail view — REAL facts from the row, the agent's REAL cost from
* the charged commerce ledger (grouped by `metadata.agent`, NOT a hardcoded/registry
+52 -4
View File
@@ -12,7 +12,9 @@
*/
import { PlaygroundApi, PromptsApi } from '~/lib/api'
import { AgentsApi } from '~/lib/api/agents'
import type { AgentBuilderLoaders, BuilderOption, BuilderPrompt } from '~/components/agent-builder/types'
import { ToolsApi } from '~/lib/api/tools'
import { defaultModel, draftInstruction, parseDraft } from '~/components/agent-builder/logic'
import type { AgentBuilderLoaders, AgentSpec, BuilderOption, BuilderPrompt } from '~/components/agent-builder/types'
/** The live model catalog as builder options (id → {value,label,hint}). */
async function loadModels(): Promise<BuilderOption[]> {
@@ -20,6 +22,52 @@ async function loadModels(): Promise<BuilderOption[]> {
return ids.map((id) => ({ value: id, label: id }))
}
/**
* The org's REAL callable tools (`GET /v1/tools`) as builder options. The plane spans
* every source — connector actions, functions, zap-service routes, agents, skills and
* the org's own MCP servers — already deduplicated by name.
*
* A tool that lists but is NOT activated says so in its hint, because listing it
* silently would offer a name that resolves and then refuses at invocation time. An
* org with nothing activated gets an empty list, which is a real answer: the field
* stays typeable and the user is not shown a tool that does not exist.
*/
async function loadTools(): Promise<BuilderOption[]> {
const tools = await ToolsApi.list()
return tools.map((t) => ({
value: t.name,
label: t.name,
hint: [t.source, t.description, t.activated ? undefined : 'not activated'].filter(Boolean).join(' · ') || undefined,
}))
}
/**
* Draft a spec from a plain-English description — one real completion through the
* gateway, with the shared instruction and the shared parse (both pure, both tested).
*
* Deliberately cheap and deterministic: this is a formatting job, not a reasoning one.
* `parseDraft` drops anything it does not recognize, so a creative answer can only
* ever fill FEWER fields than asked — never inject one the builder cannot express.
*/
async function draftAgent(description: string): Promise<Partial<AgentSpec>> {
// The model comes from the LIVE catalog through the same `defaultModel` rule the
// builder's own picker uses — never a literal id here. A hardcoded one drifts
// silently the moment the catalog changes, which is exactly how `zen-omni` came to
// be named in a rule that could no longer match anything.
const model = defaultModel(await loadModels())
if (!model) throw new Error('No model is available to draft with — pick one and write the prompt yourself.')
const completion = await PlaygroundApi.chat({
model,
messages: [
{ role: 'system', content: draftInstruction() },
{ role: 'user', content: description },
],
temperature: 0.2,
})
const answer = completion?.choices?.[0]?.message?.content ?? ''
return parseDraft(answer) ?? {}
}
/** The org's saved prompts as builder rows (names; bodies fetched lazily on select). */
async function loadPrompts(): Promise<BuilderPrompt[]> {
const prompts = await PromptsApi.list()
@@ -72,8 +120,8 @@ export const agentBuilderLoaders: AgentBuilderLoaders = {
loadModels,
loadPrompts,
loadPromptBody,
// No live tool catalog endpoint on this deployment yet — the builder's tools
// field stays typeable-only (honest), never a fabricated tool list. Wire
// `loadTools` here when `/v1/agents/tools` (or the MCP tool catalog) is bound.
loadTools,
draftAgent,
createAgent: (body) => AgentsApi.create(body),
runAgent: (name, input) => AgentsApi.run(name, input),
}
@@ -107,7 +107,7 @@ function NeedHelpCard() {
return (
<SectionCard title="Need help?" p="$3">
<YStack gap="$0.5">
<ActionRow icon={<MessageSquare size={15} />} label="Join Discord" onPress={() => openExternal('https://discord.gg/hanzo')} />
<ActionRow icon={<MessageSquare size={15} />} label="Join Discord" onPress={() => openExternal('https://discord.gg/CJCyAsm9Vr')} />
<ActionRow icon={<LifeBuoy size={15} />} label="Contact Support" onPress={() => openExternal(`mailto:support@${apex}`)} />
</YStack>
</SectionCard>
@@ -54,7 +54,7 @@ export function ResourceRail({ config, onViewCode }: { config: ProductLandingCon
<LandingCard title="Need help?" p="$3">
<YStack gap="$0.5">
<ActionRow icon={<MessageSquare size={15} />} label="Community" sub="Join the Discord" onPress={() => openExternal('https://discord.gg/hanzo')} />
<ActionRow icon={<MessageSquare size={15} />} label="Community" sub="Join the Discord" onPress={() => openExternal('https://discord.gg/CJCyAsm9Vr')} />
<ActionRow icon={<LifeBuoy size={15} />} label="Contact Support" sub={`support@${apex}`} onPress={() => openExternal(supportMailto(docs))} />
</YStack>
</LandingCard>
@@ -18,6 +18,8 @@ import { XStack, YStack } from '@hanzo/gui'
import { useSession } from '~/lib/auth/session'
import { useComposer } from './useComposer'
import { useModels, pricingOf, defaultModelId } from './useModels'
import { modelId } from '~/lib/api/aicatalog'
import { usePlanGate } from './usePlanGate'
import { useChatRun } from './useChatRun'
import { Composer } from './Composer'
import { ResponsePanel } from './ResponsePanel'
@@ -39,7 +41,18 @@ import { BackendStateCard } from '@hanzo/ui/product'
export function ChatPlayground({ mode }: { mode: 'chat' | 'completions' }) {
const composer = useComposer()
const models = useModels()
const gate = usePlanGate()
const run = useChatRun()
// What the picker OFFERS is what this org can actually run: models the
// gateway routes right now, minus premium ones on an unpaid org (the
// gateway 402s those; offering them would be the lying toast in picker
// form). When the live set is empty — a gateway outage — fall back to the
// full catalog so the picker never renders empty, and let runs answer.
const offered = useMemo(() => {
const live = models.entries.filter((m) => m.available)
const pool = live.length ? live : models.entries
return gate.paid ? pool : pool.filter((m) => !m.premium)
}, [models.entries, gate.paid])
const { account } = useSession()
// Per-user history key (org-qualified username), '' until the session resolves.
const userKey = account ? `${account.owner}/${account.name}` : ''
@@ -63,22 +76,27 @@ export function ChatPlayground({ mode }: { mode: 'chat' | 'completions' }) {
// present, else picking a sensible Zen-first default. Runs once.
const seeded = useRef(false)
useEffect(() => {
if (seeded.current || models.phase === 'loading') return
if (seeded.current || models.phase === 'loading' || !gate.resolved) return
// Seed from the OFFERED pool: the default must be a model this org can
// actually run, so a free org never boots onto a premium row.
const offeredIds = new Set(offered.map((m) => modelId(m)))
const pool = models.options.filter((o) => offeredIds.has(o.id))
const seedOptions = pool.length ? pool : models.options
const token =
typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get(SHARE_PARAM) : null
const shared = token ? decodeShare(token) : null
if (shared) {
composer.loadShare(shared)
if (!shared.model && models.options.length) composer.setModel(defaultModelId(models.options))
if (!shared.model && seedOptions.length) composer.setModel(defaultModelId(seedOptions))
seeded.current = true
} else if (models.options.length) {
composer.setModel(defaultModelId(models.options))
} else if (seedOptions.length) {
composer.setModel(defaultModelId(seedOptions))
seeded.current = true
}
// else: catalog error with no share link — leave unseeded so a Retry still
// seeds the promoted default once the catalog resolves.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [models.phase])
}, [models.phase, gate.resolved])
const turns: ComposerMsg[] = composer.messages.map((m) => ({ role: m.role, content: m.content }))
@@ -202,6 +220,12 @@ export function ChatPlayground({ mode }: { mode: 'chat' | 'completions' }) {
/>
) : null}
{/* Response — full width, directly under the surface tabs, so the answer
is the first thing on every screen size. The builder row follows. */}
<YStack width="100%">
<ResponsePanel run={run} pricing={pricing} model={composer.model} requestJson={json} />
</YStack>
<XStack gap="$4" flexWrap="wrap" items="flex-start">
{/* Builder — the composer with an attached, collapsible Model settings
side-pane (a bottom sheet on mobile). The two outer columns each ask
@@ -213,7 +237,7 @@ export function ChatPlayground({ mode }: { mode: 'chat' | 'completions' }) {
<Composer
composer={composer}
mode={mode}
models={models.entries}
models={offered}
modelsLoading={models.phase === 'loading'}
running={run.running}
onRun={() => void onRun()}
@@ -250,10 +274,6 @@ export function ChatPlayground({ mode }: { mode: 'chat' | 'completions' }) {
</XStack>
</YStack>
{/* Response — a 1/3 flex column at desktop; wraps below the builder on mobile. */}
<YStack flex={1} minW={320} gap="$3">
<ResponsePanel run={run} pricing={pricing} model={composer.model} requestJson={json} />
</YStack>
</XStack>
{/* Mobile: the same Model settings as a dismissable bottom sheet. */}
@@ -12,7 +12,7 @@ export function ModelSelect({
ids,
onChange,
disabled,
placeholder = 'model id, e.g. zen-omni',
placeholder = 'model id, e.g. zen5-mini',
}: {
value: string
ids: string[]
@@ -0,0 +1,37 @@
import { describe, it, expect } from 'vitest'
import { EXAMPLES } from './examples'
describe('EXAMPLES', () => {
it('has unique ids and fills every field', () => {
const ids = EXAMPLES.map((e) => e.id)
expect(new Set(ids).size).toBe(ids.length)
for (const e of EXAMPLES) {
expect(e.label.trim()).not.toBe('')
expect(e.system.trim()).not.toBe('')
expect(e.user.trim()).not.toBe('')
}
})
/**
* The defect this guards: all six examples once suggested `zen-omni` or `zen-coder`,
* and the gateway serves neither. Applying an example falls back to the selected
* model when the suggestion is absent, so nothing threw and nothing was logged — the
* card just advertised a model that could never be the one that ran.
*
* The test is on the SHAPE, not on a list of ids, because a hardcoded catalog would
* rot the same way the suggestions did. Zen's naming splits cleanly: `zen5…` are the
* text models; `zen-<noun>` names a modality (embedding, image, video, rerank, voice,
* vl, guard) and cannot hold a chat turn. A chat example must suggest a text model.
*/
it('suggests only Zen TEXT models — never a modality SKU or a retired id', () => {
for (const e of EXAMPLES) {
expect(e.model, `${e.id} suggests "${e.model}"`).toMatch(/^zen\d/)
}
})
it('never suggests the ids that were wrong', () => {
const retired = new Set(['zen-omni', 'zen-coder'])
for (const e of EXAMPLES) expect(retired.has(e.model)).toBe(false)
})
})
+15 -8
View File
@@ -3,8 +3,15 @@
*
* Each is a clearly-labelled STARTER (not fabricated history): picking one fills
* the System + User message and, when the suggested model is in the live catalog,
* selects it. Pure data; no network. The `model` is a suggestion shown as a chip,
* exactly like the mockup ("Explain quantum computing · zen-omni").
* selects it. Pure data; no network. The `model` is a suggestion shown as a chip.
*
* EVERY MODEL HERE MUST BE ONE THE GATEWAY SERVES. All six once named `zen-omni` or
* `zen-coder`, and the catalog carries neither — zen's text models are `zen5…`, while
* `zen-<noun>` names a modality (embedding, image, video, rerank, voice, vl). Applying
* an example falls back to the currently-selected model when the suggestion is absent
* (`models.byId.has(...)`), so nothing broke and nothing was logged: the card simply
* advertised a model that would never be the one that ran. `examples.test.ts` bites on
* the shape now, so a modality SKU or a retired id cannot come back.
*/
export type Example = {
id: string
@@ -20,42 +27,42 @@ export const EXAMPLES: Example[] = [
{
id: 'quantum',
label: 'Explain quantum computing',
model: 'zen-omni',
model: 'zen5-mini',
system: 'You are a patient teacher. Explain clearly for a curious beginner.',
user: 'Explain quantum computing in simple terms, with one everyday analogy.',
},
{
id: 'debounce',
label: 'Write a debounce function',
model: 'zen-coder',
model: 'zen5-coder',
system: 'You are an expert TypeScript engineer. Return only the code, no prose.',
user: 'Write a typed debounce<T> function with a cancel() method.',
},
{
id: 'summarize',
label: 'Summarize a paragraph',
model: 'zen-omni',
model: 'zen5-mini',
system: 'Summarize the user text in exactly three bullet points.',
user: 'Hanzo Cloud is a unified AI gateway exposing hundreds of models behind one OpenAI-compatible API, with built-in retrieval, billing and per-org keys, so orgs switch models without changing code.',
},
{
id: 'json',
label: 'Extract structured JSON',
model: 'zen-omni',
model: 'zen5-mini',
system: 'Respond with a single minified JSON object and nothing else.',
user: 'Extract name, role and company as JSON from: "Aoi Tanaka, the CTO at Hanzo, presented today."',
},
{
id: 'reasoning',
label: 'Step-by-step reasoning',
model: 'zen-omni',
model: 'zen5',
system: 'Think step by step, then give the final answer on its own line.',
user: 'A bat and a ball cost $1.10 in total. The bat costs $1.00 more than the ball. How much does the ball cost?',
},
{
id: 'sql',
label: 'Write a SQL query',
model: 'zen-coder',
model: 'zen5-coder',
system: 'You are a senior data engineer. Return only the SQL.',
user: 'Given users(id, created_at) and orders(id, user_id, total), write SQL for the top 5 users by total spend in 2026.',
},
@@ -0,0 +1,41 @@
'use client'
/**
* usePlanGate — is this org allowed the premium (frontier-priced) models?
*
* One question, answered from the org's own subscriptions over the same
* scoped `/billing/*` proxy every billing module uses: any subscription in a
* standing state (active | trialing | past_due) counts as a plan. The GATEWAY
* is the enforcement point — it 402s a premium run regardless of what any UI
* shows — so this hook only decides what the picker OFFERS, and it fails
* OPEN: if billing is unreachable the playground offers everything and lets
* the gateway answer, because hiding models on a flaky proxy would read as
* "the catalog shrank".
*/
import { useEffect, useState } from 'react'
import { BillingApi } from '~/lib/api/billing'
const STANDING = new Set(['active', 'trialing', 'past_due'])
export function usePlanGate(): { paid: boolean; resolved: boolean } {
const [state, setState] = useState({ paid: true, resolved: false })
useEffect(() => {
let alive = true
BillingApi.subscriptions()
.then((subs) => {
if (!alive) return
const paid = subs.some((s) => STANDING.has((s.status ?? 'active').toLowerCase()))
setState({ paid, resolved: true })
})
.catch(() => {
if (alive) setState({ paid: true, resolved: true })
})
return () => {
alive = false
}
}, [])
return state
}
+40 -9
View File
@@ -28,18 +28,41 @@ const GAP = 12
/** Read the current step's target rect, or null (no target / not found / hidden). */
function readRect(target?: string): Rect | null {
if (!target || typeof document === 'undefined') return null
const el = document.querySelector(target)
// The FIRST VISIBLE match, not merely the first: an anchor id can appear in a
// hidden twin (a collapsed rail, an unmounted pane) whose zero/offscreen box
// would strand the spotlight.
const all = Array.from(document.querySelectorAll(target))
const el = all.find((e) => {
const r = e.getBoundingClientRect()
return r.width >= 1 && r.height >= 1
})
if (!el) return null
// Bring a scrolled-away target back before measuring — a tour step about an
// element the viewport cannot see is a spotlight on nothing.
const pre = el.getBoundingClientRect()
const vh = window.innerHeight
if (pre.bottom < 0 || pre.top > vh) el.scrollIntoView({ block: 'center' })
const r = el.getBoundingClientRect()
// A zero-size box = a hidden anchor (e.g. the desktop sidebar on a phone) → center.
if (r.width < 1 || r.height < 1) return null
// An anchor larger than most of the viewport is a container, not a target —
// spotlighting it dims nothing and confuses everything. Center instead.
if (r.width * r.height > window.innerWidth * vh * 0.7) return null
return { top: r.top, left: r.left, width: r.width, height: r.height }
}
/** Clamp a value into [min, max]. */
const clamp = (v: number, min: number, max: number): number => Math.max(min, Math.min(v, max))
/** Fixed-position style for the tooltip card given the target rect + placement. */
/** Rough card height for fit math — measured cards vary; this bounds the clamps. */
const CARD_H = 220
/**
* Fixed-position style for the tooltip card. The declared placement is a
* PREFERENCE, not a contract: a side that has no room for the card FLIPS to
* the opposite side, and a side pair with no room either way falls through to
* below/above — so no step can render off-screen, whatever its author or its
* anchor's size assumed. Every axis is then clamped into the viewport.
*/
function tooltipStyle(rect: Rect | null, placement: TourStep['placement']): CSSProperties {
const vw = typeof window === 'undefined' ? 1200 : window.innerWidth
const vh = typeof window === 'undefined' ? 800 : window.innerHeight
@@ -47,18 +70,26 @@ function tooltipStyle(rect: Rect | null, placement: TourStep['placement']): CSSP
return { position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', width: CARD_W, maxWidth: '92vw', zIndex: Z.popover }
}
const base: CSSProperties = { position: 'fixed', width: CARD_W, maxWidth: '92vw', zIndex: Z.popover }
const fitsRight = rect.left + rect.width + GAP + CARD_W <= vw
const fitsLeft = rect.left - GAP - CARD_W >= 0
const fitsBelow = rect.top + rect.height + GAP + CARD_H <= vh
let side = placement
if (side === 'right' && !fitsRight) side = fitsLeft ? 'left' : 'bottom'
else if (side === 'left' && !fitsLeft) side = fitsRight ? 'right' : 'bottom'
if (side === 'bottom' && !fitsBelow && rect.top - GAP - CARD_H >= 0) side = 'top'
const leftClamped = clamp(rect.left, GAP, Math.max(GAP, vw - CARD_W - GAP))
switch (placement) {
const topClamped = clamp(rect.top, GAP, Math.max(GAP, vh - CARD_H - GAP))
switch (side) {
case 'bottom':
return { ...base, top: clamp(rect.top + rect.height + GAP, GAP, vh - GAP), left: leftClamped }
return { ...base, top: clamp(rect.top + rect.height + GAP, GAP, Math.max(GAP, vh - CARD_H - GAP)), left: leftClamped }
case 'top':
return { ...base, bottom: clamp(vh - rect.top + GAP, GAP, vh - GAP), left: leftClamped }
return { ...base, bottom: clamp(vh - rect.top + GAP, GAP, Math.max(GAP, vh - GAP)), left: leftClamped }
case 'right':
return { ...base, top: clamp(rect.top, GAP, vh - GAP), left: clamp(rect.left + rect.width + GAP, GAP, Math.max(GAP, vw - CARD_W - GAP)) }
return { ...base, top: topClamped, left: clamp(rect.left + rect.width + GAP, GAP, Math.max(GAP, vw - CARD_W - GAP)) }
case 'left':
return { ...base, top: clamp(rect.top, GAP, vh - GAP), right: clamp(vw - rect.left + GAP, GAP, vw - GAP) }
return { ...base, top: topClamped, left: clamp(rect.left - GAP - CARD_W, GAP, Math.max(GAP, vw - CARD_W - GAP)) }
default:
return { ...base, top: clamp(rect.top + rect.height + GAP, GAP, vh - GAP), left: leftClamped }
return { ...base, top: clamp(rect.top + rect.height + GAP, GAP, Math.max(GAP, vh - CARD_H - GAP)), left: leftClamped }
}
}
+3 -3
View File
@@ -4,8 +4,8 @@
* SubNav — the ONE level-2 nav for a product.
*
* Clicking into a product reveals ITS options rather than replacing the screen.
* That second level lives in the SIDEBAR (`DrillNav`) — the house pattern, the one
* the whole console already drills with. This strip is the SAME nav, rendered from
* That second level lives in the SIDEBAR (`SubRows`), expanded beneath the product's
* own row — the house pattern. This strip is the SAME nav, rendered from
* the SAME source (`productSubpages` over the registry), for the viewports where
* the sidebar is not on screen: below `lg` the sidebar is a drawer, so the strip
* carries level 2 in the content column and hides itself at `lg+` where the rail
@@ -75,7 +75,7 @@ export function SubNav({
const to = href ?? ((slug: string) => subpageHref(id, slug))
return (
// Hidden at lg+ — the sidebar's DrillNav is the level-2 nav there. Purely a
// Hidden at lg+ — the sidebar's SubRows is the level-2 nav there. Purely a
// CSS media style prop (not a JS media branch), so SSR and first paint match.
<XStack
gap="$1.5"
+10
View File
@@ -50,6 +50,15 @@ export type ConsoleConfig = {
brandName: string
/** Unified cloud backend base URL (hanzoai/cloud /v1) — shared across brands. */
cloudUrl: string
/**
* The PUBLIC gated API host — what a customer's own code calls, and the only base
* that belongs in a copyable snippet. Distinct from `cloudUrl`, which is SAME-ORIGIN
* in the browser: printing that would hand someone `https://console.<brand>/v1/…`,
* a URL that works for the SPA's proxied session and not for their API key. Shared
* across brands, because the cloud backend is one multi-tenant `/v1` scoped by the
* brand JWT's org — there is no per-brand API host to resolve.
*/
apiUrl: string
/** PaaS base URL (DOKS cluster control plane) — shared. */
platformUrl: string
/**
@@ -469,6 +478,7 @@ export function resolveConfig(host: string = currentHost()): ConsoleConfig {
brand,
brandName: b.brandName,
cloudUrl: cloudUrl(),
apiUrl: trimSlash(process.env.NEXT_PUBLIC_API_URL ?? 'https://api.hanzo.ai'),
iamUrl: trimSlash(process.env.NEXT_PUBLIC_IAM_URL ?? b.iamUrl),
iamOrgName: process.env.NEXT_PUBLIC_IAM_ORG_NAME ?? org,
iamAppName: process.env.NEXT_PUBLIC_IAM_APP_NAME ?? app,
+148 -136
View File
@@ -1,19 +1,27 @@
'use client'
/**
* Dashboard shell — a TWO-LEVEL sidebar (product list ⇄ drill into a product) + top
* bar + content, responsive across phone / tablet / laptop / desktop.
* Dashboard shell — a TWO-LEVEL sidebar (products, each expanding its own sub-pages
* in place) + top bar + content, responsive across phone / tablet / laptop / desktop.
*
* Level 1 (the product list) renders from the catalog: fixed Overview/Docs, a
* Pinned section the user curates, then every product grouped by category. Each
* CATEGORY is an INDEPENDENTLY collapsible section that renders EXPANDED by default
* (nothing auto-collapses); the header is flush-left with the top-level items and
* carries an OPTIONAL collapse chevron whose state persists per-user. Clicking a
* PRODUCT that has sub-pages DRILLS the sidebar INTO that product's sub-nav
* (Overview + specifics + the uniform base set: Settings · Status · Logs · Metrics)
* with a clear BACK affordance to the full list — Level 2. A product with only an
* Overview navigates directly (no drill). Sub-pages with no backend yet are dimmed
* and open an honest placeholder (never a dead link).
* carries an OPTIONAL collapse chevron whose state persists per-user.
*
* Level 2 — a product's sub-pages (Overview + specifics + the uniform base set:
* Settings · Status · Logs · Metrics) — expands BENEATH that product's own row, so
* its options appear without the rest of the catalog going away. The label
* navigates; the chevron beside it only expands or collapses, and that choice
* persists per-user (the product you are IN is open unless you closed it). Sub-pages
* with no backend yet are dimmed and open an honest placeholder, never a dead link.
*
* This replaced a DRILL: clicking a product used to swap the whole rail for that
* product's sub-nav, behind a "Back to all products" button. The options were the
* same either way — what the drill took away was every OTHER product, which is
* precisely what someone needs when the reason they opened the rail was to go
* somewhere else.
*
* Level 2 is DECLARED once, in the registry (`subpages` + `indexLabel`), and read
* here and by `SubNav` (the same nav, for the viewports where this sidebar is a
@@ -42,11 +50,10 @@
* NOT a JS media branch, so SSR and first paint match. The nav body (`SidebarNav`) is
* shared by the sidebar, the flyout, and the drawer (DRY) — one definition, many mounts.
*/
import { useEffect, useMemo, useState, type ComponentType, type ReactNode } from 'react'
import { useMemo, useState, type ComponentType, type ReactNode } from 'react'
import { usePathname, useRouter } from 'next/navigation'
import { Button, Input, ScrollView, Text, XStack, YStack } from '@hanzo/gui'
import {
ArrowLeft,
BarChart3,
Bell,
BookOpen,
@@ -61,7 +68,6 @@ import {
Lock,
Menu,
PanelLeft,
Plus,
Repeat,
ScrollText,
Search,
@@ -87,10 +93,19 @@ import { ConsoleFooter } from '~/components/ConsoleFooter'
import { openProduct } from '~/lib/products/open'
import { entryMatches } from '~/lib/products/search'
import { usePins, useProductColors } from '~/lib/products/pins'
import { useAppsBeta } from '~/lib/products/beta'
import { orderEntries } from '~/lib/products/order'
import { categoryIsOpen, toggleCategory, NAV_OPEN_PREF, EMPTY_OPEN, type CategoryOpen } from '~/lib/products/nav-accordion'
import {
categoryIsOpen,
toggleCategory,
productIsOpen,
toggleProduct,
NAV_OPEN_PREF,
NAV_PRODUCT_OPEN_PREF,
EMPTY_OPEN,
type CategoryOpen,
} from '~/lib/products/nav-accordion'
import { usePreferences } from '~/lib/products/preferences'
import { useSession } from '~/lib/auth/session'
import { useIsSuperAdmin } from '~/lib/auth/admin'
import { useEntitlements } from '~/lib/entitlements-context'
import { AddProductPanel } from '~/components/AddProductPanel'
@@ -195,14 +210,18 @@ function FixedRow({
)
}
/** A level-1 catalog row — colored product icon, opens the product; trailing is a
* pin star (catalog rows) or a color/customize dot (pinned rows). */
/** A level-1 catalog row — colored product icon, opens the product; trailing is an
* expansion chevron (products with sub-pages) then a pin star (catalog rows) or a
* color/customize dot (pinned rows). */
function NavRow({
entry,
active,
color,
collapsed,
pinned,
expandable,
expanded,
onExpand,
onOpen,
onToggle,
onCustomize,
@@ -212,6 +231,10 @@ function NavRow({
color: string
collapsed: boolean
pinned?: boolean
/** True when the product has sub-pages to expand beneath it. */
expandable?: boolean
expanded?: boolean
onExpand?: () => void
onOpen: () => void
onToggle?: () => void
onCustomize?: () => void
@@ -249,6 +272,27 @@ function NavRow({
>
{entry.label}
</Button>
{/* Expansion is its OWN control, separate from the row: the label navigates,
the chevron only opens or closes. One target that did both would make
"show me what's in here" and "take me there" the same gesture. */}
{expandable && onExpand ? (
<Button
size="$2"
chromeless
onPress={onExpand}
aria-expanded={expanded}
aria-controls={`nav-sub-${entry.id}`}
aria-label={`${expanded ? 'Collapse' : 'Expand'} ${entry.label}`}
icon={
<span
className="hz-chevron"
style={{ display: 'inline-flex', transform: expanded ? 'rotate(90deg)' : undefined }}
>
<ChevronRight size={14} color="$color9" />
</span>
}
/>
) : null}
{onCustomize ? (
<ColorDot color={color} onPress={onCustomize} label={`Customize ${entry.label}`} />
) : onToggle ? (
@@ -266,58 +310,32 @@ function NavRow({
}
/**
* Level 2 — the drilled product's sub-nav. Reached by clicking a product with
* sub-pages; a BACK affordance returns to the full product list (Level 1). The
* header shows the product (colored icon + name) under a category breadcrumb; the
* body is `productSubpages(entry)` — Overview + specifics + the uniform base set —
* with an unwired sub-page dimmed but honest (opens a placeholder, never a dead link).
* Level 2 — a product's sub-pages, expanded IN PLACE beneath its own row. The list
* is `productSubpages(entry)` (Overview + specifics + the uniform base set), with an
* unwired sub-page dimmed but honest: it opens a placeholder, never a dead link.
*
* Indented under the product and hung on a hairline, so the nesting is legible
* without a second heading — the product's own row above IS the heading. Collapsed,
* the rows are `inert`, so hidden options leave the tab order.
*/
function DrillNav({
function SubRows({
entry,
subs,
pathname,
color,
onBack,
open,
onGo,
}: {
entry: CatalogEntry
subs: ProductSubpage[]
pathname: string
color: string
onBack: () => void
open: boolean
onGo: (path: string) => void
}) {
const activeSlug = activeSubpage(pathname, entry.id)
const Icon = entry.icon
return (
<>
{/* Back to the full product list, with the category as a quiet breadcrumb. */}
<Button
chromeless
size="$2"
height={34}
px="$2"
justify="flex-start"
icon={<ArrowLeft size={17} />}
onPress={onBack}
hoverStyle={{ bg: '$color3' }}
aria-label="Back to all products"
>
<Text fontSize="$1" color="$color10" fontWeight="500">
{entry.category}
</Text>
</Button>
{/* The product header. */}
<XStack items="center" gap="$2.5" px="$2" py="$1.5" mb="$1">
<ProductIcon icon={Icon} color={color} size={22} />
<Text flex={1} fontSize="$5" fontWeight="800" color="$color12" numberOfLines={1}>
{entry.label}
</Text>
</XStack>
<ScrollView flex={1} minH={0}>
<YStack gap="$0.5">
<div className="hz-acc" data-open={open ? 'true' : 'false'} id={`nav-sub-${entry.id}`} inert={!open}>
<div className="hz-acc-inner">
<YStack gap="$0.5" ml="$4" pl="$2" pt="$0.5" borderLeftWidth={1} borderColor="$borderColor">
{subs.map((sp) => {
const wired = subpageWired(entry.id, sp.slug)
const active = sp.slug === activeSlug
@@ -328,9 +346,9 @@ function DrillNav({
onPress={() => onGo(sp.slug ? `/${entry.id}/${sp.slug}` : `/${entry.id}`)}
bg={active ? '$color4' : 'transparent'}
justify="flex-start"
icon={<SubIcon size={17} />}
icon={<SubIcon size={15} />}
iconAfter={!wired ? <Circle size={7} opacity={0.5} /> : undefined}
size="$3"
size="$2"
opacity={wired ? 1 : 0.6}
aria-label={wired ? sp.label : `${sp.label} (not available yet)`}
>
@@ -339,8 +357,8 @@ function DrillNav({
)
})}
</YStack>
</ScrollView>
</>
</div>
</div>
)
}
@@ -455,6 +473,7 @@ function SidebarNav({
const { colorOf } = useProductColors()
const detail = useDetailPane()
const showAdmin = useIsSuperAdmin()
const showBeta = useAppsBeta(showAdmin)
// Entitlement scope: currently ungated in prod (the endpoint 404s → `enabled` is
// null → the full catalog shows), matching "every product is always available".
const { enabled } = useEntitlements()
@@ -467,31 +486,23 @@ function SidebarNav({
const navOpen = prefs.get<CategoryOpen>(NAV_OPEN_PREF, EMPTY_OPEN)
const toggleSection = (category: string) => prefs.set(NAV_OPEN_PREF, toggleCategory(navOpen, category))
// ── Drill-in state (Level 1 ⇄ Level 2) ────────────────────────────────────
// ── Level 2, in place ─────────────────────────────────────────────────────
// A product's sub-pages expand beneath its own row; nothing replaces the list.
const activeId = activeModuleId(pathname)
const activeEntry = activeId ? findEntry(activeId) ?? null : null
const activeSubs = useMemo(
() => (activeEntry && activeEntry.kind === 'module' ? productSubpages(activeEntry, showAdmin) : []),
[activeEntry, showAdmin],
)
// `manualList` = the user hit BACK — force the Level-1 list even though the active
// route is a drillable product. Entering a DIFFERENT product resets it (auto-drill).
const [manualList, setManualList] = useState(false)
useEffect(() => {
setManualList(false)
}, [activeId])
const canDrill = Boolean(activeEntry) && activeSubs.length > 1
const drilled = canDrill && !manualList && !collapsed
const isActive = (id: string) => pathname === `/${id}` || pathname.startsWith(`/${id}/`)
const productOpen = prefs.get<CategoryOpen>(NAV_PRODUCT_OPEN_PREF, EMPTY_OPEN)
const toggleExpand = (id: string) =>
prefs.set(NAV_PRODUCT_OPEN_PREF, toggleProduct(productOpen, id, { active: id === activeId }))
// Navigate to a LEAF (a sub-page or a no-sub-page product) — closes the drawer.
const go = (path: string) => {
router.push(path)
onNavigate()
}
// Open a product from the list: DRILL if it has sub-pages (keep the drawer open so
// the sub-nav shows), else navigate directly (leaf → close the drawer). An external
// launch tile opens its deployed app in a new tab.
// Open a product from the list. One with sub-pages keeps the drawer open, because
// becoming active expands it in place and its options are the next thing to read;
// a leaf navigates and closes. An external launch tile opens its deployed app in a
// new tab.
const open = (entry: CatalogEntry) => {
if (entry.kind === 'external') {
openProduct(entry, go)
@@ -501,12 +512,48 @@ function SidebarNav({
setFilter('')
const subs = productSubpages(entry, showAdmin)
if (subs.length > 1) {
setManualList(false)
router.push(`/${entry.id}`) // DRILL — keep the drawer open for the sub-nav
router.push(`/${entry.id}`) // its sub-pages open beneath it
} else {
go(`/${entry.id}`) // leaf — navigate + close
}
}
/**
* ONE product row — the row itself plus, for a product that has them, its sub-pages
* expanded beneath. Both the Pinned group and the category groups render through
* this, so a product looks and behaves identically wherever it appears.
*/
const productRow = (entry: CatalogEntry, opts: { pinned?: boolean } = {}) => {
const subs = productSubpages(entry, showAdmin)
// A pinned product appears TWICE — once under Pinned, once in its category — and
// only ONE of those may carry the sub-pages. Two copies of the same list is two
// navs painting at once, which is the very thing this rail exists to avoid, and
// it doubles the rail's height for no information. The PINNED copy owns it: the
// user put it up there, and it is the one they read first.
const owns = opts.pinned || !isPinned(entry.id)
const expandable = owns && entry.kind === 'module' && subs.length > 1
const expanded = expandable && productIsOpen(productOpen, entry.id, { filtering, active: entry.id === activeId })
return (
<YStack key={`${opts.pinned ? 'pin' : 'cat'}-${entry.id}`} gap="$0.5">
<NavRow
entry={entry}
active={isActive(entry.id)}
color={colorOf(entry.id)}
collapsed={false}
pinned={opts.pinned ?? isPinned(entry.id)}
expandable={expandable}
expanded={expanded}
onExpand={expandable ? () => toggleExpand(entry.id) : undefined}
onOpen={() => open(entry)}
onToggle={opts.pinned ? undefined : () => toggle(entry.id)}
onCustomize={opts.pinned ? () => customize(entry) : undefined}
/>
{expandable ? (
<SubRows entry={entry} subs={subs} pathname={pathname} open={expanded} onGo={go} />
) : null}
</YStack>
)
}
const openDocs = () => {
if (typeof window !== 'undefined') window.open(config.docsUrl, '_blank', 'noopener')
onNavigate()
@@ -544,11 +591,11 @@ function SidebarNav({
...g,
entries: g.entries.filter((e) => {
const found = findEntry(e.id)
return Boolean(found) && (showAdmin || !found!.admin)
return Boolean(found) && (showAdmin || !found!.admin) && (showBeta || !found!.beta)
}),
}))
.filter((g) => g.entries.length > 0),
[view, showAdmin],
[view, showAdmin, showBeta],
)
// Within-scope ordering is CONTINUOUS ALPHABETICAL with the SELECTED product pinned
@@ -556,10 +603,14 @@ function SidebarNav({
// canonical order; only the items inside each are alphabetized + selected-first.
const groups = useMemo(
() =>
visibleCatalogByCategory(showAdmin, enabled)
// Search is DISCOVERY: while a query is typed the entitlement scope opens
// to the whole catalog — the point of searching is finding what you do
// not have yet — while the admin and beta gates keep holding. The resting
// rail stays scoped to the org's enabled set.
visibleCatalogByCategory(showAdmin, filtering ? null : enabled, showBeta)
.map((g) => ({ category: g.category, entries: orderEntries(g.entries.filter((e) => entryMatches(e, q)), activeId) }))
.filter((g) => g.entries.length > 0),
[q, showAdmin, enabled, activeId],
[q, filtering, showAdmin, showBeta, enabled, activeId],
)
// ── Product-shell face — the nav IS the root module's sub-pages ────────────
@@ -594,9 +645,9 @@ function SidebarNav({
{shell.wordmark}
</Text>
</XStack>
) : (
<SidebarBrand collapsed={collapsed} onNavigate={onNavigate} />
)}
) : collapsed ? (
<SidebarBrand collapsed onNavigate={onNavigate} />
) : null}
<ScrollView flex={1}>
<YStack gap="$1">
{subs.map((sp) => {
@@ -633,7 +684,7 @@ function SidebarNav({
const seen = new Set<string>()
for (const id of [...pinnedIds, ...(activeId ? [activeId] : [])]) {
const e = findEntry(id)
if (e && !seen.has(id) && (showAdmin || !e.admin)) {
if (e && !seen.has(id) && (showAdmin || !e.admin) && (showBeta || !e.beta)) {
seen.add(id)
railIds.push(id)
}
@@ -674,27 +725,9 @@ function SidebarNav({
)
}
// ── Level 2 — drilled into a product's sub-nav, with a BACK affordance ──
if (drilled && activeEntry) {
return (
<>
<SidebarBrand collapsed={false} onNavigate={onNavigate} />
<DrillNav
entry={activeEntry}
subs={activeSubs}
pathname={pathname}
color={colorOf(activeEntry.id)}
onBack={() => setManualList(true)}
onGo={go}
/>
<SidebarAccount collapsed={false} />
<SidebarWallet collapsed={false} />
</>
)
}
// ── Level 1 — the full product list: brand; filter; Overview/Docs; Pinned; every
// category (EXPANDED by default, collapsible); All-products; identity + wallet. ──
// ── The product list: brand; filter; Overview/Docs; Pinned; every category
// (EXPANDED by default, collapsible), each product expanding its own sub-pages
// in place; All-products; identity + wallet. ──
return (
// The product rail is a NAVIGATION LANDMARK. It had no role at all, so a
// screen-reader user had no way to jump to the product list and no way to
@@ -702,12 +735,13 @@ function SidebarNav({
// effect (the children keep their parent's flex context), and the explicit
// role survives regardless of how a given AT treats display:contents.
<nav role="navigation" aria-label="Products" style={{ display: 'contents' }}>
<SidebarBrand collapsed={false} onNavigate={onNavigate} />
{/* WHERE you are — organization and project in ONE control, directly under
the tenant's own mark. The account at the foot answers WHO you are; the
network chip in the top-right is a global MODE. Three questions, three
controls, each in one place. */}
{/* WHERE you are — organization and project in ONE control, and the FIRST
thing in the rail: the org's own logo (when IAM carries one) or its
name IS the mark, so a separate brand row above it said the same thing
twice. The account at the foot answers WHO you are; the network chip
in the top-right is a global MODE. Three questions, three controls,
each in one place. The collapsed icon rail keeps its mark — there is
no switcher to carry the identity there. */}
<ContextSwitcher />
{/* Product filter — narrows the whole list; a match from any category jumps
@@ -771,18 +805,7 @@ function SidebarNav({
{group.entries.map((e) => {
const entry = findEntry(e.id)
if (!entry) return null
return (
<NavRow
key={`pin-${e.id}`}
entry={entry}
active={isActive(e.id)}
color={colorOf(e.id)}
collapsed={false}
pinned
onOpen={() => open(entry)}
onCustomize={() => customize(entry)}
/>
)
return productRow(entry, { pinned: true })
})}
</YStack>
))}
@@ -797,18 +820,7 @@ function SidebarNav({
open={categoryIsOpen(navOpen, group.category, { filtering })}
onToggle={() => toggleSection(group.category)}
>
{group.entries.map((entry) => (
<NavRow
key={entry.id}
entry={entry}
active={isActive(entry.id)}
color={colorOf(entry.id)}
collapsed={false}
pinned={isPinned(entry.id)}
onOpen={() => open(entry)}
onToggle={() => toggle(entry.id)}
/>
))}
{group.entries.map((entry) => productRow(entry))}
</CategorySection>
))}
+47
View File
@@ -531,6 +531,40 @@ export type NewAgentBody = {
config?: Partial<AgentConfig>
}
/**
* One recorded run of an agent — what `POST /v1/agents/:ref/run` answers with.
*
* Every run this carries reflects an execution that ACTUALLY happened: the backend
* records a model failure as a run with `status: "error"` and its message, and
* answers 502 with that same run as the body. So a caller reads the run either way,
* and a failure is a fact about the run rather than a transport error to guess at.
*/
export type AgentRun = {
id: string
/** `ok` when the completion came back, `error` when it did not. */
status: string
/** The model actually used — a failover run reports the one it fell over to. */
model: string
/** The completion, when there was one. */
output?: string
/** The failure, when there was one. */
error?: string
durationMs?: number
}
/** Normalize a run payload defensively — a renamed field degrades, never throws. */
export function normalizeRun(payload: unknown): AgentRun {
const r = asRecord(payload)
return {
id: str(r.id) ?? '',
status: str(r.status) ?? '',
model: str(r.model) ?? '',
output: str(r.output),
error: str(r.error),
durationMs: num(r.durationMs),
}
}
export const AgentsApi = {
/** The agent registry (`GET /v1/agents`). Honest-empty/error until bound. */
list: (): Promise<Agent[]> => restGet<unknown>(originV1Url(BASE)).then(normalizeAgents),
@@ -551,6 +585,19 @@ export const AgentsApi = {
/** Create an agent (`POST /v1/agents`) — only called when the backend is live. */
create: (body: NewAgentBody): Promise<unknown> => restPost<unknown>(originV1Url(BASE), body),
/**
* Run an agent once (`POST /v1/agents/:ref/run`) and get the recorded run back.
* `ref` is the agent's name or its `agent_…` id — either resolves the same agent.
*
* THIS MOVES MONEY: the backend authorizes the org's balance BEFORE any inference,
* so an unfunded org gets 402 and no free compute. A model failure answers 502 with
* the RUN as the body, whose `error` field the transport surfaces as the thrown
* message — so a caller that shows `e.message` is already showing the run's own
* reason, not a generic transport failure.
*/
run: (ref: string, input: string): Promise<AgentRun> =>
restPost<unknown>(originV1Url(`${agentPath(ref)}/run`), { input }).then(normalizeRun),
/** Delete an agent (`DELETE /v1/agents/:name`) — keyed by the agent's NAME, never the
* display `id`. Only called when the backend is live. */
remove: (name: string): Promise<void> => restDelete(originV1Url(agentPath(name))),
+51
View File
@@ -264,3 +264,54 @@ describe('fetchPlans — honest-empty when gated', () => {
expect(plans.map((p) => p.id)).toEqual(['pro'])
})
})
describe('fetchCatalog — the routing id is what the gateway serves (tail alias)', () => {
const haiku: RichModel = {
id: 'anthropic/claude-haiku-4.5',
name: 'Anthropic: Claude Haiku 4.5',
provider: 'Anthropic',
contextWindow: 200000,
pricing: { input: 1, output: 5 },
}
const opus: RichModel = {
id: 'anthropic/claude-opus-4.6',
name: 'Anthropic: Claude Opus 4.6',
provider: 'Anthropic',
contextWindow: 200000,
pricing: { input: 5, output: 25 },
}
beforeEach(() => {
;(globalThis as { window?: unknown }).window = {
location: { origin: ORIGIN, hostname: 'console.hanzo.ai' },
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
}
vi.stubGlobal('fetch', (url: string) => {
const body = url.includes('pricing')
? { models: [haiku, opus] }
: // The gateway routes the BARE ids, not the bundle's openrouter spelling.
{ object: 'list', data: [{ id: 'claude-haiku-4.5' }, { id: 'claude-opus-4.6' }] }
return Promise.resolve(new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } }))
})
})
afterEach(() => {
vi.unstubAllGlobals()
delete (globalThis as { window?: unknown }).window
})
it('rewrites a bundle id to the live tail so the picker submits what routes', async () => {
const cat = await fetchCatalog()
const h = cat.find((m) => m.name === 'Anthropic: Claude Haiku 4.5')!
expect(h.id).toBe('claude-haiku-4.5') // NOT anthropic/claude-haiku-4.5 — that 404s
expect(h.available).toBe(true)
// The live-only merge must not append a duplicate bare row for it.
expect(cat.filter((m) => modelId(m) === 'claude-haiku-4.5')).toHaveLength(1)
})
it('derives premium from frontier pricing when the bundle omits the flag', async () => {
const cat = await fetchCatalog()
const h = cat.find((m) => modelId(m) === 'claude-haiku-4.5')!
const o = cat.find((m) => modelId(m) === 'claude-opus-4.6')!
expect(h.premium).toBeFalsy() // $1/$5 — under both bars
expect(o.premium).toBe(true) // $5/$25 — the Opus class gates behind a plan
})
})
+36 -5
View File
@@ -78,6 +78,22 @@ const FIXTURE_MODELS: RichModel[] = ((catalogFixture as { models?: RichModel[] }
const liveKey = (m: { id?: string | null; name?: string }): string =>
(m.id ?? m.name ?? '').trim().toLowerCase()
/**
* Premium = the catalog says so, or the price does. The bundle rarely carries
* the flag, so frontier-priced models (input ≥ $5/Mtok or output ≥ $25/Mtok —
* every Opus generation qualifies at $5/$25 or $15/$75; Sonnet at $3/$15 and
* Haiku at $1/$5 stay under both bars) count as premium by price. The flag
* drives the picker's Pro gate and keeps default-model away from a 402 on a
* trial balance — the GATEWAY remains the enforcement point; this is the
* honest rendering of it.
*/
export function isPremium(m: RichModel): boolean {
if (m.premium) return true
const input = m.pricing?.input
const output = m.pricing?.output
return (typeof input === 'number' && input >= 5) || (typeof output === 'number' && output >= 25)
}
/**
* Merge two catalog sources by stable id, PRIMARY winning field-by-field: a model in
* both keeps `primary`'s fields and inherits only the keys `primary` lacks from
@@ -166,13 +182,28 @@ export async function fetchCatalog(): Promise<CatalogEntry[]> {
const liveSet = new Set(liveArr.map(liveKey).filter(Boolean))
// The fixture is the base; live pricing wins where it overlaps, and fills the rest.
const catModels = mergeById(cat.models ?? [], FIXTURE_MODELS)
const catKeys = new Set(catModels.map((m) => modelId(m).toLowerCase()))
const entries: CatalogEntry[] = catModels.map((m) => ({
...m,
const entries: CatalogEntry[] = catModels.map((m) => {
const id = modelId(m).toLowerCase()
// Cross-reference by the stable id (third-party) AND the display name (Zen),
// so both record shapes resolve their live-availability correctly.
available: liveSet.has(modelId(m).toLowerCase()) || liveSet.has((m.name ?? '').toLowerCase()),
}))
if (liveSet.has(id) || liveSet.has((m.name ?? '').toLowerCase())) {
return { ...m, premium: isPremium(m), available: true }
}
// The gateway routes MANY catalog models under the bare tail of their
// openrouter-style id: the bundle says `anthropic/claude-haiku-4.5`, the
// gateway serves `claude-haiku-4.5`. The id the picker submits must be the
// id the gateway routes — a rich row keeping its bundle spelling is how the
// playground offered Claude Haiku 4.5 and then errored running it.
const slash = id.lastIndexOf('/')
const tail = slash >= 0 ? id.slice(slash + 1) : ''
if (tail && liveSet.has(tail)) {
const rawId = (m.id ?? '').trim()
return { ...m, id: rawId.slice(rawId.lastIndexOf('/') + 1), premium: isPremium(m), available: true }
}
return { ...m, premium: isPremium(m), available: false }
})
const catKeys = new Set(catModels.map((m) => modelId(m).toLowerCase()))
for (const e of entries) catKeys.add(modelId(e).toLowerCase())
// Merge live-only models the pricing overlay doesn't carry — the CURRENT Zen set
// (zen5-flash/coder/nano-*) the older bundle omits, and EVERY model when pricing
// is down. They are servable now, so they list Available under their family.
+77
View File
@@ -0,0 +1,77 @@
/**
* Tools API — the unified tool plane (`GET /v1/tools`).
*
* ONE flat set of callable names spanning every source the org can reach —
* connector actions, user functions, zap-service routes, agents, skills, and the
* org's own external MCP servers — deduplicated by name with source precedence
* applied server-side. It LISTS; dispatch is `POST /v1/tools/call` and belongs to
* whatever runs the agent, not to a form.
*
* Called SAME-ORIGIN with no prefix (`originV1Url('tools')`), so it rides the
* console's own user-bearer proxy: a short-lived user-bound IAM token is minted
* server-side and the cloud plane scopes the listing to the caller's org and
* project. No credential reaches the browser.
*
* An org with nothing activated gets `{"tools":[]}` — a REAL empty answer, not a
* failure. The agent builder shows that honestly (the field stays typeable) rather
* than inventing a tool that would 404 on the first invocation.
*/
import { restGet, originV1Url } from './client'
/** Where a tool came from — the plane dedupes across these by name. */
export type ToolSource = 'connector' | 'function' | 'zap-service' | 'agent' | 'skill' | 'mcp'
/** One callable tool as discovery reports it. */
export type Tool = {
/** The dispatch name — exactly what an agent's `tools` list must carry. */
name: string
/** Which plane provides it. */
source?: ToolSource
/** One line on what it does, when the provider carries one. */
description?: string
/** Whether it is activated for this org+project (an inactive tool lists but won't run). */
activated: boolean
}
const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined)
/**
* Defensive normalizer. The listing is read from whichever envelope key the plane
* uses, and a row missing `name` is dropped — a nameless tool cannot be dispatched,
* so surfacing it would offer the user something that cannot work.
*/
export function normalizeTools(payload: unknown): Tool[] {
const rows = Array.isArray(payload)
? payload
: payload && typeof payload === 'object'
? ((payload as Record<string, unknown>).tools ??
(payload as Record<string, unknown>).data ??
(payload as Record<string, unknown>).items)
: undefined
if (!Array.isArray(rows)) return []
const out: Tool[] = []
for (const raw of rows) {
if (!raw || typeof raw !== 'object') continue
const r = raw as Record<string, unknown>
const name = str(r.name)
if (!name) continue
out.push({
name,
source: str(r.source) as ToolSource | undefined,
description: str(r.description),
activated: r.activated === true,
})
}
return out
}
/** The tool plane's read side. */
export const ToolsApi = {
/**
* Every tool the caller's org and project can reach, each flagged `activated`.
* `activatedOnly` narrows to the callable set — the plane tests the raw query
* against the literal string "true", so this sends that or nothing at all.
*/
list: (activatedOnly = false): Promise<Tool[]> =>
restGet<unknown>(originV1Url(activatedOnly ? 'tools?activated=true' : 'tools')).then(normalizeTools),
}
+57
View File
@@ -48,6 +48,46 @@ export const ALWAYS_ON_PRODUCTS: readonly string[] = [
'captable', // the org's capitalization ledger — foundational company surface, every org (peer of 'company')
]
/**
* THE LAUNCH SET — what a new signup sees on console.hanzo.ai tonight.
*
* We are launching with hanzo.chat, hanzo.app and the console, so the console
* shows exactly what those need and nothing else. Every other product in the
* catalog (the whole cloud: compute, data, network, security, web3, the app
* suite, the fleet admin) is BETA — present, routable, and invisible until an
* org holds the beta flag. A superadmin always sees everything.
*
* This is an ALLOW-LIST on purpose: a new product added to the catalog is
* hidden by DEFAULT and joins the launch only when someone names it here. The
* inverse (a deny-list) leaks every future addition onto a customer's first
* screen.
*
* The set: the AI plane the two products run on, the credential to call it,
* the money surfaces, org/account management, and the beta door itself.
*/
export const LAUNCH_PRODUCTS: readonly string[] = [
// the console itself
'overview', // the home board
'beta-features', // the door to everything else — never behind its own flag
// the AI plane hanzo.chat + hanzo.app run on
'chat', // hanzo.chat
'models', // the model catalog
'playground', // try a model
'api-keys', // the credential both products call with
'usage', // what the AI cost
'logs', // the request log for those calls
// money
'billing',
'plans',
// org + account
'settings',
'team',
'profile',
]
/** True when a product is part of tonight's launch surface. */
export const isLaunchProduct = (id: string): boolean => LAUNCH_PRODUCTS.includes(id)
/** True when a product is always-on (implicit, never stored in `enabled`). */
export const isAlwaysOn = (id: string): boolean => ALWAYS_ON_PRODUCTS.includes(id)
@@ -84,6 +124,23 @@ export function filterEntitled<T extends { id: string }>(
* semantics so the UI can optimistically preview a change with the same result the
* backend would compute. Remove wins over add for the same id in one patch.
*/
/**
* Keep only the entries a viewer's BETA standing admits. Pure and generic like
* `filterEntitled`, and the same one-predicate rule: a superadmin sees
* everything, a beta org sees everything, everyone else loses `beta: true`
* entries. Fails CLOSED — callers that have not asked the enablement plane
* pass `showBeta: false` and beta surfaces stay hidden.
*/
export function filterBeta<T extends { id: string; beta?: boolean }>(
entries: readonly T[],
showBeta: boolean,
showAdmin: boolean,
): T[] {
if (showAdmin || showBeta) return [...entries]
// Beta is the COMPLEMENT of the launch set: outside it, or stamped.
return entries.filter((e) => isLaunchProduct(e.id) && e.beta !== true)
}
export function nextEnabled(current: readonly string[], patch: EntitlementPatch): string[] {
const set = new Set<string>(current)
for (const id of patch.add ?? []) if (id) set.add(id)
+1 -1
View File
@@ -53,7 +53,7 @@ function consented(): boolean {
// ── No publishable ingest key is passed, and that is DELIBERATE ──────────────
//
// Do not "fix" this by adding a build arg or by reading NEXT_PUBLIC_EVENT_INGEST_KEY
// Do not "fix" this by adding a build arg or by reading NEXT_PUBLIC_PUBLISHABLE_KEY
// here.
//
// A `pk-` resolves to exactly ONE org (cloud stamps the tenant from the key), and
+14
View File
@@ -102,3 +102,17 @@ describe('resolveTour', () => {
expect(resolveTour(steps, sig({ hasApiKey: true })).map((s) => s.id)).toEqual(['a']) // has key → dropped
})
})
// The tour card must never render beside a full-width checklist row — "right
// of the row" is the far viewport edge, and step one of every guide tour
// rendered clipped off-screen there.
it('a checklist tour step places BELOW its row, never beside it', () => {
const guide = { id: 'chat', pitch: { title: '', sub: '', points: [] }, steps: [] } as never
const resolved = [
{ step: { id: 'api-key', title: 't', body: 'b' }, done: false },
] as never[]
for (const st of buildTourFromSteps(guide, resolved as never)) {
expect(st.placement).toBe('bottom')
}
})
+4 -1
View File
@@ -136,7 +136,10 @@ export function buildTourFromSteps(guide: ProductGuide, resolved: StepProgress[]
target: stepAnchorSelector(guide.id, r.step.id),
title: r.step.title,
body: r.step.body,
placement: 'right' as const,
// BELOW the row, never beside it: a checklist row spans the whole card,
// so "right of the row" is the far viewport edge — the card rendered
// clipped off-screen there, step one of every tour.
placement: 'bottom' as const,
}))
return [...stepTour, ...(guide.tour ?? [])]
}
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import { filterBeta, LAUNCH_PRODUCTS } from '~/lib/entitlements'
// We launch with hanzo.chat, hanzo.app and the console: the launch set is an
// ALLOW-LIST, so everything else is beta by default and a NEW catalog entry is
// hidden the day it lands. That default is the whole point — pin it.
describe('filterBeta — the launch gate', () => {
const entries = [
{ id: 'chat' },
{ id: 'models' },
{ id: 'api-keys' },
{ id: 'beta-features' },
{ id: 'crm' },
{ id: 'gpus' },
{ id: 'lux-bridge' },
{ id: 'a-product-nobody-has-written-yet' },
]
it('shows the launch set and hides everything else', () => {
expect(filterBeta(entries, false, false).map((e) => e.id)).toEqual([
'chat',
'models',
'api-keys',
'beta-features',
])
})
it('a brand-new catalog entry is hidden by DEFAULT, not by remembering to stamp it', () => {
const shown = filterBeta([{ id: 'something-new-2027' }], false, false)
expect(shown).toEqual([])
})
it('the flag reveals everything; a superadmin never needed it', () => {
expect(filterBeta(entries, true, false)).toHaveLength(entries.length)
expect(filterBeta(entries, false, true)).toHaveLength(entries.length)
})
it('the beta door itself is in the launch set — otherwise nobody can opt in', () => {
expect(LAUNCH_PRODUCTS).toContain('beta-features')
})
it('a stamped entry inside the launch set can still ship dark', () => {
expect(filterBeta([{ id: 'chat', beta: true }], false, false)).toEqual([])
})
})
+67
View File
@@ -0,0 +1,67 @@
'use client'
/**
* useAppsBeta — does this viewer see the beta (Apps) surfaces?
*
* ONE source of truth: the enablement plane (`/v1/enablement`), the same
* self-service opt-in the Beta features module manages, scoped server-side to
* the caller's validated org. The gate looks for the `apps` feature (kind
* `feature`, id `apps`) being EFFECTIVE for the org — an admin sets it to
* `beta` (optionally granting orgs), users opt in where allowed, and this hook
* simply reads the resulting truth.
*
* Fails CLOSED: until the read answers — and whenever it refuses — beta
* surfaces stay hidden. A superadmin always sees them (mirror of the `admin`
* gate, and the only way the flag surface itself can be administered when the
* plane is down). Cached for the session like the org identity is: every nav
* surface asks, one request answers.
*/
import { useEffect, useState } from 'react'
import { EnablementApi } from '~/lib/api/admin-cockpit'
const APPS_KIND = 'feature'
const APPS_ID = 'apps'
let cached: boolean | null = null
let inflight: Promise<boolean> | null = null
async function readAppsBeta(): Promise<boolean> {
if (cached !== null) return cached
if (!inflight) {
inflight = EnablementApi.view()
.then((v) => {
const all = [...v.items, ...v.betas]
const hit = all.find((i) => i.kind === APPS_KIND && i.id === APPS_ID)
cached = Boolean(hit?.effective)
return cached
})
.catch(() => {
// A refusal is not an entitlement. Do not cache it — the next mount
// may be after sign-in or after the plane recovers.
inflight = null
return false
})
}
return inflight
}
export function useAppsBeta(isSuperAdmin: boolean): boolean {
const [on, setOn] = useState<boolean>(() => isSuperAdmin || cached === true)
useEffect(() => {
if (isSuperAdmin) {
setOn(true)
return
}
let live = true
readAppsBeta().then((v) => {
if (live) setOn(v)
})
return () => {
live = false
}
}, [isSuperAdmin])
return on
}
+47 -1
View File
@@ -1,6 +1,14 @@
import { describe, expect, it } from 'vitest'
import { categoryIsOpen, toggleCategory, type CategoryOpen } from './nav-accordion'
import {
categoryIsOpen,
toggleCategory,
productIsOpen,
toggleProduct,
NAV_OPEN_PREF,
NAV_PRODUCT_OPEN_PREF,
type CategoryOpen,
} from './nav-accordion'
const ctx = (filtering = false) => ({ filtering })
@@ -68,3 +76,41 @@ describe('toggleCategory (independent per-section)', () => {
expect(next).not.toBe(stored)
})
})
describe('productIsOpen / toggleProduct', () => {
// Where you are is the one product whose options you are certain to want.
it('opens the ACTIVE product and leaves the others closed', () => {
expect(productIsOpen({}, 'agents', { filtering: false, active: true })).toBe(true)
expect(productIsOpen({}, 'models', { filtering: false, active: false })).toBe(false)
})
it('respects an explicit choice over the active default, in both directions', () => {
expect(productIsOpen({ agents: false }, 'agents', { filtering: false, active: true })).toBe(false)
expect(productIsOpen({ models: true }, 'models', { filtering: false, active: false })).toBe(true)
})
// The filter narrows PRODUCTS; a matched product's sub-pages are not themselves
// matches, so expanding them would push the other hits off screen.
it('closes everything while filtering, active or not', () => {
expect(productIsOpen({}, 'agents', { filtering: true, active: true })).toBe(false)
expect(productIsOpen({ agents: true }, 'agents', { filtering: true, active: true })).toBe(false)
})
// Whichever way the chevron points, the click does that.
it('first click on the active one collapses it; on any other one expands it', () => {
expect(toggleProduct({}, 'agents', { active: true })).toEqual({ agents: false })
expect(toggleProduct({}, 'models', { active: false })).toEqual({ models: true })
})
it('leaves every other product untouched and never mutates the input', () => {
const stored = { models: true }
expect(toggleProduct(stored, 'agents', { active: false })).toEqual({ models: true, agents: true })
expect(stored).toEqual({ models: true })
})
// Products and categories share a preference SHAPE but not a default, and they are
// stored under different keys — a product must never inherit a category's open-by-default.
it('is keyed apart from the category accordion', () => {
expect(NAV_PRODUCT_OPEN_PREF).not.toBe(NAV_OPEN_PREF)
})
})
+46
View File
@@ -57,3 +57,49 @@ export function toggleCategory(stored: CategoryOpen, category: string): Category
const current = stored[category] === undefined ? true : stored[category]
return { ...stored, [category]: !current }
}
// ── Products, which expand IN PLACE ─────────────────────────────────────────
//
// A product with sub-pages expands beneath its own row, so its options appear
// without the rest of the catalog disappearing. This replaced a DRILL — clicking a
// product used to swap the entire rail for that product's sub-nav, behind a "Back to
// all products" button. The options were the same either way; what the drill took
// away was every other product, which is exactly what a person needs to see when the
// reason they clicked was to compare or to move on somewhere else.
//
// The default here is the OPPOSITE of a category's, and deliberately: categories are
// few and describe the whole catalog, so they open; products are many and each brings
// four to eight rows, so opening them all would bury the catalog under its own detail.
/** Preference key for which products are expanded in the rail. */
export const NAV_PRODUCT_OPEN_PREF = 'navProductsOpen'
/**
* Whether a product's sub-pages render EXPANDED:
* - while FILTERING: closed. The filter narrows PRODUCTS, and a matched product's
* sub-pages are not themselves matches — expanding them would push the other hits
* off screen;
* - the ACTIVE product: open, unless the user explicitly collapsed it. Where you are
* is the one place whose options you are certain to want;
* - otherwise: the user's explicit choice, else CLOSED.
*/
export function productIsOpen(
stored: CategoryOpen,
id: string,
ctx: { filtering: boolean; active: boolean },
): boolean {
if (ctx.filtering) return false
const v = stored[id]
return v === undefined ? ctx.active : v
}
/**
* Toggle one product's expansion (pure + immutable). The stored value is what the
* product is being toggled AWAY from, so the first click on the active product
* collapses it and the first click on any other one expands it — in both cases the
* click does the thing the chevron was pointing at.
*/
export function toggleProduct(stored: CategoryOpen, id: string, ctx: { active: boolean }): CategoryOpen {
const current = stored[id] === undefined ? ctx.active : stored[id]
return { ...stored, [id]: !current }
}
+30 -3
View File
@@ -109,7 +109,7 @@ import { Users,
} from '@hanzogui/lucide-icons-2'
import { config, type BrandId, type ShellId } from '~/config'
import { ALWAYS_ON_PRODUCTS, filterEntitled } from '~/lib/entitlements'
import { ALWAYS_ON_PRODUCTS, filterBeta, filterEntitled, isLaunchProduct } from '~/lib/entitlements'
import { type ProductCategory, categoryOrder, categoriesForBrand, categoryInBrand } from './brand-scope'
import { shellFor, isProductShell } from './shell'
import { ProvidersModule } from '~/components/products/ProvidersModule'
@@ -420,6 +420,14 @@ type CatalogBase = {
docs?: string
/** Admin-gated surface (shown with a lock hint; access enforced server-side). */
admin?: boolean
/**
* Beta (early-access) product — hidden from every nav, palette, discovery
* panel and search until the caller's ORG holds the `apps` beta through the
* enablement plane (kind `feature`, id `apps`) — the same self-service
* opt-in the Beta features module manages. Superadmins always see them, and
* the gate fails CLOSED: no enablement read, no beta surfaces.
*/
beta?: boolean
/**
* Per-brand scope — the brands whose console shows this entry (`entryInBrandScope`).
* OMIT for a brand-agnostic entry (the default: shown on every brand its category
@@ -1228,6 +1236,10 @@ export const catalog: CatalogEntry[] = [
{ path: ':tab', component: AgentsModule },
],
subpages: [
// The guided way in: describe an agent or start from a template, configure it in
// the ONE builder, run it, and take the call away. Leads the sub-nav because it
// is where someone with no agents yet should land.
{ slug: 'quickstart', label: 'Quickstart' },
{ slug: 'status', label: 'Status' },
{ slug: 'logs', label: 'Logs' },
{ slug: 'metrics', label: 'Metrics' },
@@ -3764,6 +3776,17 @@ export const catalogByCategory = (): { category: ProductCategory; entries: Catal
/** An admin-only (global / Hanzo-managed) entry — hidden from a customer's nav. */
export const isAdminEntry = (e: CatalogEntry): boolean => e.admin === true
/**
* A BETA entry — hidden until the org holds the beta flag (or is a superadmin).
*
* Beta is the COMPLEMENT of the launch set, not a per-entry stamp: we are
* launching with hanzo.chat, hanzo.app and the console, so everything outside
* `LAUNCH_PRODUCTS` is beta by default and a new catalog entry is hidden the
* day it lands. `beta: true` still forces the flag on for an entry inside the
* launch set, which is how a launch surface can ship dark.
*/
export const isBetaEntry = (e: CatalogEntry): boolean => e.beta === true || !isLaunchProduct(e.id)
/**
* Per-brand category scope — the ONE knob that makes each brand's console show
* the right surfaces. `hanzo` is the full AI cloud. The sovereign-chain brands
@@ -3800,6 +3823,9 @@ export const inBrand = (e: CatalogEntry): boolean =>
export const visibleCatalog = (
showAdmin: boolean,
enabled?: string[] | null,
// Fails CLOSED on purpose: a caller that has not asked the enablement plane
// does not show beta surfaces.
showBeta = false,
): CatalogEntry[] => {
// Product-shell face (billing / marketing / ads / social / sentry host, or an
// override): the SAME console image, scoped to ONE product FACE — its root module
@@ -3816,7 +3842,7 @@ export const visibleCatalog = (
// belong to their face, not the general nav (e.g. the sentry panels are the o11y
// surfaces' Sentry twin, shown only on sentry.<brand>). marketing/ads/social carry
// NO `e.shell` (normal Apps products), so they ALSO show in the full console.
const byAdmin = (showAdmin ? catalog : catalog.filter((e) => !isAdminEntry(e)))
const byAdmin = filterBeta(showAdmin ? catalog : catalog.filter((e) => !isAdminEntry(e)), showBeta, showAdmin)
.filter((e) => !e.shell)
.filter(inBrand)
// ENTITLEMENT GATE (customer only): out-of-box an org sees ONLY the products it has
@@ -3830,8 +3856,9 @@ export const visibleCatalog = (
export const visibleCatalogByCategory = (
showAdmin: boolean,
enabled?: string[] | null,
showBeta = false,
): { category: ProductCategory; entries: CatalogEntry[] }[] => {
const visible = visibleCatalog(showAdmin, enabled)
const visible = visibleCatalog(showAdmin, enabled, showBeta)
// In a product-shell face the root module IS the whole catalog — surface it as a
// single group regardless of the brand's category order (its category may be
// outside the brand's normal set). ONE branch for EVERY face.
+5 -3
View File
@@ -87,12 +87,14 @@ function scoreDestination(q: string, d: Destination): number {
* deep sub-page jumps ("queues" → Compute Tasks Queues). `showAdmin` gates
* admin-only surfaces so a customer can't jump to what they can't see.
*/
export function searchDestinations(query: string, showAdmin = true, enabled?: string[] | null): Destination[] {
export function searchDestinations(query: string, showAdmin = true, enabled?: string[] | null, showBeta = false): Destination[] {
const q = query.trim().toLowerCase()
// Scope to the visible catalog (brand + billing-only shell + entitlements), then
// gate admin — so ⌘K jumps match exactly what the nav shows (billing-only offers
// only billing; a customer only what their org has enabled).
const all = destinationsFor(visibleCatalog(showAdmin, enabled), showAdmin)
// only billing; a customer only what their org has enabled). A TYPED query is
// DISCOVERY: the entitlement scope opens to the whole catalog — searching is
// for finding what you do not have yet — while admin and beta keep holding.
const all = destinationsFor(visibleCatalog(showAdmin, q ? null : enabled, showBeta), showAdmin)
if (!q) return all.filter((d) => d.kind === 'product')
return all
.map((d) => ({ d, s: scoreDestination(q, d) }))
+13
View File
@@ -80,6 +80,19 @@ describe('allowCloudSurface', () => {
expect(allowCloudSurface('v1/agents/agent-1/runs')).toBe(true)
})
// The tool plane is admitted so the agent builder can offer an org's REAL tool
// names. Discovery only: a head admits every sub-path, and `POST /v1/tools/call`
// RUNS a tool — that belongs to whatever runs an agent, never to a browser tab.
it('admits tool discovery but refuses the dispatch door', () => {
expect(CLOUD_HEADS).toContain('tools')
expect(allowCloudSurface('v1/tools')).toBe(true)
expect(allowCloudSurface('v1/tools?activated=true')).toBe(true)
expect(allowCloudSurface('v1/tools/catalog')).toBe(true)
expect(allowCloudSurface('v1/tools/call')).toBe(false)
expect(allowCloudSurface('/v1/tools/call')).toBe(false)
expect(allowCloudSurface('v1/tools/call?x=1')).toBe(false)
})
it('admits the evals facade (scores/datasets/rubrics/evaluators/runs)', () => {
expect(CLOUD_HEADS).toContain('evals')
for (const sub of ['scores', 'datasets', 'rubrics', 'evaluators', 'runs']) {
+16 -1
View File
@@ -33,6 +33,13 @@ export const CLOUD_HEADS: readonly string[] = [
'functions',
'prompts',
'agents',
// The unified tool plane (cloud apps/tools): /v1/tools — discovery across every
// source (connector actions, functions, zap-service routes, agents, skills, the
// org's own MCP servers), deduplicated by name. `scopeOf` derives the org+project
// from the Bearer owner and 403s a cookie-only call, so it routes through /v1
// exactly like agents/prompts. Discovery only — `/v1/tools/call` is refused below,
// because running a tool belongs to whatever runs an agent, not to a browser tab.
'tools',
// Login manager (cloud clients/link): /v1/links[/…] — the org+user-scoped registry
// of which AI provider accounts are signed in on which machines + their usage. The
// handler resolves org from the Bearer owner + the user from the validated subject
@@ -395,7 +402,15 @@ export function v1Head(path: string): string | null {
* rule would break a live surface while claiming to preserve a property that never
* covered it. Defense in depth — the backend gates cross-tenant reads on its own.
*/
const REFUSED_SUBPATHS: readonly RegExp[] = [/^v1\/ai\/stores\/global(?:$|[/?#])/]
const REFUSED_SUBPATHS: readonly RegExp[] = [
/^v1\/ai\/stores\/global(?:$|[/?#])/,
// The tool plane's DISPATCH door. `tools` is allow-listed for discovery — the agent
// builder needs to offer the org's real tool names — but a head admits every
// sub-path, and `POST /v1/tools/call` RUNS a tool. Executing one belongs to whatever
// runs an agent, never to a form in a browser tab, so the console's proxy is a
// read-only window onto the plane.
/^v1\/tools\/call(?:$|[/?#])/,
]
export function allowCloudSurface(path: string): boolean {
const rel = path.replace(/^\/+/, '')