Compare commits

...
6 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
14 changed files with 298 additions and 129 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.
+49
View File
@@ -114,3 +114,52 @@ test('phone: it stacks and the body never scrolls sideways', async ({ browser })
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()
})
+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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hanzo/console",
"version": "8.5.61",
"version": "8.5.62",
"packageManager": "pnpm@11.17.0",
"private": true,
"license": "MIT OR Apache-2.0",
+10 -23
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,7 @@ 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'
@@ -136,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()
@@ -228,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
+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,
+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
@@ -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>
@@ -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.',
},
+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