Compare 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
zooqueen 4d9aabb7bd release: 8.5.50 — the manifest says what the registry already carries
Hanzo CI/CD / cicd (push) Successful in 5m49s
CI/CD / cicd (push) Successful in 5m49s
The version in package.json had sat at 8.5.35 while CI published through
8.5.48 (the tag hanzoai/cloud pins today): a main push derives its number as
max(declared, published) + a patch, so the registry floor carried the series
alone and the manifest drifted fifteen patches behind it.

That matters for a hand-cut tag, because a tag build publishes the tag
VERBATIM with no monotonic check — cutting the next number after the last git
tag would have republished a semver that already exists, over different bytes,
and orphaned anything pinned to it. Saying the number here puts the human's
say-so back above the floor and makes the next release the one nobody is
holding.
2026-08-05 01:38:35 -07:00
zooqueen 30acbc52d0 assistant: the send carries a credential, and the card stops lying about why
Hanzo CI/CD / cicd (push) Successful in 7m52s
CI/CD / cicd (push) Successful in 7m55s
Four defects, all measured on the live console with wire captures.

The completions request carried NO Authorization header at all. `streamChat`
reached for a bare `fetch` because a stream cannot go through the parsing
helpers that consume the body — and in doing so it left the one file where
identity is attached, so every send answered 401 while the identical
non-streaming call succeeded. So the client grows the door that was missing,
`restStream`: same `authedFetch`, same `baseHeaders`, raw `Response` back. The
gateway stream, text-to-speech and the guide's SSE step-runner all take it, and
a test drives the real path and reads the Bearer off the wire.

Preferences saved to `/v1/ai/preferences`, which is the AI gateway's casibase
handler and authenticates on a casibase SESSION — no Bearer can satisfy it, so
every save was refused "Please sign in first" while the user was signed in. They
now ride `/v1/prefs`, the per-user plane on cloud, which reads exactly the
credential the console already sends. That plane also SERVES a read, which the
old one never did: preferences were recovered from a snapshot of them carried in
the identity token, and a merge had to guess from two timestamps which side was
newer. Reading the document removes the guess, and the ordering machinery with
it.

The 401 card said "Your session expired" to people whose session was live. The
shared classifier can only see the status; whether this session is live is a
fact only the app holds, so the assistant decides it — a refusal with an
unexpired token reads as a surface that refused a signed-in caller, and "sign in
again" is kept for when it is true.

On a desktop the composer could not be clicked. Docking opened the sheet AND the
column and hid one with CSS, but the sheet is a modal dialog and hiding its
content does not stop it being modal: it covered the viewport, the column
painted inert beneath it, and every click landed on the dialog. The two shapes
now meet in one fact — the dock choice, a viewport that can hold a column, and a
page that is not already a composer — and exactly one of them mounts.

Also: the cross-app launcher sat in the lg-only topbar group, so on a phone the
grid of other Hanzo apps was unreachable. It moves to the account drawer, whose
trigger is on every viewport — one launcher, reachable everywhere.
2026-08-05 01:33:28 -07:00
hanzo-dev c1a8c8495d the tenant is the org, and "Team" is that org's members — say both
Hanzo CI/CD / cicd (push) Successful in 8m35s
CI/CD / cicd (push) Successful in 8m36s
Nothing here was ever a team. The referral, store, affiliate and author copy
called a customer organization "a team" ("when a new team signs up", "every
time a team runs your project"). The onboarding step called the tenant "your
workspace" in its title and "your team" in its blurb, while the org picker one
control away said "organization" — three words for one thing.

And Settings > Team was not a team either: its own description reads
"Organization members and roles", its index label already said "Members", and
the sibling button already said "Manage members". Only the nav label held out,
sitting in brand-scope as "Team, organization, and profile" — as if the two
were different things.

So: the tenant is an organization everywhere a human reads it, and the page
listing the people in it is Members.

Left alone: the `/team` route id (it is a link people already have), the `team`
plan tier, the helpdesk's HD Team doctype, and the tracker's real Team entity
whose boards are keyed ENG-1 — none of those are tenancy. "Teammate" and
"invite your team" stay too; those are people.
2026-08-05 01:17:31 -07:00
zooqueen a6bc25943f lists: numbered pages, and one stale claim corrected
Hanzo CI/CD / cicd (push) Successful in 4m51s
CI/CD / cicd (push) Successful in 4m51s
The observability footer offered Prev and Next and nothing else, so a reader 40
pages into a trace list reached page 1 by pressing Prev forty times. Pagination
from @hanzo/ui/product gives numbered jumps with an ellipsis. What stays in
Pager is the sentence only this surface can write — the row range — because only
it knows the shape of the meta its endpoints return.

ModelSelector's header said the package's selector was "the shadcn/Tailwind
build" and that the console, running Tamagui, could not use it. That stopped
being true: at 8.0.56 @hanzo/ui/models imports @hanzo/gui and
@hanzogui/lucide-icons-2, with no Radix and no class strings. Two real things
still block the swap and the comment now names them — the entry shape
(CatalogEntry vs ModelCatalogEntry, which decides family grouping) and the row
logos (this one draws curated brand marks through ui/ProviderLogo; the package's
draws none). A stale reason is worse than no reason: it retires a question that
is actually still open.

playground/ModelSelect had its import placed above 'use client' by the sweep
that moved it onto the package. Next reads the directive only as the first
statement, so the file had quietly stopped being a client module.
2026-08-04 23:33:49 -07:00
zooqueen 70b99204ac secrets: a masked field that is actually masked, and one copy button
`secure` on a gui Input is React Native's spelling, and the WEB build drops it:
`secureTextEntry` becomes type="password" only in @hanzogui/core's native.cjs —
grep the esm/cjs builds and the prop is not there at all. So every field in this
console that asked to be masked rendered its value in plain text, and the prop
made it look handled.

Five of them were secrets:

  AdminModule       a user's initial password
  KmsModule         a KMS secret value
  ConnectionsModule a provider API key
  AccountsTab       an AI provider key
  platform-apps     an app env secret — on a panel whose own caption promises
                    "never stored or shown in plaintext"

All five now use SecretInput from @hanzo/ui/product, which sets BOTH spellings
(`masked()` — neither alone is safe) and adds a deliberate reveal. ApiKeysModule
joins them: it printed a freshly minted key as bare selectable monospace, and
SecretInput's read-only mode copies while still masked, which removes the reason
to unmask at all.

The copy control was written seven times — sentry, code, git, webhooks,
Kubernetes, api-keys, playground, verify — each with its own confirmation window
(1200, 1400, 1500, 2000ms) and its own error behavior, one of which logged the
clipboard failure with the value in it. They are one import now. git/parts
re-exports it so its four views keep a single import site, and CopyRef stays as
the wrapper that knows how to spell a `repo file:line` reference.

The shared control also reports what was copied by LENGTH, never by content.

Typecheck clean; 3210 passing, 8 skipped.
2026-08-04 23:30:50 -07:00
zooqueen b88389e91c ui: delete the shadow fork, and reach for the package that replaced it
src/components/ui/ was a ~30-file copy of components that were hoisted OUT of
this console into @hanzo/ui/product (hanzoai/ui#36). The hoist landed; the copy
never left. Both halves kept getting edits, so the console has been shipping the
half nobody else could see.

Twenty modules go back to the package, 278 call sites now import
'@hanzo/ui/product', and 2,311 lines leave the tree. Everything the package
gained while the copy sat here arrives with them — chiefly the interaction
instrumentation: DataTable, PrimaryButton, ConfirmDelete, EmptyState, Segmented
and SearchInput now report what a user did through @hanzogui/telemetry, which
console already depends on, with no wiring and no provider to mount.

BackendStateCard needs two effects a presentational layer cannot have — sign in
again (401) and add credits (402). The package asks its host for them, so
src/entry/host.tsx answers once at the dashboard root and all 106 cards below
render the right affordance unchanged. One answer, not 106.

The package's components name their CSS classes without the hz- prefix
(`skeleton`, `row`, `tnum`, `fade-up`, `drag`), so app/layout.tsx now imports
@hanzo/ui/styles/motion.css. Without it a DataTable's skeleton, row hover and
tabular figures render unstyled — the classes simply would not match. Console's
own hz- twins stay in globals.css; 250+ call sites in console markup still name
them.

Four things did NOT come back, because the package version is behind the copy
rather than ahead of it, and swapping them would ship a visible regression:

  ProviderLogo — draws curated per-family brand marks (BRAND_MARK, inline
    Slack/GitHub, brand-colored monograms). The package knows two providers and
    renders initials for everything else.
  ProductIcon — picks its glyph color with contrastText(). The package hardcodes
    #ffffff, which is unreadable on 3 of console's 19 product accents and
    invisible on #FAFAFA.
  Donut, Metric, Charts — read console's theme: var(--color4/9/12) rather than
    hardcoded dark hex, and the monochrome RAMP rather than the package's
    eight-color SERIES. Swapping repaints every chart in the console.
  SlideOver, Toast — take their layer from lib/z.ts. The package hardcodes
    zIndex 1000 and 100000, which are two of the exact literals that ladder was
    introduced to eliminate.

Field and Filters shrink to the part that is genuinely console's:
FieldOptionSelect (a value/label picker the package has no twin for) and the
Filters bar (it takes a List, so it is app-coupled by construction). color.ts
keeps only tileRadius, which encodes console's radius scale.

ThemeToggle and HanzoMark are deleted outright — nothing imported either; the
mark everything actually uses is ui/Loader's BrandMark.

Reorder.test.ts and combobox/filter.test.ts go with the code they tested: 3222
tests to 3210. Their assertions cannot follow the logic upstream, because
@hanzo/ui/product publishes no node-resolvable subpath for its pure functions —
importing the barrel drags @hanzogui/next-theme into vitest and fails on
next/script. A consumer therefore cannot guard the contract it now depends on.

Typecheck clean; 3210 passing, 8 skipped.
2026-08-04 23:23:59 -07:00
zooqueen d78a71e50d deps: @hanzo/ui 8.0.56 — the release that carries the product set
The console's own src/components/ui/ is a copy of components that were hoisted
into @hanzo/ui/product (hanzoai/ui#36) and never deleted here. 8.0.56 is the
release where every one of them lands published, so the copy has somewhere to
go. Typecheck clean and 3222 tests green on the bump alone — nothing in this
tree reaches an API that moved between 8.0.38 and 8.0.56.
2026-08-04 22:42:35 -07:00
zooqueen f8d83250f7 models: reach first, and the chrome carries only navigation
Hanzo CI/CD / cicd (push) Successful in 6m0s
CI/CD / cicd (push) Successful in 6m0s
The Models page listed every family in order but gave the user no way back to
the models they actually use: every visit started at the top of a long list.
A Recent + Suggested chip strip now sits above the families — recents are the
user's own trail (recorded where a model is genuinely exercised: a chat turn
sent, a catalog detail opened; account-persisted, so it follows them across
devices), suggestions are one live rung per pinned family, which puts the
house default first by construction. Both hide while a search owns the page,
and neither ever shows a fabricated chip.

The topbar slims to what navigation needs: status, docs, the cross-app
launcher, and the account drawer trigger — now on every viewport. The network
chip leaves the bar: production is the default environment, so switching is a
deliberate act you open the drawer for, not chrome you wear. The theme toggle
leaves too — the account menu already carries theme, and two controls for one
setting is one too many. Alerts stay at /alerts and in the drawer.

Chat's stale copy caught up with the Enso default: the surface no longer
greets you with the name of a family it stopped defaulting to.
2026-08-04 21:48:19 -07:00
hanzo-devandzooqueen a7a7fc9ef6 console: the cross-app launcher it never had
Console had no way to reach another Hanzo surface. The two "switchers" in its
topbar are about where you are INSIDE console — ContextSwitcher picks the org
and project, ScopeSwitcher picks the network — and the LayoutGrid in the sidebar
lists console's own products. A fullscreen launcher existed once and was removed
as a duplicate of that product filter, which it was; nothing replaced the part
it was actually for.

@hanzogui/shell has shipped HanzoAppLauncher the whole time, and chat already
wears it. It is inline-styled and React-only, so it drops into a Tamagui topbar
untouched — no wrapper, no theme bridge. quickSwitchKey={false} because
CommandPalette.tsx:806 already binds ⌘K; the shell's prop doc names Console as
the reason that opt-out exists, so this integration was designed for and then
never landed.

Also takes shell to 8.1.1, where the panel is portalled out of its host's
stacking context. This topbar does not clip it today, but the version that
cannot be clipped by a container the component never sees is the one to pin.
2026-08-04 21:48:19 -07:00
hanzo-devandzooqueen 7c6305c455 toast: memoize the context value so raising a toast cannot raise another
The provider built its context value fresh on every render and passed it
straight to Provider. Its own state changes on every toast, so each toast
handed every useToast() consumer a new identity. An effect that both
depends on the toast api and raises a toast therefore re-triggered itself:
raising one rendered the provider, which handed the effect a new api,
which raised another. The OAuth return did exactly that and stacked twelve
identical cards down the viewport.

Stripping the query params could not have stopped it. router.replace is
asynchronous, so the params are still readable on every render in between
-- and the effect calls load(), which causes exactly those renders. The
integrations return now latches once per mount; stripping the params
covers the NEXT mount, which is the case it can actually address.

The callbacks were already stable, so memoizing the object is the whole
fix, and it makes depending on the toast api safe everywhere, not just
here.

Proven by render, since no unit test can see a render loop: reverted, the
new spec counts 12 toasts; fixed, it counts 1.
2026-08-04 21:48:19 -07:00
zooqueen 15da85d0ac the assistant answers as Enso, and asks for anything
The console preselected zen5-flash and greeted you with 'Message
zen5-flash…' — the machine's model id where an invitation belongs, and
a stand-in family where Hanzo's own belongs. Enso left limited preview,
so the house family is what an unchosen caller should get: DEFAULT_MODEL
is enso-flash, the family's free rung, which keeps the property the old
default was chosen for (a trial or welcome balance still answers on the
first message). The placeholder now says what the box is for.

The preselect's fallback follows: the house family, then the first
model discovery offers.
2026-08-04 21:48:19 -07:00
hanzo-dev d20f0859f2 fix(entry): an anonymous console visitor starts the IAM hop, not a second landing
Hanzo CI/CD / cicd (push) Successful in 4m45s
CI/CD / cicd (push) Successful in 4m45s
Clicking "Sign in" on cloud.hanzo.ai appeared to do nothing. It was not a redirect
loop and nothing returned an error — every hop was HTTP 200, which is why the page
"looked fine".

console.hanzo.ai/ served a SECOND copy of the Hanzo Cloud marketing page, wearing the
byte-identical @hanzogui/shell header. So the journey was:

  cloud.hanzo.ai  [Sign in] -> console.hanzo.ai/   (same header, same "Sign in")
  console.hanzo.ai [Sign in] -> /signin            (one button, nothing else)
  /signin [Log in with Hanzo Cloud] -> hanzo.id

Three clicks, and the first landed on a page indistinguishable from the one it left.
That reads as a re-render, and users stopped there.

The console is the APPLICATION; the marketing face of Hanzo Cloud is cloud.hanzo.ai.
Serving a third copy of it on the app host is what created the illusion. So `/` is no
longer a special surface: everything except the two auth routes is `guarded`, and a
definitively-anonymous visitor STARTS the authorize hop instead of being parked on an
interstitial that only asks "did you mean it?".

  cloud.hanzo.ai [Sign in] -> hanzo.id

`startReauth()` is reused rather than `signinRedirect()`, so a deep link (/models)
returns to /models after login instead of dumping the user on the home.

SIGN-OUT IS PRESERVED, the one hazard here. Sign-out lands on /signin, which keeps its
button and never auto-authorizes -- IAM may still hold its own session, so authorizing
there would sign the user straight back in and make signing out impossible. A callback
failure lands on the same surface, so a broken hop cannot loop.

Removes PublicLanding + landing-surface, now unreachable (-269 lines net).

Tests: 258 files / 3211 assertions pass; tsc --noEmit clean.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 20:12:20 -07:00
hanzo-dev 53375e44ed test: make the rescued gui-8 guard true on this lineage
Hanzo CI/CD / cicd (push) Successful in 5m3s
CI/CD / cicd (push) Successful in 5m4s
The rescued gui8-props guard lands red here because origin fixed the same
line-height bug a different way and grew a shell the guard's fixtures predate.
Three corrections, none of them a weakened rule:

- gui8-props: blank out whole comment SPANS, not lines whose opener is `//`.
  The module already says a doc comment must be free to name the bug it
  documents; the fix comments at the call sites are multi-line `{/* … */}` JSX
  blocks, so the continuation lines were still scanned and PitchHero's own
  description of the bug counted as the bug.
- Charts: `lineHeight: 1.35` -> `'1.35'`. This one is a plain `<div>`, where
  React already emits it unitless, so the CSS is unchanged — but the string form
  is what the rule asks for and stays correct if it ever becomes a gui component.
- shell.test: `tracker` is a shell now (the rescued tracker pick), so the
  descriptor table's expected ids include it.

tsc --noEmit: 0 errors. vitest: 3217 passed, 8 skipped, 0 failed.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
(cherry picked from commit 6ca6cc8fe1)
2026-08-04 18:59:00 -07:00
hanzo-dev 87abc48add fix(gui): the gui 8 props that silently rendered nothing
The 8.x dependency convergence was already done and building. What was left is
the half it cannot catch: @hanzo/gui accepts any prop and drops the ones it does
not recognise, so a gui 7 spelling type-checks, builds, ships, and does nothing.

Asked the renderer instead of the type-checker (scripts/gui-prop-probe.mjs
renders a prop and reads the host element and emitted class back out), which
settled the open `tag` vs `render` question the interrupted session left and
found four live defects:

  tag="a"                -> <div tag="a">        an inert link
  style lineHeight: 1.1  -> line-height: 1.1px   a ratio is not a length

- CloudflareModule's pages.dev/workers.dev URL chip and every ContactModule
  channel card (mailto included) were <div>s. Both now render="a".
- PublicLanding's hero title, PitchHero's headline and the CodeSamples block
  shipped line-height 1.1px/1.12px/1.6px — a wrapped title, and every line of a
  code sample, drawn on one baseline. gui appends px to a bare number in `style`
  as much as in a prop, so the ratio is now spelled as a string.

Because the type system provably cannot gate this class, the gate is the source
text: src/lib/gui8-props.ts holds the four verified rules and its suite runs them
over every file that imports gui (335 of them). Scoping by import is what keeps
it precise — a `{ tag: 'v1' }` image tag in a pure-logic module is out of scope
by construction, as are the rule module and its own fixtures.

Also: typescript stays on 5.x, and that is correct rather than a shortfall.
typescript@7.0.2 is genuinely the native Go compiler (its tsc is a statically
linked ELF from typescript-go/cmd/tsgo), but it ships only
{version, versionMajorMinor} on the main entry, while `next build` calls
ts.parseJsonConfigFileContent / ts.JsxEmit / ts.ModuleKind /
ts.ModuleResolutionKind — all undefined there. TS7 breaks Next exactly the way it
breaks tsup, for the same reason. @typescript/native-preview (7.0.0-dev, behind
stable) stays removed.

Verified: next build ✓ compiled successfully in 25.8s, 20/20 pages;
tsc --noEmit exit 0; vitest 2903 passed / 8 skipped (the pre-existing enso-bench
parity self-skip); build:embed ✓ static export ready, 30 handlers restored.
Zero tailwind/radix/shadcn, not even transitively.

(cherry picked from commit c9a4d92554fa8b8274801218a2c14a0044cf17bf)

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
(cherry picked from commit 19e6d2e250)
2026-08-04 18:59:00 -07:00
hanzo-dev 8153606ac8 wip: preserve agent work interrupted by session limit
(cherry picked from commit bac7cc5bf68eb03973ab0bec5d28fcc23b2a2ee8)

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
(cherry picked from commit e077a2285a)
2026-08-04 18:59:00 -07:00
hanzo-dev c4ac4eff3f wip: preserve agent work interrupted by session limit
(cherry picked from commit c256eb39aab2280121bfec766fdea3ea92f71091)

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
(cherry picked from commit 775d9ab840)
2026-08-04 18:59:00 -07:00
hanzo-dev 922ba7d676 wip: preserve in-flight telemetry + event bump before 8.x convergence
(cherry picked from commit 0045bac1727d1251991f1106b8f2bccb1acb34ac)

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
(cherry picked from commit 909388da5e)
2026-08-04 18:59:00 -07:00
hanzo-dev 15b8cff948 refactor(commerce): render the shared CommerceResource, not a second copy
@hanzo/ui/product's CommerceResource documents itself as what BOTH the console's
Store category and the standalone Commerce admin render. That was false while
this directory kept its own copy — the console imported the local one, so the two
surfaces could drift silently and the shared component's docstring lied.

The local fork is deleted and the import repointed at @hanzo/ui/product, which is
what the Commerce admin already renders. One component, two surfaces, and the
docstring is now a fact.

tsc --noEmit: exit 0, 0 errors.

(cherry picked from commit e9524b5c4588ca6664ca23c341863faadaa48141)

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
(cherry picked from commit 4dbae2115d)
2026-08-04 18:59:00 -07:00
hanzo-dev bbff6b3a82 feat(tracker): Linear-grade standalone tracker at tracker.hanzo.ai
Rebuild the console Tracker into a SOTA, Linear-grade issue tracker over the
native cloud /v1/tracker surface, and give tracker.<brand> its own standalone
shell (the catalog chrome is stripped; the module's own views ARE the nav).

- Standalone shell: `tracker` ShellId + isTrackerHost + shellFromHost +
  PRODUCT_SHELLS.tracker (indexLabel "Issues", home "tracker"). Registry entry
  repointed to the native cloud backend with :view / :view/:sub routes and
  subpages (My Issues, Teams, Cycles, Roadmap). Host-detected exactly like
  billing./sentry./dns. — one image, one more face.
- Unified board: client-side cross-project merge (listAllIssues) → ONE filterable
  board across every team AND every mirrored GitHub repo (the App-webhook lane's
  GH mirror), with no second store. Group by status/priority/assignee/team,
  filter (status/priority/kind/source/assignee/label/team), live search,
  List <-> Board.
- Issue detail pane: one-click status, full edit, agent hand-off (assignee +
  `agent` label -> the cloud coding seam opens a linked PR), the issue<->branch
  <->PR chain (linkedPRs), git.hanzo.ai + upstream GitHub links, epic children.
- Keyboard-first: c=create, /=search, g-chord nav (g i/m/t/c/r), j/k/arrows/
  Enter/e, and — standalone only — a capture-phase Cmd-K command palette that
  never fights the console's global Cmd-K.
- Cycles (derived current iteration + progress) and Roadmap (epics + their
  ExtRef children), both from real data — honest, never fabricated.
- Richer Issue type (kind/source/repo/extRef) so GitHub-mirrored + agent-PR rows
  render distinctly; a "Sync GitHub" action triggers the org backfill.

Pure decisions decomplected to tracker/logic.ts (27 vitest green) + the shell
face test. Native @hanzo/gui v5 + React 19 — zero Svelte. Consumes the concurrent
App-webhook lane's GitHub mirror through the existing endpoints (no backend
duplication; the tracker data model + views + agent flow are owned here).

(cherry picked from commit 781db541e8476cb7e0bf1800c55f86f6d9162b50)

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
(cherry picked from commit 613abbabb5)
2026-08-04 18:59:00 -07:00
zeekayandClaude Fable 5 83e6eb4667 Read design tokens from the published package, not a vendored copy
Hanzo CI/CD / cicd (push) Successful in 5m16s
CI/CD / cicd (push) Successful in 5m16s
app/design/ carried a verbatim vendor of @hanzo/design's tokens, synced by hand
on 2026-07-24 because the package wasn't on npm. It is now (0.4.6), and the
vendored copy had already drifted a full border rework behind it — pure-black
ground, solid #1f1f1f borders, grey destructive, the pre-0.4.x palette the rest
of the fleet just moved off of. index.css now imports the real dependency and
the seven vendored token files are deleted; the two console-only tokens they
added (--border-card, --border-hairline) were unused. The Tamagui theme layer
in globals.css derives --colorN from the neutral ladder, which is unchanged, so
only the semantic surfaces adopt the rework — the intended unification.

tsc 0 errors, 3157 tests pass, next build clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 15:53:28 -07:00
zeekayandhanzo-dev 32c88caa12 Merge branch 'lda' into mrg
Hanzo CI/CD / cicd (push) Successful in 6m35s
CI/CD / cicd (push) Successful in 6m36s
# Conflicts:
#	LICENSE

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 14:18:13 -07:00
hanzo-dev b4c71e1aac Merge branch 'fix/restore-upstream-license'
Hanzo CI/CD / cicd (push) Successful in 7m16s
CI/CD / cicd (push) Successful in 7m17s
Move vendored MIT copyright notices out of LICENSE into NOTICE, and add the
full MIT permission text alongside them. LICENSE is reserved for this
project's own BSD-3-Clause grant.
2026-08-04 12:16:05 -07:00
hanzo-dev c8295794fa Merge remote-tracking branch 'origin/ux/console-fab-apps-nav' into try-ux
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 12:08:51 -07:00
hanzo-dev 40da2fc17d o11y metrics: follow the read to its own name
cloud's per-product RED window and upstream o11y's metric-NAME CATALOG were both
answering GET /v1/o11y/metrics. Two different questions at one address, which is
why cloud had to suppress the module's real read to boot at all. cloud's moves to
/v1/o11y/product/metrics — the honest name for "one product's requests, error
rate and p95" — and the bare name goes back to the catalog it describes.

This is the read behind the platform-apps drawer, so it must land WITH the cloud
change (hanzoai/cloud fix/o11y-route-ownership): before it, this path 404s;
after it, the old path answers the catalog's shape instead of RED numbers.

`o11y` is still the allow-listed head in proxy-allow.ts, so the BFF is unchanged.
vitest 6/6 on the touched suite. tsc reports 3 errors in PublicLanding.tsx and
landing-surface.ts for a missing `@hanzogui/shell` — pre-existing here, present
on origin/main and untouched by this change, which edits only string literals.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 11:38:17 -07:00
hanzo-dev d09d3cae1b the assistant has one home, and the app directory is one you can walk
The assistant's way in was the only shape of it this module did not own: two
small buttons in the topbar, wedged between the search box and the account
chrome, putting it in a third place and squeezing a 390px header to five
controls. Both move into one floating control bottom-right — the same
openChat/startVoice, the same surface, in the corner it actually appears in.

All products was a directory you could not walk. Each app rendered as a plain
DIV with role=null and cursor:auto — measured, not read — so the one place the
whole catalog is browsable had exactly one live control per row, the pin. The
row opens its app now, through the shared openProduct, and closes the pane
behind it; pin stays a separate control that stops the press from bubbling.

And a pin made after sign-in was thrown away on the next reload. Preferences
are read off the identity token's claims — a snapshot taken when that token was
minted — and once a user has saved anything the token CARRIES one, so the merge
let an hour-old snapshot beat a newer write. It is now told the ordering it was
missing: the token's own iat against a stamp written only when the server
acknowledges a save. A save that never landed earns nothing, so this orders two
real writes rather than inventing durability in localStorage. There is still no
GET for the document; the smallest seam is named in preferences-core.

Five white-filled buttons competed on the home, counted by computed background
luminance. Now one: the getting-started card's active step.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 10:38:14 -07:00
hanzo-dev 0207df0759 fix(license): move vendored MIT notices out of LICENSE into NOTICE
console is NOT a fork -- its root commit is Hanzo's own and Hanzo is the
correct copyright holder. LICENSE is now a clean, canonical BSD-3-Clause
(verified against the SPDX text) naming Hanzo alone.

The seven retained MIT notices for vendored code (Tamagui, react-native-web,
Radix, Framer Motion, WorkOS et al) were attribution living in LICENSE. They
move to NOTICE, with the MIT permission notice reproduced so the obligation
travels with them.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 09:59:29 -07:00
zooqueen a0a489986d onboarding: Continue keeps its place, and no step can strand you
Hanzo CI/CD / cicd (push) Successful in 4m41s
CI/CD / cicd (push) Successful in 4m42s
StepActions was the last child of a flex column, so its y was whatever the
step's content happened to add up to — Continue sat at a different height on
every step and a user clicking through had to re-aim each time.

It is a slot on StepShell now, above a content area with a reserved height:
one placement, decided in one place. Taller content still grows.

The workspace step also offered no way past it without naming the workspace,
which is optional — it skips now. Consent deliberately still has none:
accepting the Terms is not optional, so an affordance that skipped them would
be dishonest, and Continue stays disabled until the box is ticked.

Proven by geometry in a browser, because the JSX move is invisible to a unit
test: both shapes render the same button with the same label. Removing the
reserved height fails the spec; restoring it passes.
2026-08-04 06:57:10 -07:00
hanzo-dev b6e35ee042 legal: console is MIT OR Apache-2.0 (HIP-0137)
Hanzo-original work; BSD-3 is out of scope for hanzoai under HIP-0137.
LICENSE becomes the dual pointer, LICENSE-MIT / LICENSE-APACHE carry the
texts, and the upstream MIT copyright notices previously kept in LICENSE
are retained verbatim in LICENSE-MIT. README, LLM.md, NOTICE, Dockerfile
and package.json follow.
2026-08-04 01:28:13 -07:00
hanzo-dev 06e416365b retire the ML Pipelines (Kubeflow) product — its whole backend is gone
Hanzo CI/CD / cicd (push) Successful in 5m28s
CI/CD / cicd (push) Successful in 5m29s
The `ml-pipelines` product (label "ML Pipelines", description "Orchestrated
training and evaluation pipelines (Kubeflow)", status enabled, slug aliases
`/kubeflow` and `/mlpipelines`) read nothing but Katib and Trainer: its three
sources were GET /v1/train/health, GET /v1/train/experiments (its "Pipelines")
and GET /v1/train/jobs (its "Runs"). Those CRDs are not served by the cluster and
cloud deleted the ops, so every one of them is now a 404 and the product's only
possible state is an error card. `KubeflowApi` had no other consumer.

There is nothing to repoint it at. Per-org model-shape SEARCH — the job Katib
was installed for — is /v1/risk/search, which runs natively in the org's own
sandbox and needs no CRD.

Also removed from the `/training` BFF allow-list: `train/jobs`,
`train/experiments`, `train/health`. An allow-list entry is a declaration that a
path exists; those three no longer do. `ml/models` (kserve, live and serving) and
the eight `finetune/*` broker heads stay exactly as they were.

STILL WIRED TO THE DELETED PATHS, deliberately left for its own change:
FinetuningModule's Jobs tab, its loss chart and NewTrainingPanel still call
TrainApi.listJobs/createJob/experiments. Gutting them would delete a product;
the right fix is to repoint them at the /v1/finetune/* broker — already
allow-listed two lines below in the same proxy, and a richer surface (presets, HF
pickers, cancel, deploy-to-serving). That is a payload-contract change with its
own verification, not a line to slip into a deletion.

Verified: match-core 37/37; the full suite is 3175/3175 on this tree, which is
pristine origin/main's 3177 minus exactly the two retired alias assertions, with
the same single pre-existing social.test.ts collection failure. tsc reports SIX
FEWER error files than pristine origin/main (413 vs 419 — the local
@hanzo/gui shorthand drift this box has either way) and not one error in a file
this change touches.
2026-08-04 00:32:39 -07:00
zooqueen da268976b8 auth: a refusal is not always a failure
Hanzo CI/CD / cicd (push) Successful in 4m59s
CI/CD / cicd (push) Successful in 4m59s
The callback screen said "Sign-in failed." to everyone. Cancel a Google consent
screen and the product told you it broke; return with a session the issuer would
not reuse and it said the same thing. Both are ordinary OIDC answers, not faults,
and the words were the only thing wrong with them.

The SDK is right to report them uniformly — it validates `state` before honouring
an error branch, because /callback?error=… is a plain GET anyone can hand a
victim, so nothing downstream may DECIDE from that code. But it may READ it: the
screen now classifies the code for WORDING only, with authority left where it was.
access_denied says the sign-in was cancelled; login_required / interaction_required
/ consent_required say the session ended and lead with Sign in — the action that
actually resolves them, rather than a Retry that repeats the same refusal. Anything
unrecognized prefers the issuer's own error_description, which names a real cause
far better than a generic line ever did.

Both strings arrive from a redirect, so both are bounded before they reach a
screen whose only job is to say what happened.

This also unblocks silent SSO: a top-level prompt=none attempt returns
error=login_required to the app, and until now that landed on "Sign-in failed."
— a wrong screen for the most ordinary outcome that flow has.

Pure classifier, tested apart from the browser: 9 cases covering each class, the
issuer's own words, the unbounded-description bound, and a malformed query, which
must not throw on the one screen a person cannot navigate away from.
2026-08-03 22:21:07 -07:00
zooqueen 2271c29297 profile: a photo you can change, instead of one you can only look at
Hanzo CI/CD / cicd (push) Successful in 5m4s
CI/CD / cicd (push) Successful in 5m5s
The Profile card rendered `avatar` read-only and offered "Edit in IAM" — which
links to an IAM that cannot set one either: its only writers are federation (a
GitHub avatar_url, an OIDC picture claim) and SCIM. A user who signed up with a
password had a monogram and no way out of it.

Now the card has "Add photo" / "Change photo". It posts to cloud's new
POST /v1/avatar, which stores the image in S3 and writes the URL onto the IAM
user row, so the change is visible to every product rather than this tab —
`reload()` re-reads the session so the rest of this tab agrees too.

The photo is downscaled to 512px in the browser first. A phone original is
several MB and would be served to every viewer of every page showing that face;
512 is larger than any surface renders it. The server still caps the body — this
is the courtesy, not the guard — and a source the canvas cannot decode is sent
verbatim so the server's format check stays the authority.

postForm is the ONE upload door, added because `request` JSON-encodes its body
so a file cannot travel that way. It shares everything else: the same
authedFetch (bearer + refresh) and the same baseHeaders tenant stamp, so an
upload is org/project-scoped exactly like a read. Content-Type is deliberately
deleted rather than set — the browser must write it itself to carry the
multipart boundary.

A refusal shows the SERVER'S reason: "a profile photo must be a PNG, JPEG, GIF
or WebP image" is actionable where "Request failed" is not.
2026-08-03 18:03:31 -07:00
zooqueenandhanzo-dev 9da398459e deps: iam 0.21.6 — the SDK whose storage default survives prerender
Hanzo CI/CD / cicd (push) Successful in 4m54s
CI/CD / cicd (push) Successful in 5m24s
The previous commit dropped console's explicit `storage: sessionStorage` so the
session would be shared across tabs, and the build died on the next push:

  Error occurred prerendering page "/auth/callback"
  ReferenceError: sessionStorage is not defined

`^0.21.2` resolved to a build whose default was the bare global
`config.storage ?? sessionStorage`, evaluated in the IAM constructor. In a
browser that is merely the wrong lifetime; under Next's static export it runs in
Node, where the identifier does not exist at all, so the constructor threw and
the export exited. Passing `sessionStorage` explicitly had been masking it —
the callback page constructs the SDK at module scope, and the value it passed
was the client-side one Next never evaluates on the server.

0.21.6 resolves storage through a guarded probe (`typeof localStorage`, then an
actual write, since privacy modes expose the object and throw on setItem) and
falls back to an in-memory Storage. So prerender gets a real object, the browser
gets localStorage, and neither needs a caller to know which one it is.

The caret already permitted 0.21.6; the lockfile is what pinned 0.21.2, which is
why CI installed the broken one while a local `pnpm install` would not have.
Verified by running the export that failed: 13/13 static pages, /auth/callback
among them.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 17:18:58 -07:00
zooqueen b9d31aa305 landing: the CTA starts the sign-in, instead of asking again
Hanzo CI/CD / cicd (push) Failing after 1m44s
CI/CD / cicd (push) Failing after 1m56s
Reaching a login form took three clicks across two hosts: "Sign in" on
cloud.hanzo.ai landed here, "Sign in" here routed to /signin, and /signin's
entire content was one button that starts the authorize redirect. The middle
step asked "did you mean it?" about an answer already given one click earlier.

Both CTAs now call signinRedirect() directly. /signin is unchanged and is still
the one sign-in surface — the guarded entry sends anon visitors there, sign-out
lands there, and deep links resolve to it. It deliberately does NOT auto-redirect
on load: after an explicit sign-out IAM may still hold a session, so an automatic
authorize would sign the user straight back in and there would be no way to leave.
The button there means something; the hop to reach it did not.
2026-08-03 17:07:58 -07:00
zooqueen 1b50c31a96 auth: one token store, and every tab can see it
The console passed `window.sessionStorage` to the IAM SDK explicitly, so the
session was scoped to the tab that established it. A middle-clicked link opened
signed out. Nothing here documented that as a security posture — the comment
merely restated the SDK's default — so this drops the override and lets the SDK
own the decision, where the other surfaces inherit the same one.

The hand-rolled `memoryStorage()` goes with it. It existed only because the SDK
used to touch a bare `sessionStorage` global that is undefined under SSR; the
SDK now falls back to memory itself, so the shim has no remaining job.

Worth naming: console already owns a stronger mechanism than either Web Storage
area — `src/lib/server/session.ts`'s AEAD-sealed httpOnly `hz_session`, written
precisely so a session "cannot lapse out from under a working tab". The client
half regressed off it onto the SDK's per-tab store. This change fixes the tab
bug; moving the credential itself out of script-readable storage is the separate,
larger piece of work that cookie was built for.

`hz_return_to` stays in sessionStorage on purpose: "come back to where I was" is
a property of the tab that navigated away, not of the session.

The e2e seeds move with the store. They forged tokens into sessionStorage, which
the SDK no longer reads — left alone, every primed spec would have started
signed out and the suite would have failed for a reason that had nothing to do
with what it was testing.
2026-08-03 17:05:27 -07:00
zooqueen 846069c98b onboarding: a refused create is not a complaint about the name
Hanzo CI/CD / cicd (push) Successful in 4m53s
CI/CD / cicd (push) Successful in 4m53s
Reported: signing in led to a create-account flow, an organization named
"coffee cups", and a refusal read as "organization name taken". The name was
free — there is no org matching it anywhere in the IAM store.

What actually fired is the FIRST-RUN GATE (iam internal/oidc/provision.go:156):
onboarding MOVES the caller into the org it founds, so founding a second would
strip this account from — and orphan — the org it already admins, along with that
org's billing account. IAM refuses with 409 "you already have an organization".
The console renders the server's message verbatim, so the customer was told about
organizations immediately after typing a name, and read it as being about the name.

This screen is only ever rendered when the client resolved an EMPTY owner
(entry/scope.tsx: `if (!owner) return <OrgOnboarding />`) — and an owner that is
empty because a read failed is indistinguishable from a brand-new account. So the
refusal is the first RELIABLE signal that the session was wrong, and the only
honest thing to do with it is recover, not apologise: read the account, and offer
the way into the org this identity is actually in.

THE STATUS ALONE CANNOT DECIDE THAT, which is the part worth naming. /v1/iam/onboard
answers 409 for two opposite reasons — the first-run gate above, and a name
genuinely held by another tenant (provision.go:190). What separates them is whether
this account is itself in an org: only the first has somewhere to go. readOnboardRefusal
takes that reading and returns recover-or-report, so a customer who really did pick
a taken name still sees the accurate message and is not bounced into someone
else's org.

Recovery is offered, never automatic — re-authenticating on our own would loop
forever against whatever left the session ownerless in the first place.

24 tests pass in onboarding.test.ts, 4 of them new and covering both 409s.
NOT yet browser-verified: this needs a real signed-in account whose session
resolves without an owner, which is the state I cannot manufacture locally.
2026-08-03 13:48:19 -07:00
hanzo-dev 06b7f5416a ia: one trigger for the command palette, not two
Hanzo CI/CD / cicd (push) Successful in 5m25s
CI/CD / cicd (push) Successful in 5m26s
The topbar carried a search box reading "Search or jump to… ⌘K" and, directly
beside it, an "Apps" button. Both called the SAME `useCommandPalette().open`.
Two adjacent triggers for one surface read as two different destinations, and
"Apps" in particular implied an app directory that does not exist — the
palette is what opens either way.

The mobile drawer had the identical pair, so it goes there too; otherwise the
rule would hold on a laptop and not on a phone.

Nothing is lost: the remaining control is the one that SAYS what it does and
shows its shortcut. This is the fifth of the five switchers, and the count on
the Overview at 1440x900 is now three — one per question:

  before  project (top-right) · account (bottom-left) · network (top-right) · apps
  after   context (top-left)  · account (bottom-left) · network (top-right)

Measured in a browser against the real shell, not counted by reading the JSX.

  tsc --noEmit  exit 0

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 12:37:06 -07:00
hanzo-dev 80cbfcbbb1 ia: org and project are one question, so they are one control
Five controls on one screen answered "who and where am I", and three of them
answered it from three different corners: the org mark top-left, the account
(which also switched tenant) bottom-left, and a project chip top-right beside
the network. Org and project are not two questions — they are "which tenant,
and which slice of it" — so they condense into ONE control at the top-left,
under the mark that already anchors the tenant.

Each question now has exactly one place:
  WHERE  ContextSwitcher, top-left     org + project
  WHO    AccountMenu, foot of the rail identity, team, settings, balance, exit
  MODE   ScopeSwitcher, top-right      network, with its tier dot

The network deliberately stays its own always-visible chip: it is a global
mode rather than a place, and the dot (mainnet green / testnet amber) is a
destructive-environment guard you must be able to READ without opening a menu.

There is still exactly ONE org switch. `org-scope.switchOrg` — the seam that
persists the scope and reloads so every module refetches under the new
`X-Org-Id`, and the seam tenant scoping and billing attribution hang off — is
imported, never reimplemented. The admin-gated, server-PAGED cross-tenant
search moved across whole rather than being reduced to a first page, so an
admin can still reach a tenant nobody is a member of. Verified against a
mocked cross-tenant list: typing "acme" narrows the org group to exactly
"Acme Industrial".

`adminOrgState` had no caller once the account menu stopped switching tenant,
so it is deleted rather than kept warm. The invariant it protected is now
pinned directly: `org-state.test.ts` scans every source file for `switchOrg(`
and asserts the caller set exactly. Mutation-tested — re-adding a switch to
AccountMenu turns it red, removing it turns it green.

One row, not two: the org/project/network menus rendered the same row shape
from two copies. That is `ui/MenuRow` now, so they cannot drift.

FINDING — a missing primitive, not worked around. A single-select list wants
ARIA `listbox`/`option`, and @hanzo/gui types `role` as React Native's
accessibility-role union: it admits `option` but NOT `listbox`, so an `option`
could never be given the parent ARIA requires. Used `radiogroup`/`radio`, the
single-select pair gui carries whole. @hanzo/gui should carry `listbox`.

`FieldText` grew an `ariaLabel`: a search field with no visible label had no
accessible name, and that is the console's own primitive to extend.

  tsc --noEmit  exit 0
  vitest        3156 passed, 8 skipped (255 files)

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 12:36:44 -07:00
hanzo-dev 4656c316f4 build: react-native-svg — the dependency gui 8 needs and the bump forgot
`deps: console onto gui 8.x` moved @hanzo/gui 7.3.0 -> ^8.0.0. gui 8 pulls
@hanzogui/lucide-icons-2@8.0.0, whose ESM build does

    import { Svg, Path } from "react-native-svg"

while declaring react-native-svg in NEITHER `dependencies` NOR
`peerDependencies`. Nothing installs it, so webpack cannot resolve it, and
`next build` dies with a wall of "Module not found: Can't resolve
'react-native-svg'" — once per icon. The `react-native$: react-native-web`
alias in next.config.mjs does not cover it: `$` is an EXACT-match alias and
react-native-svg is a different package.

So console main has been UNBUILDABLE since that bump. That is why
ghcr.io/hanzoai/console has no v8.5.33, v8.5.34 or v8.5.35 — the tags were
cut, the images were never published, and universe's bump to v8.5.35 had to
be reverted to v8.5.32 to keep a pullable image. The three fixes riding those
tags (the 1px display line-box, the missing 404, @hanzo/iam 0.21.2) have been
finished in main and dark in production the whole time.

The estate already had the answer: every app on gui 8 carries this dependency
explicitly (hanzo.ai, hanzo.sh-std, app-std, console-std all pin 15.15.5),
and every app still on gui 7.3.0 does not need it. Console took the bump
without the companion. This restores the one established pairing.

Measured, not assumed:
  before  next build -> exit 1, "Can't resolve 'react-native-svg'"
  after   next build -> exit 0, 42 routes emitted
  pnpm install --frozen-lockfile -> exit 0 (the Dockerfile's exact command)

Lockfile regenerated with the DECLARED pnpm 11.17.0 (node 22), not the pnpm 9
on PATH — pnpm 9 renormalizes peer-suffix keys and churns 1061 lines. This
diff is +96/-0, purely additive.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 12:11:49 -07:00
zeekay d9bccc9e8d console: 400+ models, and two comments that cannot keep a number current
Hanzo CI/CD / cicd (push) Failing after 1m2s
CI/CD / cicd (push) Failing after 1m2s
The guide headline said "100+ models. One OpenAI-compatible API." — wrong against
api.hanzo.ai, which serves 444 priced entries (and lists 107 callable ids). 400+
is true under the catalog reading and is what the rest of the estate now says.

Two comments carried counts they had no way to maintain — "85+ models across a
dozen families" and "~340 models" — each written when it was true and silently
wrong since. A comment that states a number the code does not compute is a comment
that will lie; both now describe the shape without asserting a size.
2026-08-03 09:45:29 -07:00
zooqueen ffd291fedf keys: two shapes — sk- authenticates, pk- does not
Hanzo CI/CD / cicd (push) Failing after 1m5s
CI/CD / cicd (push) Failing after 1m5s
The console spoke of three key prefixes. IAM resolves two: sk- (secret,
same-tenant pinned) and pk- (publishable, refused at the auth door). An
hk- string is not a key, so nothing in a user-facing surface may offer it.

The one behavioural change is the workbench Inspector's prefix filter,
which routed hk-/sk-/pk- to the account key status; it now recognizes
sk-/pk- and refuses anything else, with the error text to match. The
pasted value is never sent — the Inspector reads the session's own key
status — so this narrows what the UI calls a key without touching auth.

The rest is copy: placeholders, Bearer examples, product Auth facts and
the guide step now name sk-, the credential the /keys route actually
mints. The workbench legend had sk- as a "provider key" and implied all
three ride the Authorization header; it now separates the secret that
authenticates from the publishable value that never does.
2026-08-02 13:31:55 -07:00
hanzo-dev 4da48f9274 billing: call the route names the server registers, and stop minting our own credit
Commerce dropped the compound prefixes from its billing routes. The /v1/billing/
namespace already says "billing", so billing/payment-methods stuttered. Both
servers now register only the short names, and the live edge agrees:
/v1/billing/methods 401, /v1/billing/settings 403, /v1/billing/alerts 403, while
payment-methods, payment-config and spend-alerts are all 404.

The console never followed. Its card list, its card save, its card detach and its
Square-config read were all pointed at routes that no longer exist, which means a
new user could not add a card. This was the revenue path, broken in production.
Alerts had already been repointed, so the four dead call sites were the three
payment-methods ones and payment-config; they now build methods and settings.
No alias, no fallback — one name per concept.

The tests were part of the defect rather than the guard against it. Every suite
around payment methods stubbed a response body and asserted the normalization, so
a client aimed at a 404 stayed green; that is precisely how this survived. The URL
is now pinned where the request is made, including the two reads nothing had ever
asserted, and reverting any short name turns the suite red — checked, not assumed.
The two e2e specs that pinned dead URLs are corrected, and the isolation spec also
had the retired /billing/v1/ prefix.

POST /v1/billing/me/welcome is deleted rather than repointed, along with the type
and the module that fed it. Commerce removed that route deliberately: it was a
self-service mint, a browser could grant its own org $5, and commerce's own
api/billing/mint_gates_test.go calls it the TOCTOU double-mint. Credit is minted
only through the mint-gated POST /v1/billing/credit. The call was already failing
silently, so restoring it would have re-opened a closed money hole in exchange for
nothing. The trial credit still arrives — commerce grants it server-side when a
card is vaulted, and signup grants it server-side — and that path is untouched.

Scope was measured, not guessed. /v1/finance/payment-methods is still alive and
/v1/finance/methods is 404, so the finance ledger keeps the compound name; a
blanket repo-wide rename would have broken it. The Billing Center tab slugs are
console page URLs, not server routes, and are unchanged.

Two headlines blamed the wrong layer. "Card top-up isn't available on this
deployment yet" and "Adding a card isn't available on this deployment yet" both
fire when the organization has no Square applicationId or locationId — per-org
configuration, not a property of the deployment. Both now name the organization,
as does the onboarding step that had the same defect, and the stale endpoint hints
beneath them now read settings.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 22:07:39 -07:00
hanzo-dev 7a37a8fd92 analytics: identify carries the attributes that make the id legible
identify() sent a user id and nothing else, so the warehouse held a population
of opaque subjects. Every funnel could count users and none could say which
user, and answering "who hit this" meant an IAM lookup per row — which is why
no user attribute is visible anywhere downstream.

Email and name are first-party facts about our own users, and they arrive in
the same IAM claims this file already decodes to get the id: accountFromClaims
projects them onto the Account and the bridge dropped them on the floor. The
SDK has taken traits since it took a person id; nothing new is collected, a
value already in the token is simply not discarded on the way past.

The scrubber does not touch this: capturePII gates error TEXT, and identify
traits ride build()'s `...extra` verbatim. Secret redaction is unconditional
and stays that way.

A key is omitted rather than sent undefined, so an absent claim cannot blank a
trait an earlier identify established. The org is still not sent — the tenant
is stamped server-side from the validated bearer, and a tenant the client can
name is a tenant the client can get wrong.
2026-08-01 14:54:48 -07:00
hanzo-devandhanzo-dev 29c98ddf5b analytics: attribute the stream with the IAM bearer, identify by sub
Two faults, one outcome: every console row landed in the $public tenant, a
partition no org can read, with no user attached. 498 rows, zero identified users.

CREDENTIAL. The client posted same-origin and trusted the first-party cookie to
carry the tenant. That cookie is the casibase session; cloud resolves a tenant from
a validated IAM bearer. A cookie-only POST carries no principal, and the door does
not refuse it — it takes the anonymous lane, which files rows under $public and
drops identify with a 200 receipt. Both ends look healthy. Pass the IAM access
token the console already holds; read it through a function, since the module is
built at import time when nobody is signed in yet.

No publishable key is passed instead, and the note in the file says why: a pk- maps
to one org while this image serves three brands resolved at runtime, and the SDK
resolves `ingestKey ?? token`, so a key would silently override each signed-in
user's own identity rather than supplement it.

IDENTIFIER. identify() sent `${owner}/${name}`, an org-relative reference in a
different id space from the IAM sub that hanzo.ai and hanzo.chat send — so one user
counted twice across surfaces, and the ref moves when an org or handle is renamed,
rewriting history. The sub was already decoded here and used only to back-derive
owner/name, then dropped; carry it through as Account.userId and identify by it.
Absent stays absent rather than falling back to the actor ref.
2026-08-01 12:33:07 -07:00
hanzo-dev f64de84a4e deps: @hanzo/event 0.3.8 — the release that redacts credentials from event URLs
0.3.8 stops the client shipping a raw location on every event. The location is
stamped on all of them now, so an invite/reset/magic link — a JWT in the query,
an address in `?email=` — reached the warehouse in cleartext on the first click
and again on every later one. url, path and referrer now get the same redaction
the error plane has always applied to error text.

Console is the surface where that matters most and the one furthest behind: the
running bundle serves libraryVersion 0.3.0, six releases back, so it has no
error plane at all (sentry.ts did not exist yet) and never stamped a page onto
an event. The lockfile has been ahead of the image for a while — this bumps the
lockfile; the image has to be rebuilt and its pin moved for any of it to be
true in production.

Lock edited only where it names @hanzo/event, so an unrelated tree is not
re-resolved into a dependency bump. Integrity is the one npmjs serves for
0.3.8; `pnpm install --frozen-lockfile` accepts it on pnpm 11.17.0, the version
package.json pins and the Dockerfile installs via corepack.
2026-08-01 12:25:22 -07:00
hanzo-dev ecb9be3a8b deps: @hanzo/event ^0.3.5 for page-stamped autocapture
Autocapture ($click/$input/$change) reaches the wire through capture(),
which supplied no location, so every $click landed with an empty url and
path and was unattributable to a page -- the one thing a heatmap needs.
0.3.5 stamps the page in build(), the single point every event is built,
placed ahead of the caller's fields so pageview()'s explicit path still
wins on the route changes that fire before window.location catches up.

The lockfile moves with the range here. What ships is the lockfile, not
the range, which is why production was serving 0.3.3 while the declared
range already read ^0.3.4. Resolves 0.3.6 (the 0.3.5 runtime plus a
test), deduped with @hanzo/observe and @hanzogui/telemetry.
2026-08-01 10:53:23 -07:00
zooqueen 400a206d80 api: follow cloud onto /v1/o11y/reviews
cloud renamed /v1/o11y/annotation-queues[…] to /v1/o11y/reviews[…] — o11y's own
comment already called them human-review queues, so "queue" was the
implementation and "review" the resource.

lib/api/o11y.ts makes the three reads (list, detail, items) and must move or
the Annotation Queues board renders empty. The rest here is prose that named
the cloud address, including o11y.ts's "mirrors" note and next.config.mjs's
dev-proxy comment.

The console's own `annotation-queues` page — route id, label and docs link —
stays, for the same reason the score-configs page did: renaming a page id
without its heading and its documentation leaves a page that matches neither.
2026-07-31 17:35:47 -07:00
zooqueen 628e6fb469 api: follow cloud onto datasets/:name/items and rubrics
cloud nested dataset items inside the set that contains them
(/v1/evals/dataset-items -> /v1/evals/datasets/:name/items) and renamed score
configs to rubrics (/v1/evals/score-configs -> /v1/evals/rubrics).

The item change is a real contract change, not a path swap: the dataset was
always required — as a body field on POST and a query param on GET — and is now
a path segment. So EvalsApi.createDatasetItem takes it as its FIRST ARGUMENT
and CreateDatasetItemBody no longer carries datasetName; listDatasetItems
builds the nested URL. DatasetsModule passes the set it already had in hand.

Two comments here were wrong before this commit and are corrected rather than
merely renamed: registry.tsx, ScoreConfigsModule.tsx and o11y.ts all claimed
this surface was `/v1/o11y/score-configs`. o11y has never served it — o11y.ts's
own note says scores and their definitions STAY on /v1/evals — so the address
they named did not exist under either spelling.

DELIBERATELY NOT RENAMED: the console's own `score-configs` PAGE (its route id,
its "Score Configs" label and its ${DOCS}/score-configs link). That is product
copy plus an external docs URL, and moving the id without the label and the
docs page would leave a page whose name matches neither its own heading nor the
documentation it links to. It is a coordinated rename with hanzo-docs, not part
of an api sweep.
2026-07-31 17:33:52 -07:00
zooqueen c1a7d48113 api: follow cloud off the spend-caps and block-storage compounds
cloud renamed /v1/admin/spend-caps[/:id] to /v1/admin/caps[/:id] and
/v1/admin/block-storage to /v1/admin/volumes — under /v1/admin there is one
kind of cap, and what the storage board returns is a list of volumes.

TWO literal allowlists carry these names and both are load-bearing, so this
cannot land after cloud without breaking the admin boards:
  - ADMIN_AGGREGATE_HEADS (src/lib/server/admin-aggregate.ts) — allowAdminSurface
    admits `v1/admin/<head>[/...]`, so the head is what lets the :id sub-path
    through at all.
  - ADMIN_V1_HEADS (next.config.mjs) — the dev rewrite onto a real backend.

lib/api/admin-spend-caps.ts -> admin-caps.ts, with AdminSpendCapsApi ->
AdminCapsApi and the AdminSpendCap type -> AdminCap, so the module, the API
object, the type and the route all say one thing; its test moves with it.
storage-fleet.ts calls the volumes read. UsageCapsPromoModule.tsx, client.ts,
registry.tsx and the aggregate route's doc comments name these addresses in
prose and would otherwise document routes nobody serves.

e2e/storage-fleet.spec.ts intercepts the cloud call by URL, so its matcher moves
too or the fixture never binds and the board renders empty.
2026-07-31 17:28:26 -07:00
zooqueen e5f510b994 api: follow cloud off the load-balancers compound
cloud renamed /v1/load-balancers[/:id] to /v1/balancers[/:id] — nesting under
/v1/networks was unavailable (apps/zt owns that prefix), so the flat single
noun is the one-way answer, matching its sibling /v1/vpcs.

proxy-allow.ts is the load-bearing edit: it is a literal FIRST-SEGMENT
allowlist, and `load-balancers` was a real entry, so without this every call
403s at the proxy before it ever reaches cloud. LoadBalancerModule.tsx calls
list/create/delete directly.

The admin Infra tab slug moves too — same concept, one name — while its visible
label stays "Load balancers", which is both what a person reads and what
e2e/admin-infra.spec.ts clicks. The response body is unchanged: cloud still
returns {loadBalancers:[…]}, DigitalOcean's own name for its own resource.
2026-07-31 17:24:43 -07:00
zooqueen 0a883aefaf api: follow cloud off the share-classes and equity-plans compounds
cloud renamed /v1/captable/share-classes[/:id] to /v1/captable/classes[/:id]
and /v1/captable/equity-plans to /v1/captable/plans: the captable prefix
already supplies "share" and "equity", so each member was repeating its group.

lib/api/captable.ts builds both URLs and must move or the Classes and Plans
panels 404. CapTableModule.tsx prints the address in a BackendStateCard hint,
which is only useful if it names the route that actually failed. proxy-allow.ts
admits by first segment, so `captable` is unchanged — only its comment, which
lists the sub-paths that head covers.

The ShareClass and EquityPlan TYPES keep their names: those are the domain
objects, and a share class is a share class wherever it is addressed.
2026-07-31 17:22:03 -07:00
zooqueen 0c097129d8 api: follow cloud off the rotate-secret compound
cloud renamed POST /v1/webhooks/:id/rotate-secret to POST /v1/webhooks/:id/secret
— the endpoint has one signing secret and POST is what mints a new one, so the
verb belonged to the method, not the noun.

WebhooksModule.tsx calls the address directly, so it moves or the rotate button
404s. proxy-allow.ts needs no allowlist change (it admits by first segment, and
`webhooks` is unchanged); only its comment, which enumerates the sub-paths that
head covers, would otherwise name an address nobody serves.
2026-07-31 17:19:38 -07:00
zooqueen 3749f2669f api: /v1/billing/alerts — follow the rename off the compound 2026-07-31 16:21:05 -07:00
zeekayandClaude Opus 5 06d0681081 deps: unfreeze console's first-party pins; quarantine finance-ui 0.2.x
@hanzo/canvas was declared "^0.1.0". A caret on a 0.x version pins the
MINOR, so console was locked to canvas 0.1.0 while 0.2.1 was current —
the same trap that froze @hanzogui/shell on 7.5.1. @hanzo/ui@8.0.38
peer-depends on canvas ">=0.1.0", which 0.1.0 satisfies, so nothing
warned. Silent freeze.

Six exact pins (dash, gui, and the four @hanzogui/* entry points) could
never take a patch either. Floated them to carets; 8.x is a stable major
so a caret is the correct expression.

Moved on install: brand 1.4.4->1.4.5, canvas 0.1.0->0.2.1,
data 1.2.1->1.2.2, logo 1.0.13->1.0.14, ui 8.0.20->8.0.38.

@hanzo/finance-ui stays on 0.1.1, now written "~0.1.1" so the range says
so. 0.2.1 declares peer "@hanzo/ui": ">=8.0.0" — satisfiable, so the peer
check passes — but its source imports DataTable, LineChart, Column,
ChartPoint and Sparkline from @hanzo/ui, and no published @hanzo/ui
exports them (verified against 8.0.20, 8.0.38 and ui-shadcn 5.9.1).
It publishes raw src/, so those errors land in the consumer's tsc.
A satisfiable-but-false peer range is worse than an unsatisfiable one:
it fails at build time in the consumer instead of at install.

tsc --noEmit clean; next build succeeds; 3152 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 15:49:51 -07:00
zeekay cdb6f9d091 deps: console onto gui 8.x — the shell fixes can finally reach production
Hanzo CI/CD / cicd (push) Failing after 1m7s
CI/CD / cicd (push) Failing after 1m7s
The console sat on @hanzo/gui 7.3.0 / @hanzogui/shell 7.6.3 while the gui line
shipped 8.0.0, so every shell fix landed on main and stopped there: cloud.hanzo.ai
and console.hanzo.ai kept serving a header from a package that could not move.

What actually pinned it was a phantom peer. Every published @hanzogui/shell
declared `@hanzo/iam@^0.13.1` — a package it never imported — and for a 0.x range
the caret pins the minor, so once IAM reached 0.21.2 the range was unsatisfiable
and the 8.x line was uninstallable. Fixed at the source and released as
@hanzogui/shell@8.0.1 with zero dependencies.

typescript goes ^7.0.2 -> ^5.8.2 in the same commit because it has to: Next 15
does not support TS 7, and on 7.0.2 `next dev` fails to a bare
"Cannot read properties of undefined (reading 'endsWith')" after silently
installing a 5.x behind your back. Same fix hanzo.ai just made.

Verified on the real 8.x tree at 1440x900 and 390x844: Products opens as FIVE
columns with all ten categories visible and nothing clipped (it was four columns
with WEB3 + APPS below the fold), one "Sign in" pointing at /signin, one h1,
the hero primary a white 999px pill, and Terms inside the viewport with no
underline and no horizontal overflow.
2026-07-31 14:35:28 -07:00
hanzo-dev 744164c4c5 merge: one host normalizer for the brand/admin-gate boundary
rescue/stash-0: brandFromHost routes through normHost, which now trims
before stripping the port and drops the FQDN root dot. A padded, ported
or dotted host can no longer soften the suffix match and swap adminDomain
onto the default brand. Tests cover the padded/ported/dotted/lookalike cases.
2026-07-31 14:30:36 -07:00
hanzo-dev 454636c4e2 merge: subscribe the console to the DocType engine's change feed
feat/framework-realtime, additive: framework client gains a change-feed
subscription plus its types and tests. No existing surface changes.
2026-07-31 14:30:36 -07:00
hanzo-dev 8bdbc9116a telemetry: say why the console bakes no ingest key
The env var is read but is undefined in every shipped artifact, which
reads like an oversight and invites a build arg. It is not one.

A pk- resolves to exactly ONE org -- cloud stamps the tenant from the key
-- and this image is brand-agnostic: one build serves cloud.hanzo.ai,
cloud.lux.cloud and cloud.zoo.cloud with the brand resolved at runtime
from the request hostname. Baking a key would file every brand's traffic
into whichever org owns the key: wrong data, and a cross-tenant leak. It
is the same reason the Dockerfile bakes no NEXT_PUBLIC_*.

Signed-in traffic does not need one. host:'' posts same-origin, so the
first-party session rides along and cloud resolves the tenant from it at
full capability; identify and track already land correctly.

What is genuinely unattributed is the logged-out lane, which reaches
cloud with no credential and takes the anonymous lane -- pageview and
error stored, track/identify/group dropped, 200 either way. Closing it
needs a per-host key delivered at runtime over a channel both artifacts
share, which is recorded here so the next reader does not reach for the
build arg instead.
2026-07-31 10:46:36 -07:00
hanzo-dev 599f6f3a31 billing: Stripe is back in the connector list — Square stays our rail
A merchant connecting THEIR Stripe account is a capability we offer; only
Hanzo's own charging is Square-only. Square leads the list because it is
the rail this platform bills on. The doc-comment cleanups stay: describing
a payload SHAPE by a vendor's name was always vague, whoever we charge on.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-30 21:52:02 -07:00
hanzo-dev f26c8fbcca billing: the UI stops naming a rail we do not use
Square is the rail (via commerce), so the console says so: the payments
integration list drops Stripe, and every doc comment that reached for the
brand to describe a SHAPE — a nested subscription record, a seconds-vs-
millis period stamp, snake_case card fields, the developer-workbench
pattern — now describes the shape instead. No behavior change; the
normalizers still accept the same payloads, which is what the 25 billing
tests prove.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-30 21:22:43 -07:00
hanzo-dev 447825ef6d api: read the named total first; data2 is only the legacy fallback
The count of a list envelope now reads the named field before Casdoor's
untyped second slot: total, then data2, then the rows themselves. ONE
helper (envelopeTotal in lib/api/client.ts) owns the order; getList,
iamList, makeIamClient and AuditApi.list all go through it, so the data2
fallback lives in exactly one place and dies with the legacy emitters.

e2e fixtures still emit data2 on purpose — they pin today's live wire
and flip only when the fallback is deleted.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-30 12:32:55 -07:00
hanzo-dev 9915705d10 referrals: signup, one vocabulary
Hanzo CI/CD / cicd (push) Successful in 6m2s
CI/CD / cicd (push) Successful in 6m2s
The status and the counts field say signup (was signed_up / signedUp),
matching the cloud rename that migrates the store rows — the whole
platform now spells the concept one way.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-30 10:02:01 -07:00
hanzo-dev b10c5ce18f identity: a refusal, a network error and an absence are three answers
IAM v1.33.31 made org scoping honour-or-refuse (internal/authz/authz.go Scope):
a non-SuperAdmin asking about a foreign owner is REFUSED, not silently re-pointed
at its own org. The confidential `hanzo-console` client is exactly the principal
that starts earning 403s, and this module read every failure as `null`.

`iamGetData` returned null when unconfigured, null on a thrown fetch, and null on
`!res.ok || status !== 'ok' || data == null` — so a 403 refusal, an unreachable
IAM, and a malformed envelope were indistinguishable from "no such row". IAM's
own getHandler (internal/compat/aliases.go) answers three DIFFERENT things from
one endpoint:

  hit      200 {status:"ok", data}
  absence  200 {status:"error", msg:"the entity does not exist"}   (httpx.Err)
  refusal  403 {status:"error", msg:"forbidden: …"}                (authz.Deny)

Collapsing them cost real behaviour, not just tidiness:

  - getMember is the invite flow's identity read. A refusal became "this
    invitation is no longer valid — the member was removed" (410), telling an
    invitee their membership was revoked when IAM merely would not answer, and it
    is the same null the single-use activation guard reads.
  - getUserKey is the API-key state read. A refusal became "no key", which is the
    exact regression its own docstring records: the page falls back to Create, the
    live key is hidden, and the user mints a duplicate over a key they can no
    longer revoke.

So: ONE transport (`iam`) that returns data or throws, carrying `absent` for the
one benign kind — the same contract and the same wire constants as the app's
client (ai-sdk lib/org/onboard.ts) and cloud's Go client (apps/account/iam.go
`do`), which is why `method` is explicit here: IAM has param-only POSTs. Over it
sit exactly two named policies: `iamGetOrAbsent` (absence is a legitimate answer
to null, everything else propagates) and `iamGetUser` (fail-SOFT, and safe
precisely because the admin gate admits only on positive evidence, so a lost
answer can only ever DENY). Soften where a lost answer closes a door, never where
it opens one.

That replaces FOUR transports — iamGetUser, iamCall, iamGetData, iamPostBody —
which had three different error conventions between them.

Also drops the org-onboarding half (getOrganization, createOrganization,
createUser, moveUserToOrg, IamOrganization): dead since onboarding moved server
side to cloud's POST /v1/iam/onboard (apps/account/account.go), which
OrgOnboarding.tsx calls via v1Url('iam/onboard'). Zero consumers — tsc --noEmit
over the whole app is the proof, not a grep. Deleted outright, no shim.

The one behaviour change beyond the above: the transport now refuses when the
confidential client is unconfigured instead of sending Basic Og== and letting IAM
401. Both throw; this one says why and skips the round-trip. Every route already
checks mintConfigured() first, so the three identity.test.ts cases that relied on
the old unguarded path now configure the client, which is the production
precondition they were always standing in for.

Tests: identity-refusal.test.ts pins all three answers plus the unreachable and
malformed cases for both live readers, and pins that a REFUSED claims read still
fails the admin gate CLOSED. 6 of its 11 fail against the code this replaces.
Suite 3141 passed / 8 skipped (was 3130 / 8); tsc --noEmit clean; next build
compiled successfully.
2026-07-28 23:30:58 -07:00
zooqueen d93e9148b2 docs: the identity server these comments describe is IAM, not a vendor
Every hit was a comment naming the wrong system. hanzoai/iam is Hanzo IAM —
original, clean-room work; the vendor-derived server was hanzoai/iam-v1, which
is retired and ships in nothing. Replaced the name with IAM and kept each
comment's fact intact: tokens really are ~3.6 KB full-user JWTs (hence the two
cookies and the chunking), IAM really does pack the full user object, and it
really does skip the client-secret check when the secret is empty.

Comments and markdown only — no identifier, cookie name, or wire field moved.
2026-07-28 17:12:30 -07:00
zooqueen 6b881b2a8e docs: /zap is served — the reason recorded for preferring REST is stale
This file says "the cloud /zap WS face is NOT served (the edge returns SPA
HTML, 200 not a WS upgrade)". That was true when written and is not true
now. Measured 2026-07-28 on api.hanzo.ai, platform.hanzo.ai and
cloud.hanzo.ai: a WebSocket upgrade handshake returns 401 on all three —
not SPA HTML, not 200. A route that refuses an unauthenticated upgrade is
a route that exists.

Read as current, the paragraph says ZAP is unavailable when it is merely
gated, which is the kind of stale rationale that gets acted on. The REST
path it describes is still correct and still shipping, so nothing about
the code changes — only the reason.

Marked stale in place rather than deleted: the history explains why
Providers moved to REST, and removing it would lose that. The correction
names what was measured and where, so the next reader can re-check rather
than trust either version.

Found while sweeping for docs that actively mislead. The sibling case,
universe crs/console.yaml justifying replicas:2 with an admin.hanzo.ai
route that no longer exists, needs no fix — that CR has since been
retired with the standalone console.
2026-07-28 13:30:59 -07:00
hanzo-dev f85b08e44d console: name the fleet board by its one name
Cloud folded /v1/paas into /v1/platform — paas was a second name for the same
product, and one product gets one name. The operator fleet/drift board now lives
at /v1/platform/fleet.

No functional change here, and that is the point: the `platform` allowlist head
already admits every /v1/platform sub-path, so the new route was reachable the
moment cloud shipped it. What was stale was the PROSE — three comments still
pointed readers at `/paas/apps`, a route that no longer exists, and the
allowlist's own sub-path list did not mention the board it admits. A comment
that names a dead route is worse than no comment: it sends the next reader to
look for a surface that was deleted.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-28 11:45:18 -07:00
hanzo-dev daef1ea32c console(framework): subscribe to the DocType engine's change feed
The generic renderer had no realtime at all — zero EventSource, zero subscribe.
This adds the client half of the ONE mechanism the engine now serves:

  changes.list(q)        one page of the feed  -> GET /v1/framework/changes
  changes.subscribe(q,h) the same query held open as SSE -> /v1/framework/stream
  presence.list(dt,name) the roster            -> GET /v1/framework/presence

changeQuery is ONE function for the poll and the stream, because server-side
they are ONE query — the stream is that query in a loop. A view renders current
state from records.list, keeps the cursor it gets back, and applies changes from
there.

No credential and no org leaves the browser. subscribe opens a same-origin
EventSource through the console's existing /v1 bearer proxy, which already
streams res.body straight through; the proxy mints the short-lived user-bound
token and the engine resolves the org from its owner claim. There is nothing a
page could send to name another tenant, which is the reason to prefer SSE here
over a WebSocket that would need its own auth path.

Resume is the browser's job: each frame's id IS the change's seq, so a dropped
connection reconnects with Last-Event-ID and misses nothing. onReset fires when
the cursor fell behind the server's retention window — refetch, THEN resume,
never patch on top of a state you never had.

Passing watching: '<DocType>/<name>' additionally declares presence for as long
as the connection lives, which is why presence needs no client->server channel.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-28 10:58:12 -07:00
hanzo-dev 7a722f0a3c feat(console): the shared components report through the console's ONE client
@hanzo/ui 8.0.20 instruments itself — DataTable, PrimaryButton, SlideOver,
ConfirmDelete, the Field* editors, ComboBox, Segmented/SearchInput, MenuItemView,
OrgSwitcher, ThemeToggle, Toast and EmptyState now report what a user did. The
console renders all of them, so it gets the whole interaction vocabulary without
a line of app code — that is the point of instrumenting the component instead of
the app.

But those components emit through module-scope `track()`, which resolves an
AMBIENT client, and left alone that would have been a SECOND client: default host
api.hanzo.ai, no session cookie, its own anon id and its own batch. Cloud's
anonymous capability lane admits pageview and error only, so every component
event would have been DROPPED on arrival — a silent, plausible-looking nothing.

So `src/lib/event.ts` — the file that already declares itself the ONE console
client — registers `eventClient` as the ambient one. Now the provider's pageviews,
the error boundaries, and every shared component ride a single same-origin batch
that carries the session cookie, which is what makes their events CREDENTIALED and
attributable to the signed-in org. One client, one anon id, one stream, exactly as
that file's header always promised.

Also pins typescript back to 5.9.3. Under the 7.0.2 that main had taken, Next
15.5.19 stops honoring the tsconfig `paths` map and the build dies on every
alias — `Can't resolve '~/config'`, `'~/lib/event'`, `'~/components/ProductRoute'`
— i.e. main could not build at all, before and independent of this change. Adding
`baseUrl` does not help; only the TS pin does.

Build: 8.0.20 installs clean under pnpm 11 (8.0.19 could not — it shipped
`workspace:*`, fixed upstream) and `next build` completes.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-28 09:22:39 -07:00
hanzo-dev 58ff94c159 ci: delete the GitHub puller — one direction, decided elsewhere
This workflow polled github.com every 10 minutes and fast-forwarded the forge
from it. It was written when which side was canonical was still open; it is not
open now, so a cron that reconciles two mains is a second answer to a settled
question. Removing it leaves exactly one way for code to move.

It was already inert by its own admission — the header notes it does nothing
while the GitHub repo is a pull mirror, because the forge overwrites main on its
own timer and rejects the push.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-28 08:57:09 -07:00
hanzo-dev b489ab0fdb ci: pin .hanzo/workflows/build.yml@v1
v1 is the .hanzo/workflows era. There is no v2 — one tag, forward only.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-28 00:31:05 -07:00
zandhanzo-dev 13c5b30cd6 build: regenerate the lockfile for @hanzo/ui ^8.0.17
Hanzo CI/CD / cicd (push) Failing after 47s
CI/CD / cicd (push) Failing after 47s
CI fails at install, before anything is built:

    [ERR_PNPM_OUTDATED_LOCKFILE] Cannot install with "frozen-lockfile"
      specifiers in the lockfile don't match specifiers in package.json:
      - @hanzo/ui (lockfile: ^8.0.11, manifest: ^8.0.17)

package.json was bumped without regenerating the lockfile, and `--frozen-lockfile`
refuses — correctly; that is what frozen means and the strictness is worth
keeping.

Resolves to 8.0.18, the newest release satisfying ^8.0.17.

Regenerated with pnpm 11.17.0, the version this repo declares in
`packageManager`, so the file matches what the builder's corepack will use. The
diff is large but it is not a format rewrite: lockfileVersion stays 9.0, the
change begins exactly at the @hanzo/ui specifier, and only 30 package entries
are added (2 removed) — lucide-react, sonner, tailwind-merge, react-remove-scroll
and friends, i.e. what @hanzo/ui pulls in between 8.0.11 and 8.0.18. The rest of
the churn is peer-hash suffixes on existing entries. packages and snapshots both
count 1060, so the file is self-consistent.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-28 00:21:32 -07:00
zeekayandhanzo-dev 108977e0ca fix(deps): take @hanzo/iam 0.21.2 and delete the patch it supersedes
`patches/@hanzo+iam+0.13.6.patch` added a fallback that reads the token's
`exp` claim when the server omits `expires_in`, so session expiry is still
learned. It was written against 0.13.6; the dep has since moved to 0.21.1,
so patch-package refused it — and, because a version mismatch is a
warning-class failure there, `postinstall` still exited 0. The patch had
silently stopped applying with nothing failing: verified absent from the
installed 0.21.1, so the console was running without the fallback.

Fixed upstream instead (hanzo-js/iam 08476b4, released as 0.21.2) where it
belongs — one implementation, every consumer — so the patch is now
genuinely unnecessary, which is what patch-package itself reported.

Verified: 0.21.2 installed, the fallback present in node_modules, and
postinstall clean ("No patch files found"). typecheck 0 errors; 3136 tests
pass.

Co-Authored-By: Hanzo Dev <dev@hanzo.ai>
2026-07-28 00:04:04 -07:00
hanzo-dev 137432ad42 refactor(gui): import the ONE scale, stop declaring it
gui.config.ts declared the console's type/radius/space ladder. It is not the
console's ladder — it is the ladder every @hanzo/ui/product component is drawn at,
and the dedicated Hanzo Social app renders those same components. A private copy
would have forked the moment either side tuned a size.

It now ships with the components (@hanzo/ui/gui-config, 8.0.17); this file is the
console's import path onto it, so `~/gui.config` call sites are unchanged.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 23:30:51 -07:00
hanzo-dev 9bd12d7c28 refactor(social): render the shared surface, not the console's private copy
The whole social product lived HERE — a 601-line SocialModule plus its own copy of
the /v1/social contract in src/lib/api/social.ts. That is fine while the console is
the only host, and it stopped being true: social.hanzo.ai gets a dedicated app, and
the only way for a second app to render Publish was to copy 800 lines and let them
drift.

So the product moved to @hanzo/ui/product/social (SocialResource + createSocialApi)
and the console keeps only what is genuinely the console's:

- src/lib/api/social.ts is now the TRANSPORT binding — the four verbs on
  originV1Url('social/…'), through our own app/v1 user-bearer BFF — and re-exports
  the contract so call sites keep one import path. It binds
  @hanzo/ui/product/social/api, the React-free entry, so the console's data layer
  (and its node tests) never load a component tree.
- SocialModule.tsx is the mount: hand SocialResource the bound client. 23 lines.
- The tests split the same way the code did: the contract's normalizers and paths
  are tested in @hanzo/ui; what is tested here is what is ours — that a contract
  path resolves to this origin's /v1/social/… .

817 lines of module + client become 77. Nothing about the surface, the routes, or
the tenant isolation changes: the org is still resolved SERVER-SIDE from the bearer
owner claim, and social.hanzo.ai still boots straight into this product.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 23:20:42 -07:00
zeekayandhanzo-dev bc93b371f6 build: typescript ^7.0.2 (native compiler)
TypeScript 7 is the native Go compiler; the npm package is a shim resolving
a platform-specific native binary. The build is `next build`, so SWC does the
emit and tsc is typecheck-only — no built artifact changes.

Measured on this same config before and after: 0 errors on both tsc 5.9 and
tsc 7, so the compiler swap introduces nothing new.

Note: `pnpm install` exits non-zero here on an unrelated, pre-existing
postinstall failure — patches/@hanzo+iam+0.13.6.patch no longer applies
because the installed @hanzo/iam has moved past 0.13.6. That failure
reproduces with the previous package.json and is not caused by this change.

Co-Authored-By: Hanzo Dev <dev@hanzo.ai>
2026-07-27 23:07:47 -07:00
hanzo-dev b55eb201d6 WIP on main: 9d6190a78 merge: console enso leaderboard + reported-vs-measured source toggle 2026-07-21 15:05:19 -07:00
hanzo-dev e7b36af216 index on main: 9d6190a78 merge: console enso leaderboard + reported-vs-measured source toggle 2026-07-21 15:05:19 -07:00
hanzo-dev 9d6190a783 merge: console enso leaderboard + reported-vs-measured source toggle 2026-07-21 15:04:25 -07:00
524 changed files with 24651 additions and 12155 deletions
+1 -1
View File
@@ -21,5 +21,5 @@ on:
pull_request:
jobs:
cicd:
uses: hanzoai/ci/.hanzo/workflows/build.yml@v2
uses: hanzoai/ci/.hanzo/workflows/build.yml@v1
secrets: inherit
-53
View File
@@ -1,53 +0,0 @@
name: Sync from GitHub
# git.hanzo.ai is canonical; development also lands on
# github.com/hanzoai/console. ONE deterministic direction: an in-cluster PULL.
# The runner reaches both ends (GitHub outbound, this forge via the instance URL
# actions/checkout already uses), so the sync has no ingress dependency.
#
# Fast-forward ONLY: a divergence fails loudly here instead of force-pushing
# either side.
#
# Inert until hanzoai/console stops being a pull mirror — while it is one,
# the forge overwrites main from GitHub on its own timer and rejects the push
# below. That conversion is also what turns Actions on here (measured today:
# mirror: true, has_actions: false ⇒ zero native runs).
on:
schedule:
- cron: '*/10 * * * *'
workflow_dispatch: {}
concurrency:
group: sync-from-github
cancel-in-progress: false
jobs:
ff-main:
runs-on: [hanzo-build-linux-amd64]
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: true
- name: Fast-forward main from github.com/hanzoai/console
env:
GH_PAT: ${{ secrets.GH_PAT }}
run: |
set -euo pipefail
git fetch --quiet "https://x-access-token:${GH_PAT}@github.com/hanzoai/console.git" main
LOCAL="$(git rev-parse HEAD)"; REMOTE="$(git rev-parse FETCH_HEAD)"
if [ "$LOCAL" = "$REMOTE" ]; then echo "in sync at $LOCAL"; exit 0; fi
if git merge-base --is-ancestor "$LOCAL" "$REMOTE"; then
echo "fast-forwarding $LOCAL -> $REMOTE"
git push origin "$REMOTE:refs/heads/main"
# A push made with the workflow token does not trigger workflows, so
# synced commits would never build. Dispatch CI explicitly.
curl -fsS --max-time 20 -X POST \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Content-Type: application/json" \
"${{ github.server_url }}/v1/repos/${{ github.repository }}/actions/workflows/cicd.yml/dispatches" \
-d '{"ref":"main"}' \
&& echo "CI dispatched" || echo "CI dispatch failed (non-fatal — next direct push builds)"
elif git merge-base --is-ancestor "$REMOTE" "$LOCAL"; then
echo "canonical is AHEAD of GitHub — nothing to pull (never force from here)."
else
echo "::error::main DIVERGED between GitHub ($REMOTE) and canonical ($LOCAL) — refusing to force. Reconcile manually."
exit 1
fi
+50 -47
View File
@@ -1,48 +1,51 @@
# console2Hanzo Cloud Console (Next.js 15 + @hanzo/gui). BSD-3-Clause.
# 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"]
+10 -38
View File
@@ -1,42 +1,14 @@
BSD 3-Clause License
Licensed under either of
Copyright (c) 2026-present, Hanzo AI, Inc.
* Apache License, Version 2.0 (LICENSE-APACHE or
https://www.apache.org/licenses/LICENSE-2.0)
* MIT license (LICENSE-MIT or https://opensource.org/licenses/MIT)
Portions of this software are derived from upstream code originally licensed under
the MIT License, with the following copyright notices retained per its terms:
at your option.
Copyright (c) 2020 Nate Wienert
Copyright (c) 2015-present, Nicolas Gallagher.
Copyright (c) 2015-present, Facebook, Inc.
Copyright (c) 2021 Radix
Copyright (c) 2017 Carmelo Pullara
Copyright (c) 2018 Framer B.V.
Copyright (c) 2022 WorkOS
Unless you explicitly state otherwise, any contribution intentionally
submitted for inclusion in the work by you, as defined in the Apache-2.0
license, shall be dual licensed as above, without any additional terms or
conditions.
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors
may be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.
See HIP-0137 (hanzoai/hips) for the standard this follows.
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+33
View File
@@ -0,0 +1,33 @@
MIT License
Copyright (c) 2026-present, Hanzo AI, Inc.
Portions of this software are derived from upstream code originally licensed
under the MIT License, with the following copyright notices retained per its
terms:
Copyright (c) 2020 Nate Wienert
Copyright (c) 2015-present, Nicolas Gallagher.
Copyright (c) 2015-present, Facebook, Inc.
Copyright (c) 2021 Radix
Copyright (c) 2017 Carmelo Pullara
Copyright (c) 2018 Framer B.V.
Copyright (c) 2022 WorkOS
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+242 -15
View File
@@ -1,7 +1,7 @@
# console2 — Hanzo Cloud Console
Unified admin console for **Hanzo Cloud** and all cloud products. Our code,
BSD-3-Clause, built on **@hanzo/gui** (the Tamagui-based cross-platform UI).
`MIT OR Apache-2.0` (HIP-0137), built on **@hanzo/gui** (the Tamagui-based cross-platform UI).
NOT an observability-console fork, NOT casibase — it is a clean client over the unified `/v1`
backend (`hanzoai/cloud`), reached at the ONE Hanzo API endpoint https://api.hanzo.ai/v1/*.
@@ -92,7 +92,8 @@ keyed by `owner/modelName`, so modelName is form-entered, not generated).
One `request()` in `lib/api/client.ts`: always `credentials: 'include'` (the
backend sets a session cookie at `/v1/signin`), forwards `Accept-Language`,
unwraps the casibase `{ status, msg, data, data2 }` envelope, throws typed
unwraps the casibase `{ status, msg, data, total }` envelope (named `total`
first, legacy `data2` count accepted until the emitters finish renaming), throws typed
`ApiError` (401/403 carry status). Base URL = `config.cloudUrl` (default
`https://cloud.hanzo.ai`, override `NEXT_PUBLIC_CLOUD_URL`).
@@ -290,11 +291,18 @@ Findings + fixes (all in console2; honest states everywhere, no fakes):
separately) → honest "not available on this deployment" (was a scary error).
HUSD balance/top-up already honest "coming" (token unconfigured).
- **Providers was broken**`ProviderListView`/`ProviderEditView` imported the
ZAP twin (`~/lib/zap`), but the cloud `/zap` WS face is NOT served (the edge
returns SPA HTML, 200 not a WS upgrade — documented in `lib/zap/client.ts`), so
the module showed "Failed to load providers". Switched both back to the working
REST `~/lib/api` (identical surface). The ZAP twin stays as the proof-of-pattern
until `/zap` is bound. Providers now shows real/empty over REST like every module.
ZAP twin (`~/lib/zap`), and at the time the cloud `/zap` WS face was not served
(the edge returned SPA HTML, 200 rather than a WS upgrade), so the module showed
"Failed to load providers". Switched both back to the working REST `~/lib/api`
(identical surface). Providers now shows real/empty over REST like every module.
**STALE AS OF 2026-07-28 — `/zap` IS served.** Measured on all three hosts
(api.hanzo.ai, platform.hanzo.ai, cloud.hanzo.ai): a WebSocket upgrade handshake
returns **401**, not SPA HTML and not 200. A route that refuses an
unauthenticated upgrade is a route that exists. The reason this section gives
for preferring REST no longer holds, and read as current it says ZAP is
unavailable when it is merely gated. The REST path is still correct and still
shipping — this is a stale rationale, not a bug. Re-measure before acting on it.
- Already-correct honest states (unchanged): IAM/Audit + KMS/Secrets (`/v1/iam`,
`/v1/kms` 404 → "not available on this deployment"); Observability (`/v1/o11y`
503 → "runtime not initialized"). Plans/Embeddings show real data; Models/
@@ -318,14 +326,14 @@ keep all credentials server-side (the browser only ever sends its session cookie
embeddings|rerank` (not a general tunnel). `playground.ts` now points at this
proxy (`<origin>/ai`), so Models/Playground/Chat/cmd+K all work with no key in
the browser and no rotation on a chat turn.
- **`app/keys/route.ts`** — per-user `hk-` Cloud API key. POST mint/rotate, DELETE
- **`app/keys/route.ts`** — per-user `sk-` Cloud API key. POST mint/rotate, DELETE
revoke, GET status (no secret). Same app-on-behalf pattern via
`/v1/iam/mint-user-keys` + `/v1/iam/revoke-user-keys`. The `hk-` secret is shown
`/v1/iam/mint-user-keys` + `/v1/iam/revoke-user-keys`. The `sk-` secret is shown
ONCE (POST). `ApiKeysModule` is now create/copy/rotate/revoke.
- Shared trust boundary: `src/lib/server/identity.ts` (server-only) — `resolveUser`
+ `mintUserKey`/`revokeUserKey`/`issueUserToken`. The `hanzo-console` client is
allow-listed in IAM `IAM_KEY_MINT_ALLOWED_APPS`; verified end-to-end that a
minted `hk-` key and an issued user JWT both 200 on `api.hanzo.ai/v1/chat/
minted `sk-` key and an issued user JWT both 200 on `api.hanzo.ai/v1/chat/
completions`.
- **Chat is interactive** (`chat/ChatConversation.tsx`): a real multi-turn
conversation over `AiApi.chat` (→ the `/ai` proxy), with a Zen default model,
@@ -1400,7 +1408,7 @@ of session-only reads (get-account, get-cloud-usages) — so it is KEPT, not rep
The fix is **additive, one session manager, zero regression** (worst case === v8.4.28):
- **`src/lib/server/session.ts`** — THE token manager (server-only by construction:
`node:crypto` + `next/server`). Sealed AES-256-GCM (key = HKDF(`IAM_MINT_CLIENT_SECRET`);
no-secret → per-process random key, never a constant). Casdoor tokens are ~3.6 KB
no-secret → per-process random key, never a constant). IAM tokens are ~3.6 KB
full-user JWTs (86 claims incl. password hash / TOTP secret) — the ACCESS token and
the REFRESH token are BOTH that big — so a single cookie is impossible (browser ~4 KB
per-cookie cap; a real browser silently REJECTS an oversized cookie — a bug curl never
@@ -1463,7 +1471,7 @@ for wrong application (client_id)":
`/v1/iam/signin`, which the ingress routes to the CLOUD backend (casibase); casibase
redeems with ITS confidential `hanzo-cloud` client → mismatch. Now the console redeems
the code ITSELF: on an admin host `iam-login.ts` authorizes with **PKCE** (S256
`codeChallenge` in the login body — casdoor stores it with the code), and
`codeChallenge` in the login body — IAM stores it with the code), and
`completeSignIn` posts `{code, codeVerifier}` to the new BFF **`app/auth/signin`**, which
runs `pkceCodeGrant(client_id=admin-console, code, code_verifier)` with **NO client secret**.
Verified in IAM source (`object/token_oauth.go` GetAuthorizationCodeToken 880-896): an
@@ -1475,7 +1483,7 @@ for wrong application (client_id)":
- **`durableSessionClientId(host)`** (session.ts) is the ONE host→client decision:
admin host → `admin-console` (public — pkceCodeGrant + secretless refreshGrant), else null
→ the confidential `hanzo-console` path. `/auth/refresh` uses it so an admin session
refreshes with admin-console (casdoor skips the secret check when it's empty, token.go 469).
refreshes with admin-console (IAM skips the secret check when it's empty, token.go 469).
- The admin session rests on `hz_session` (the code grant returns access + refresh, minted
at authorize time), which `resolveUser`/`getAdminGate` read FIRST — so the admin console
works without the casibase cookie. `accountOf` + `applyCookies` extracted to session.ts
@@ -3580,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.
@@ -3694,3 +3702,222 @@ and I will not type a password; one SuperAdmin session re-running this spec clos
that. Untouched and flagged for the caps pass: `MarketplaceModule`'s "CATEGORIES"
and the palette's own uppercased section labels are `textTransform` sites that
belong to that lane, not this one.
## Billing calls the route names the server actually registers
Commerce dropped the compound prefixes from its billing routes — the `/v1/billing/`
namespace already says "billing", so `billing/payment-methods` stuttered. Both servers
register only the short names, measured against the live edge: `/v1/billing/methods`
401, `/v1/billing/settings` 403, `/v1/billing/alerts` 403, while `payment-methods`,
`payment-config` and `spend-alerts` are all 404. The console never followed. Its card
reads, its card writes and its Square-config read were all addressed at routes that no
longer exist, so a new user could not add a card — the revenue path was broken in
production.
The client had already been repointed for alerts, so `payment-methods` (list, save,
detach) and `payment-config` were the ones still dead. They now build `methods` and
`settings`. There is deliberately no alias and no fallback: one name per concept.
The tests were part of the defect, not the safety net. Every suite around payment
methods stubbed a response body and asserted the normalization, so a client pointed at
a 404 stayed green — the exact reason this survived. The URL is now pinned where the
request is actually made, including the two reads nothing had ever asserted
(`methods`, `settings`) and `alerts` beside them. Reverting any of the four short names
turns the suite red, which was checked rather than assumed.
`POST /v1/billing/me/welcome` and everything feeding it is deleted, not repointed.
Commerce removed that route on purpose: it was a self-service mint, a browser could
grant its own org $5, and commerce's own `api/billing/mint_gates_test.go` names it the
TOCTOU double-mint. Credit is minted only through the mint-gated `POST
/v1/billing/credit`. The call had been failing silently, so restoring it would have
re-opened a closed money hole to fix nothing. The trial credit still lands — commerce
grants it server-side when a card is vaulted, and signup grants it server-side — and
that path is untouched. `src/lib/billing/welcome.ts` had no callers left at all.
Scope, checked rather than assumed: `/v1/finance/payment-methods` is still 401 (alive)
and `/v1/finance/methods` is 404, so the finance ledger keeps the compound name — a
blanket repo-wide rename would have broken it. The Billing Center's tab slugs
(`/billing/payment-methods`, `/billing/credits`) are console page URLs, not server
routes, and are unchanged.
Two headlines were lying about which layer failed. "Card top-up isn't available on this
deployment yet" and "Adding a card isn't available on this deployment yet" both fire
when the ORG has no Square `applicationId`/`locationId` — a per-organization
configuration, not a property of the deployment. Both now name the organization, as
does the onboarding step's "Payments aren't set up", which had the same defect. The
stale `GET /v1/billing/payment-config` endpoint hints under those cards now read
`settings`.
## The assistant has one home, and the app directory is one you can walk
Three fixes to the console's own chrome. Each root cause was measured in a browser
on computed style, geometry or where the browser lands — never inferred from source.
**The assistant lived in a third place.** `FloatingChat` owned every shape the
assistant can take (the sheet, the docked column, the dock state) except the way
in, which was two small buttons in the TOPBAR — a brand-H "Chat with Hanzo" and a
"Talk to Hanzo" mic — wedged between the search box and the org/theme/alert
cluster. On a 390px phone that put five controls in the header and squeezed the
search field to "Search or jump…". Both controls moved into `AssistantFab`, one
floating cluster fixed bottom-right, in the corner the assistant actually appears
in. Chat and voice are the same surface opened two ways, so they sit together.
Nothing about the assistant was rewritten: the FAB calls the same `openChat` /
`startVoice` the topbar called, and `open`/`toggle`/`ask` still drive it
programmatically (the Code hub's "Ask AI").
It is suppressed exactly where the assistant is already on screen — while the
sheet is open, on the pages that ARE a composer, and, at `lg+` only, while it is
docked as a column. That last half is a CSS media prop rather than a JS branch so
SSR and first paint agree, and it sits above the Developers dock (whose collapsed
bar is 44px and exists only at `lg+`).
**[BUG, measured] All products was a directory you could not walk.** The pane that
lists every Hanzo app — the sidebar's "All products", the one place the whole
catalog is browsable — rendered each app as an inert `XStack`: a plain `DIV` with
`role=null` and `cursor: auto`, no handler, no pointer affordance. Measured, not
read. The only live control in the row was the pin, so a user could curate the
sidebar but could not open anything from the list. The row now opens its app
through the shared `openProduct` — the ONE opener the sidebar, ⌘K and the category
pages already route through — and closes the pane behind it, because a directory is
not a destination. Pin stays a separate control on the same row and stops the press
from bubbling: curating never navigates, navigating never curates.
**[BUG] A pin made after sign-in was thrown away on the next reload.** Preferences
are read off `properties['hanzo.preferences']` in the IAM access token's claims — a
SNAPSHOT taken when that token was minted. An earlier lane fixed the case where the
snapshot is SILENT about a key. The other half was never closed: once a user has
saved anything, the next token CARRIES a snapshot, and the merge let it win over a
newer local write. So the second pin onward read as pinned and was gone after F5.
The merge is now told the ordering it was missing. `Account` carries the token's
own `iat`; the provider stamps `…prefs.<user>.writtenAt` when — and only when — the
SERVER acknowledges a write; `mergePrefs(cached, fromAccount, order)` lets the cache
win only when a confirmed write is newer than the snapshot. Last writer wins, and
both writers are now identifiable. A fresh device (no cache, no stamp) and a fresh
sign-in (token minted after the write) both still take the account wholesale, so
cross-device is preserved. Stamping only server-confirmed writes is what keeps this
from being localStorage impersonating a backend: a save that never landed earns
nothing and the account stays authoritative.
**Backend gap, named rather than papered over.** There is no READ for this
document. `PATCH /v1/ai/preferences` (hanzoai/ai `UpdatePreferences`) writes it to
the IAM user's `properties['hanzo.preferences']` and returns the merged result;
nothing serves a GET, so the token's snapshot is the only read the console has. The
smallest seam that removes the ordering problem entirely is `GET
/v1/ai/preferences` returning that property after the handler's existing
`refreshSessionUser` — the write path already does every part of it. Better still
is `GET/PATCH /v1/prefs` (hanzoai/cloud `apps/prefs`), the canonical cross-surface
plane, which answers 503 on api.hanzo.ai today.
**House rule: one filled CTA.** Counted by computed background luminance on the
rendered home, not by reading JSX: FIVE white-filled buttons competed — "Take the
tour", the getting-started card's active step, and all three `PrimaryActionTile`
CTAs (two of them saying "Get API key"). The same measurement now returns ONE: the
checklist's ACTIVE step, the thing to do next. The tour is a neutral aside beside its
dismiss, and the three tiles are neutral because they are PEERS — a menu of things
you can do, not a call to action, and three primaries are none.
**Verification.** `tsc --noEmit` clean; `vitest` 3175 passed / 8 skipped (256 files,
+6 ordering tests). RED→GREEN proven both ways: disabling `cacheIsNewer` turns the
two new ordering tests red and the browser test with it. `e2e/assistant-fab-and-apps.spec.ts`
(4 tests, 1440 and 390) asserts the control's BOX is in the bottom-right quadrant and
≥44px, that `.hz-topbar` carries no assistant control, that clicking an app in All
products LANDS on `/agents`, and that a pin survives a reload under a token whose
snapshot is an hour old. `e2e/chrome-brand-voice.spec.ts` was retargeted, not
deleted — every claim it made still holds, only the location moved.
Two spec gotchas worth keeping. `_session.ts`'s `b64` emitted plain base64; that is
fine while a forged payload is tiny, but `+`/`/` appear as soon as one grows (a
`properties` bag is enough) and a strict decoder rejects the token outright — the SDK
reports signed out and the app sits on its loader forever. It emits base64URL now,
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.
+36 -1
View File
@@ -1,5 +1,5 @@
Hanzo Cloud Console (console2)
Copyright (c) Hanzo AI, Inc. Licensed BSD-3-Clause (see LICENSE).
Copyright (c) Hanzo AI, Inc. Licensed MIT OR Apache-2.0 (see LICENSE) per HIP-0137.
------------------------------------------------------------------------
Third-party attribution
@@ -39,3 +39,38 @@ Langfuse EE / commercial ("ee") code is neither used nor referenced.
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
------------------------------------------------------------------------
Vendored MIT-licensed code
------------------------------------------------------------------------
Portions of this software are derived from upstream MIT-licensed code. Those
copyright notices are retained here per the MIT License's terms; they were
previously carried in LICENSE, which is reserved for this project's own
BSD-3-Clause grant.
Copyright (c) 2020 Nate Wienert (Tamagui)
Copyright (c) 2015-present, Nicolas Gallagher. (react-native-web)
Copyright (c) 2015-present, Facebook, Inc. (react-native-web)
Copyright (c) 2021 Radix (Radix UI)
Copyright (c) 2017 Carmelo Pullara
Copyright (c) 2018 Framer B.V. (Framer Motion)
Copyright (c) 2022 WorkOS
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+3 -1
View File
@@ -48,4 +48,6 @@ the product-module registry, and the Providers surface). Endpoint reference in
## License
BSD-3-Clause. Copyright (c) 2026-present, Hanzo AI, Inc.
`MIT OR Apache-2.0` at your option — see [LICENSE](./LICENSE),
[LICENSE-MIT](./LICENSE-MIT), [LICENSE-APACHE](./LICENSE-APACHE).
Copyright (c) 2026-present, Hanzo AI, Inc. Estate-wide licensing standard: HIP-0137 (`hanzoai/hips`).
+6 -1
View File
@@ -3,11 +3,14 @@ import type { ReactNode } from 'react'
import { Preferences } from '~/lib/products/preferences'
import { Toast } from '~/components/ui/Toast'
import { Entry } from '~/entry/entry'
import { Host } from '~/entry/host'
/**
* The console entry, decomplected (see src/entry/). `Preferences` + `Toast` are the
* session-tier context: the stage RESOLVER reads the onboarding preference, and the
* onboard wizard + every module report through Toast — so they sit above the switch.
* `Host` answers the two effects `@hanzo/ui/product`'s state cards ask for (sign in,
* add credits), so every card below renders its affordance without being handed one.
* `Entry` computes ONE stage value from the session and renders EXACTLY one surface
* (sign-in · waitlist · org · onboard · dashboard).
*/
@@ -15,7 +18,9 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
return (
<Preferences>
<Toast>
<Entry>{children}</Entry>
<Host>
<Entry>{children}</Entry>
</Host>
</Toast>
</Preferences>
)
+10 -7
View File
@@ -21,15 +21,12 @@ import { ProductRoute } from '~/components/ProductRoute'
import { openProduct } from '~/lib/products/open'
import { useFavorites } from '~/lib/products/favorites'
import { useIsSuperAdmin } from '~/lib/auth/admin'
import { PageHeader } from '~/components/ui/PageHeader'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import { ProductIcon } from '~/components/ui/ProductIcon'
import type { IconLike } from '~/components/ui/color'
import { useProductColors } from '~/lib/products/pins'
import { FadeIn } from '~/components/ui/FadeIn'
import { livingOverviewModule } from '~/components/products/overview/living/LivingOverviewModule'
import { ResourceOverview } from '~/components/products/overview/ResourceOverview'
import { ProductObservability } from '~/components/products/observability/ProductObservability'
import { FadeIn, PageHeader, type IconLike } from '@hanzo/ui/product'
// The home centerpiece is the reusable LivingOverview (count-up KPIs, live
// sparklines, streaming activity) — the SAME component every product overview uses.
@@ -148,13 +145,19 @@ function PrimaryActionTile({
{description}
</Text>
<XStack>
<PrimaryButton
{/* Neutral, not filled. These three tiles are PEERS — a menu of things you can
do, not a call to action — so three white buttons side by side gave the
screen three primaries and therefore none. The one filled action on this
page is the getting-started card's active step: the thing to do NEXT. */}
<Button
size="$3"
borderWidth={1}
borderColor="$borderColor"
iconAfter={external ? <ExternalLink size={15} /> : <ArrowRight size={15} />}
onPress={onPress}
>
{ctaLabel}
</PrimaryButton>
</Button>
</XStack>
</Card>
)
@@ -234,7 +237,7 @@ export default function DashboardHome() {
icon={HandCoins}
color={colorOf('authors')}
title="Earn from your OSS"
description="Earn 20% of the compute margin your open-source project drives when teams run it on Hanzo Cloud — paid to your Hanzo wallet."
description="Earn 20% of the compute margin your open-source project drives when organizations run it on Hanzo Cloud — paid to your Hanzo wallet."
ctaLabel="Start earning"
onPress={() => push('/authors')}
/>
+4 -4
View File
@@ -95,7 +95,7 @@ export async function GET(req: NextRequest, ctx: Ctx) {
/**
* POST — the GLOBAL-admin mutations that ride the same god-view gate
* (`/v1/admin/providers/{toggle,primary}`, `/v1/admin/spend-caps` create). Identical
* (`/v1/admin/providers/{toggle,primary}`, `/v1/admin/caps` create). Identical
* path through `getAdminGate` (fail-closed 403) → `forwardWithUserBearer`, which applies
* the same-origin CSRF check to this mutating method BEFORE resolving the user, streams
* the JSON body through, and re-validates the path against `allowAdminSurface` (so a POST
@@ -116,16 +116,16 @@ export async function PUT(req: NextRequest, ctx: Ctx) {
}
/**
* PATCH — the GLOBAL-admin partial edits (`PATCH /v1/admin/spend-caps/:id?org=<slug>`,
* PATCH — the GLOBAL-admin partial edits (`PATCH /v1/admin/caps/:id?org=<slug>`,
* override an org's usage cap). Same gate + same CSRF/traversal hardening; the `:id`
* sub-path passes because `allowAdminSurface` admits `v1/admin/spend-caps[/...]`.
* sub-path passes because `allowAdminSurface` admits `v1/admin/caps[/...]`.
*/
export async function PATCH(req: NextRequest, ctx: Ctx) {
return handle(req, ctx)
}
/**
* DELETE — the GLOBAL-admin removals (`DELETE /v1/admin/spend-caps/:id?org=<slug>`,
* DELETE — the GLOBAL-admin removals (`DELETE /v1/admin/caps/:id?org=<slug>`,
* remove an org's usage cap). Same gate + CSRF/traversal hardening as the other
* mutating verbs; only an allow-listed head/sub-path is ever reached.
*/
+1 -1
View File
@@ -3,7 +3,7 @@
*
* `/v1/chat/completions` (and friends) REQUIRE an `Authorization: Bearer` token; a
* browser session cookie alone is rejected. Rather than ship the user's durable
* `hk-` key to the browser, the console calls its OWN origin at the canonical, prefix-free
* `sk-` key to the browser, the console calls its OWN origin at the canonical, prefix-free
* `/v1/<aihead>` (the /v1-first law); `next.config.mjs` dispatches those heads to THIS `/ai`
* proxy (re-rooting the upstream at `v1/` — invisible to the client). `forwardWithUserBearer`
* resolves the user, mints a SHORT-LIVED, user-bound IAM token (shared per-user cache in
+2 -2
View File
@@ -2,7 +2,7 @@
* /console/mfa/<action> — console-native two-factor (TOTP) enrollment BFF.
*
* WHY console-native: the console delegated 2FA to hanzo.id's account page, but the
* custom hanzo.id login worker doesn't establish a Casdoor account session, so a
* custom hanzo.id login worker doesn't establish an IAM account session, so a
* user who signed in through it lands on an account page that can't manage MFA
* (setup returns "Unauthorized operation"). This closes that gap: the user enrolls
* 2FA IN the console. We forward each IAM MFA op as the caller's OWN user bearer
@@ -20,7 +20,7 @@ import { fetchWithTimeout } from '~/lib/server/fetch-timeout'
export const runtime = 'nodejs'
const TOTP = 'app' // Casdoor TotpType
const TOTP = 'app' // IAM TotpType
/**
* IAM endpoint + the params each action sends. owner/name are ALWAYS included and
-125
View File
@@ -1,125 +0,0 @@
/* Hanzo is monochrome. One hue rendered through an opacity ladder.
Base ladder = Tailwind neutral (tailwind.config.ts). Semantic names match
hanzo.ai's CSS variables exactly, so code copies over 1:1.
DARK IS THE DEFAULT THEME (hanzo.ai mounts ThemeProvider defaultTheme="dark"). */
:root{
/* ——— base neutral ladder ——— */
--neutral-50:#FAFAFA;
--neutral-100:#F5F5F5;
--neutral-200:#E5E5E5;
--neutral-300:#D4D4D4;
--neutral-400:#A3A3A3;
--neutral-500:#737373;
--neutral-600:#525252;
--neutral-700:#404040;
--neutral-800:#262626;
--neutral-900:#171717;
--neutral-950:#0A0A0A;
--pure-black:#000000;
--pure-white:#FFFFFF;
/* Press-kit brand constants (public/press/hanzo/README.md). */
--hanzo-black:#0A0A0B;
--hanzo-white:#FFFFFF;
/* ——— the opacity ladder: the real palette ——— */
--white-05:rgb(255 255 255 / .05);
--white-10:rgb(255 255 255 / .10);
--white-15:rgb(255 255 255 / .15);
--white-20:rgb(255 255 255 / .20);
--white-30:rgb(255 255 255 / .30);
--white-40:rgb(255 255 255 / .40);
--white-60:rgb(255 255 255 / .60);
--white-80:rgb(255 255 255 / .80);
/* ——— semantic aliases (dark, the default) ——— */
--background:#000000;
--foreground:#ededed;
--card:#0a0a0a;
--card-foreground:#f5f5f5;
--popover:#0a0a0a;
--popover-foreground:#f5f5f5;
--primary:#ffffff;
--primary-foreground:#000000;
--secondary:#1a1a1a;
--secondary-foreground:#f5f5f5;
--muted:#101010;
--muted-foreground:#888888;
--accent:#1a1a1a;
--accent-foreground:#f5f5f5;
--destructive:#666666;
--destructive-foreground:#f5f5f5;
--border:#1f1f1f;
--input:#1f1f1f;
--ring:#333333;
--brand:#e4e4e7;
--brand-foreground:#09090b;
--brand-muted:#a3a3a3;
--black:#000000;
--white:#f5f5f5;
/* ——— surface recipes (card fills used across hanzo.ai) ——— */
--surface-page:var(--background);
--surface-card:rgb(23 23 23 / .5); /* bg-neutral-900/50 — grid tiles */
--surface-card-emphasis:rgb(23 23 23 / .8);/* bg-neutral-900/80 — featured */
--surface-card-quiet:rgb(23 23 23 / .4); /* bg-neutral-900/40 — story cards */
--surface-overlay:rgb(10 10 10 / .95); /* dropdown / popover panels */
--surface-header:rgb(0 0 0 / .7); /* fixed nav, with backdrop blur */
--border-hairline:var(--neutral-800);
--border-card:var(--white-10);
--border-strong:var(--neutral-700);
/* ——— text ranks ——— */
--text-primary:var(--pure-white);
--text-secondary:var(--white-80);
--text-tertiary:var(--white-60);
--text-helper:var(--muted-foreground);
--text-disabled:var(--white-30);
/* ——— the ONLY permitted hues (DESIGN.md §2.4) ——— */
--state-error:#ef4444; /* red-500 — destructive / blocking error */
--state-error-text:#fca5a5; /* red-300 */
--state-error-bg:rgb(239 68 68 / .1);
--state-online:#4ade80; /* green-400 — live status dot */
--state-success:#22c55e; /* green-500 — "Free" / "Save N%" callouts */
--chrome-dot-red:rgb(239 68 68 / .6);
--chrome-dot-yellow:rgb(234 179 8 / .6);
--chrome-dot-green:rgb(34 197 94 / .6);
}
/* Light theme — the same tokens, inverted. Rare: only /brand-style docs pages. */
.light{
--background:#ffffff;
--foreground:#0a0a0a;
--card:#f5f5f5;
--card-foreground:#0a0a0a;
--popover:#ffffff;
--popover-foreground:#0a0a0a;
--primary:#0a0a0a;
--primary-foreground:#ffffff;
--secondary:#f5f5f5;
--secondary-foreground:#0a0a0a;
--muted:#f5f5f5;
--muted-foreground:#525252;
--accent:#f5f5f5;
--accent-foreground:#0a0a0a;
--destructive:#999999;
--destructive-foreground:#ffffff;
--border:#e5e5e5;
--input:#e5e5e5;
--ring:#d4d4d4;
--black:#0a0a0a;
--white:#ffffff;
--surface-card:#f5f5f5;
--surface-card-emphasis:#ffffff;
--surface-card-quiet:#fafafa;
--surface-overlay:rgb(255 255 255 / .95);
--surface-header:rgb(255 255 255 / .8);
--border-hairline:var(--neutral-200);
--border-card:rgb(0 0 0 / .1);
--border-strong:var(--neutral-300);
--text-primary:var(--neutral-950);
--text-secondary:rgb(10 10 10 / .8);
--text-tertiary:rgb(10 10 10 / .6);
--text-disabled:rgb(10 10 10 / .3);
}
-19
View File
@@ -1,19 +0,0 @@
/* Hanzo barely uses shadow: on black, elevation reads as a hairline border plus
a wide, very dark drop. Only two levels ship (Tailwind's shadow-2xl for
floating surfaces) plus the ambient radial glow used behind heroes. */
:root{
--shadow-none:none;
--shadow-floating:0 25px 50px -12px rgb(0 0 0 / .25); /* shadow-2xl: composer, dropdowns, mega panel */
--shadow-inset-hairline:inset 0 0 0 1px var(--white-10);
--ring-focus:0 0 0 2px var(--ring);
/* Ambient hero glow — a single white radial, blurred 120px, low opacity. */
--glow-hero:radial-gradient(circle,rgb(255 255 255 / .12) 0%,transparent 68%); /* @kind color */
--glow-hero-blur:120px;
/* Card top-corner sheen used on the story cards. */
--sheen-card:radial-gradient(120% 120% at 80% 0%,rgb(255 255 255 / .08) 0%,transparent 55%); /* @kind color */
/* Chrome text: the canonical headline gradient. Never a saturated rainbow. */
--gradient-chrome:linear-gradient(to right,#ffffff,var(--white-80),var(--white-60));
--gradient-chrome-2:linear-gradient(to right,#ffffff,var(--neutral-500));
/* Section-top protection gradient (hero overlays). */
--gradient-protect:linear-gradient(to bottom,var(--white-10),transparent);
}
+19 -21
View File
@@ -1,30 +1,28 @@
/* ─────────────────────────────────────────────────────────────────────────────
Hanzo Design System tokens — VENDORED from hanzoai/design (@hanzo/design).
Hanzo Design System tokens — the PUBLISHED @hanzo/design package.
This directory is the canonical Hanzo design language expressed as CSS custom
properties: the monochrome neutral ladder + opacity ladder, the ONLY permitted
semantic hues (live/success/warning/error), type scale, 4px spacing ramp,
radius, elevation and motion. It is the SINGLE SOURCE OF TRUTH for how every
Hanzo surface looks.
These were vendored under app/design/ (synced 2026-07-24) only because the
package was not yet on npm. It is now (@hanzo/design ≥ 0.4.6), so the console
reads the real dependency and can no longer drift a border rework behind the
rest of the fleet. The token subpaths are named one by one rather than pulling
`@hanzo/design/styles.css`: that entry chains relative `@import url(...)`s that
Next's CSS pipeline resolves as modules, not sibling files, so the explicit
published subpaths are the resolvable form of the same import.
@hanzo/design is not yet published to npm, so its token layer is vendored here
verbatim (the README's stated contract: "code copies over 1:1"). Do NOT edit
these files in the console — edit them in hanzoai/design and re-sync. Synced
from hanzoai/design tokens/ on 2026-07-24.
The console's Tamagui theme (app/globals.css) DERIVES its --colorN ladder and
borders from this neutral ladder, so the whole product reads from one palette.
Fonts are deliberately NOT imported from the package: the console loads the
Geist faces via app/fonts.css, and the `:root` shim below lets the vendored
typography roles resolve without a second copy.
───────────────────────────────────────────────────────────────────────────── */
@import './colors.css';
@import './typography.css';
@import './spacing.css';
@import './radius.css';
@import './elevation.css';
@import './motion.css';
@import './z.css';
@import '@hanzo/design/tokens/colors.css';
@import '@hanzo/design/tokens/typography.css';
@import '@hanzo/design/tokens/spacing.css';
@import '@hanzo/design/tokens/radius.css';
@import '@hanzo/design/tokens/elevation.css';
@import '@hanzo/design/tokens/motion.css';
@import '@hanzo/design/tokens/z.css';
/* Font families — the console loads the Geist faces via app/fonts.css; these vars
let the vendored typography roles (--type-*) resolve without re-importing fonts. */
let the typography roles (--type-*) resolve without re-importing fonts. */
:root {
--font-sans: 'Geist', 'Geist Sans', ui-sans-serif, system-ui, sans-serif;
--font-display: var(--font-sans);
-24
View File
@@ -1,24 +0,0 @@
/* Motion is restrained: fade + small rise, CSS-only hovers, one breathing glow.
No springs, no bounce, no parallax, no autoplay carousels. */
:root{
--duration-fast:150ms; /* @kind other */ /* dropdown / panel open */
--duration-base:300ms; /* @kind other */ /* slide-up-fade */
--duration-slow:400ms; /* @kind other */ /* hero element entry */
--duration-slower:500ms; /* @kind other */ /* section entry */
--duration-glow:9s; /* @kind other */ /* ambient radial breathe */
--ease-out:cubic-bezier(0,0,0.2,1); /* @kind other */
--ease-in-out:cubic-bezier(0.4,0,0.2,1); /* @kind other */
--stagger:60ms; /* @kind other */ /* per-element delay in a group */
--entry-rise:16px; /* hero y-offset */
--entry-rise-lg:24px; /* card y-offset */
}
@keyframes hanzo-fade-up{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}
@keyframes hanzo-fade-down{from{opacity:0;transform:translateY(-10px)}to{opacity:1;transform:translateY(0)}}
@keyframes hanzo-slide-up-fade{from{opacity:0;transform:translateY(20px)}to{opacity:1;transform:translateY(0)}}
@keyframes hanzo-glow{0%,100%{transform:scale(1);opacity:.45}50%{transform:scale(1.08);opacity:.65}}
@keyframes hanzo-pulse-dot{0%,100%{opacity:1}50%{opacity:.35}}
@media (prefers-reduced-motion:reduce){
*,*::before,*::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important}
}
-10
View File
@@ -1,10 +0,0 @@
:root{
--radius:0.5rem; /* the base token (globals.css) */
--radius-sm:0.375rem; /* rounded-md — buttons, inputs */
--radius-md:0.5rem;
--radius-lg:0.75rem; /* rounded-xl — cards */
--radius-xl:1rem; /* rounded-2xl — dropdown panels */
--radius-2xl:1.5rem; /* rounded-3xl — story / hero cards */
--radius-composer:28px; /* the chat composer, exactly 28px */
--radius-full:9999px; /* pills, CTAs, avatars, badges */
}
-45
View File
@@ -1,45 +0,0 @@
/* Spacing: the 4px Tailwind ramp is what ships. The golden-ratio ramp below is
declared in hanzo.ai's tailwind.config.ts (legacy v3 config, kept for
reference) — use it for editorial layouts, not for component padding. */
:root{
--space-0:0;
--space-1:0.25rem;
--space-2:0.5rem;
--space-3:0.75rem;
--space-4:1rem;
--space-5:1.25rem;
--space-6:1.5rem;
--space-8:2rem;
--space-10:2.5rem;
--space-12:3rem;
--space-14:3.5rem;
--space-16:4rem;
--space-20:5rem;
--space-24:6rem;
--space-32:8rem;
/* golden ramp (φ) — hanzo.ai tailwind.config.ts */
--golden-1:0.25rem;
--golden-2:0.405rem;
--golden-3:0.654rem;
--golden-4:1.059rem;
--golden-5:1.713rem;
--golden-6:2.772rem;
--golden-7:4.487rem;
--golden-8:7.26rem;
--golden-9:11.749rem;
--golden-split:38.2% 61.8%; /* @kind other */
/* layout rules (DESIGN.md §1.3) */
--container-max:80rem; /* max-w-7xl — grids */
--container-prose:48rem; /* max-w-3xl — centered text */
--container-wide:72rem; /* max-w-6xl — landing sections */
--gutter:1rem; /* px-4 */
--gutter-sm:1.5rem; /* sm:px-6 */
--gutter-lg:2rem; /* lg:px-8 */
--section-y:4rem; /* py-16 — content sections */
--section-y-lg:6rem; /* py-24 — landing sections */
--hero-y:5rem; /* py-20 … */
--hero-y-lg:8rem; /* … lg:py-32 */
--header-height:4rem;
}
-46
View File
@@ -1,46 +0,0 @@
/* TIGHT app-first type scale — the compact developer-app register (linear.app /
vercel.com / the Codex desktop look), the Hanzo default across chat / app /
desktop. Base is 14px, nav 13px, labels 11px; display sizes tightened. Kept in
lockstep with @hanzo/brand (styles/variables.css --font-size-* + typography.ts)
— the two are the SAME scale, mirrored. A surface/tenant overrides any --text-*
on :root to retune density on demand. */
:root{
--text-xs:0.6875rem; --leading-xs:1rem; /* 11px — eyebrows / section labels */
--text-sm:0.8125rem; --leading-sm:1.15rem; /* 13px — nav labels, dense body */
--text-base:0.875rem; --leading-base:1.35rem; /* 14px — base app text (was 16px) */
--text-lg:0.9375rem; --leading-lg:1.4rem; /* 15px */
--text-xl:1.0625rem; --leading-xl:1.55rem; /* 17px */
--text-2xl:1.3125rem; --leading-2xl:1.7rem; /* 21px */
--text-3xl:1.625rem; --leading-3xl:1.95rem; /* 26px */
--text-4xl:2rem; --leading-4xl:2.25rem; /* 32px */
--text-5xl:2.5rem; --leading-5xl:1.05; /* 40px */
--text-6xl:3.25rem; --leading-6xl:1; /* 52px */
--text-7xl:4rem; --leading-7xl:1; /* 64px */
--weight-normal:400;
--weight-medium:500;
--weight-semibold:600;
--weight-bold:700;
--tracking-tight:-0.025em;
--tracking-normal:0em;
--tracking-wide:0.025em;
--tracking-widest:0.1em; /* eyebrows / uppercase category labels */
--leading-none:1;
--leading-tight:1.25;
--leading-snug:1.375;
--leading-normal:1.5;
--leading-relaxed:1.625;
--leading-golden:1.618;
/* named roles */
--type-hero:600 var(--text-5xl)/1.05 var(--font-display);
--type-h2:700 var(--text-4xl)/var(--leading-4xl) var(--font-display);
--type-h3:600 var(--text-xl)/var(--leading-xl) var(--font-display);
--type-lead:400 var(--text-lg)/var(--leading-relaxed) var(--font-sans);
--type-body:400 var(--text-sm)/var(--leading-sm) var(--font-sans);
--type-caption:400 var(--text-xs)/var(--leading-xs) var(--font-sans);
--type-code:400 var(--text-sm)/var(--leading-relaxed) var(--font-mono);
--type-eyebrow:600 0.625rem/1 var(--font-sans);
}
-14
View File
@@ -1,14 +0,0 @@
/* Stacking order — the one z-index ladder. Layers are named by role, never by a
magic number, so a dropdown opened from the fixed header always sits above it
and nothing ever reaches for 9999. Below --z-raised is ordinary document flow. */
:root{
--z-base:0;
--z-raised:10; /* hover-lifted cards, sticky table headers */
--z-sticky:200; /* pinned section rails */
--z-header:300; /* the fixed site header */
--z-dropdown:400; /* menus, selects, comboboxes */
--z-overlay:500; /* dialog / sheet scrim */
--z-modal:600; /* dialogs, sheets, command palette */
--z-popover:700; /* popovers, tooltips (also when anchored in modals)*/
--z-toast:800; /* toasts / notifications — always on top */
}
+5 -5
View File
@@ -1,5 +1,5 @@
/**
* Per-user `hk-` Cloud API key — the SAME-ORIGIN console route (the fix for the
* Per-user `sk-` Cloud API key — the SAME-ORIGIN console route (the fix for the
* API-keys "sign in to manage API keys" / CORS crack).
*
* The browser calls this OWN-origin route (`/keys`) with just its first-party
@@ -7,19 +7,19 @@
* (`resolveUser`) and mints/reads/revokes the key through IAM as the confidential
* `hanzo-console` client (`identity.ts` `mintUserKey`/`getUserKey`/`revokeUserKey`,
* over IAM `mint-user-keys`/`get-user`/`revoke-user-keys` — the WORKING key path,
* verified live). No credential ever reaches the browser; the `hk-` secret is
* verified live). No credential ever reaches the browser; the `sk-` secret is
* returned ONLY by POST (show once).
*
* Why not `cloud.hanzo.ai/v1/iam/keys` (the old path): that is a DIFFERENT
* ORIGIN than console.hanzo.ai, so a browser `fetch` is blocked by CORS ("Failed to
* fetch") — and cloud-api's own keys handler 501s ("IAM client unset") on this
* deployment anyway. The IAM confidential-client mint the console already uses for
* `hk-` keys elsewhere (`app/ai` chat) is the ONE authoritative, same-origin,
* `sk-` keys elsewhere (`app/ai` chat) is the ONE authoritative, same-origin,
* always-working path — so the Org-Settings API-keys surface uses it too (DRY: the
* exact primitives from `identity.ts`, no new IAM plumbing).
*
* GET → { hasKey, keyPrefix, createdAt } (no secret)
* POST → { accessKey } (mint/rotate; full hk- shown ONCE)
* POST → { accessKey } (mint/rotate; full sk- shown ONCE)
* DELETE → { ok: true } (revoke; the old key stops working)
*/
import { type NextRequest, NextResponse } from 'next/server'
@@ -57,7 +57,7 @@ export async function GET(req: NextRequest) {
}
}
/** POST — mint (or rotate) the key. Returns the full `hk-` secret ONCE. */
/** POST — mint (or rotate) the key. Returns the full `sk-` secret ONCE. */
export async function POST(req: NextRequest) {
// CSRF: minting mutates (and is billable-adjacent) from the auto-sent cookie —
// refuse a cross-origin request before any work.
+5
View File
@@ -4,6 +4,11 @@ import '@hanzogui/core/reset.css'
// source of truth. Imported BEFORE globals.css so the console's Tamagui theme can
// derive its ladder from the design neutral/semantic tokens.
import './design/index.css'
// The motion/skeleton classes `@hanzo/ui/product` components emit (`skeleton`,
// `row`, `tnum`, `fade-up`, `drag`). Console's own markup still names the `hz-`
// prefixed twins in globals.css below; these are the package's, and without this
// import a DataTable's skeleton, row hover and tabular figures render unstyled.
import '@hanzo/ui/styles/motion.css'
import './globals.css'
import type { Metadata, Viewport } from 'next'
+11 -16
View File
@@ -1,6 +1,6 @@
/**
* Same-origin proxy to the cloud ML/training surface on hanzoai/ai (`/v1/train/*`,
* `/v1/ml/models`, and the fine-tuning broker `/v1/finetune/*`).
* Same-origin proxy to the cloud ML/training surface (`/v1/ml/models` and the
* fine-tuning broker `/v1/finetune/*`).
*
* The console's Training page calls its OWN origin (`/training/...`) with just the
* first-party session cookie; this server handler resolves the signed-in user from
@@ -11,16 +11,16 @@
* this is user-scoped (resolveUser), NOT the control-plane admin gate the `/paas`
* proxy uses. The cloud backend resolves the org from the token's `owner` claim (and
* the X-Org-Id the plain-REST train sub-service reads), so a caller can only ever
* touch their own org's jobs. `POST /v1/train/jobs` is billing-gated by the live
* ResourceMeter and returns 402 on an unfunded org — that status flows straight back
* so the UI can surface it honestly.
* touch their own org's jobs. `POST /v1/finetune/jobs` is billing-gated upstream and
* returns 402 on an unfunded org — that status flows straight back so the UI can
* surface it honestly.
*
* Why a Bearer and NOT the cookie (the fix for the "Not enabled" 403): cloud-api's
* `/v1/train/*` authorizes on a VALIDATED JWT principal and returns 403 "no validated
* `/v1/*` authorizes on a VALIDATED JWT principal and returns 403 "no validated
* principal" for a cookie-only call — the raw casibase session cookie is NOT a
* principal it accepts (only the sanitizer's cookie-token names or a Bearer). Minting
* the same user-bound token the `/v1` proxy uses is the ONE way a signed-in tenant
* reaches the train surface; the cookie is deliberately dropped upstream (it can't
* reaches this surface; the cookie is deliberately dropped upstream (it can't
* authenticate, and a cookie + JWT together risks the public-gateway 431).
*
* Least privilege: only the explicit ML/training sub-paths are forwarded; anything
@@ -44,14 +44,9 @@ const CLOUD_API_URL = trim(process.env.CLOUD_API_URL ?? 'http://cloud.hanzo.svc.
/** The exact `/v1/<...>` ML/training sub-paths the console is allowed to reach. */
const ALLOWED = new Set([
// mlsvc — the canonical training surface (task #40 ResourceMeter gates POST jobs).
'train/jobs',
'train/experiments',
// Model serving — the org's deployed kserve InferenceServices.
'ml/models',
// Real Kubeflow control-plane probe (which operators/CRDs are actually served).
// Read-only; 503 + body flows through so the UI can report a degraded plane.
'train/health',
// fine-tuning broker (custom-data runs, HF search) — sibling surface.
// fine-tuning broker (custom-data runs, HF search) — the ONE training door.
'finetune/jobs',
'finetune/job',
'finetune/cancel',
@@ -68,7 +63,7 @@ async function forward(req: NextRequest, path: string[]): Promise<NextResponse>
return NextResponse.json({ status: 'error', msg: 'Not found' }, { status: 404 })
}
// CSRF: `POST /train/jobs` mutates (and bills) from the auto-sent cookie — refuse a
// CSRF: `POST /finetune/jobs` mutates (and bills) from the auto-sent cookie — refuse a
// cross-origin one before any work (safe reads pass).
const csrf = csrfRefusal(req, 'casibase')
if (csrf) return csrf
@@ -82,7 +77,7 @@ async function forward(req: NextRequest, path: string[]): Promise<NextResponse>
}
// Mint a short-lived, user-bound Bearer (the SAME per-user cache the `/v1`
// proxy uses). cloud-api's `/v1/train/*` 403s a cookie-only call ("no validated
// proxy uses). cloud-api's `/v1/*` 403s a cookie-only call ("no validated
// principal"); a Bearer is the one credential it accepts. Fail CLOSED with 502 if
// the token can't be minted — never fall through to an unauthenticated forward.
let bearer: string
+2 -2
View File
@@ -10,8 +10,8 @@
* billing UI tab URLs (`/billing/reports`, `/billing/invoices`, …) — they differ at
* the FIRST path segment, so the tab slugs fall through to the SPA.
*
* Verbs: GET (reads: balance/usage/invoices/subscriptions/payment-methods, and the
* per-invoice PDF), POST (writes: top-up, spend-alerts, save-a-method, cancel/
* Verbs: GET (reads: balance/usage/invoices/subscriptions/methods, and the
* per-invoice PDF), POST (writes: top-up, alerts, save-a-method, cancel/
* reactivate a subscription), PATCH (edit a budget/spend-alert), DELETE (detach a
* saved payment method, remove a budget). Each is scoped to the caller's OWN org
* server-side; a mutating verb is CSRF-guarded (`forwardBilling`).
Vendored
+5
View File
@@ -0,0 +1,5 @@
// Side-effect CSS imports (`import './globals.css'`, `import '@hanzogui/core/reset.css'`).
// The bundler owns them; TypeScript only needs to know the specifier resolves.
// TS7 (tsgo) errors on an unresolvable side-effect import (TS2882) where tsc stayed
// silent, so the declaration lives here — one place, every stylesheet.
declare module '*.css'
+3 -2
View File
@@ -2,8 +2,9 @@
The console talks to the unified Hanzo Cloud backend (`hanzoai/cloud`).
Base URL: `${NEXT_PUBLIC_CLOUD_URL}/v1`. All requests send cookie
credentials; responses are the envelope `{ status, msg, data, data2 }` (`data2`
is the total row count on list endpoints).
credentials; responses are the envelope `{ status, msg, data, total }` (`total`
is the row count on list endpoints; the legacy `data2` count is still accepted
as a fallback until every emitter finishes the rename).
Client modules live in `src/lib/api/`.
+24 -8
View File
@@ -27,12 +27,24 @@ export type SessionClaims = {
email?: string
displayName?: string
isAdmin?: boolean
/** The IAM user's property bag — where `hanzo.preferences` rides as a SNAPSHOT. */
properties?: Record<string, string>
/** When the token was minted (`iat`, seconds). Defaults to now; set it in the past
* to reproduce the production case where the snapshot predates a later write. */
issuedAt?: number
}
const b64 = (o: object): string => Buffer.from(JSON.stringify(o)).toString('base64')
/**
* base64URL — what a JWT segment actually is. Plain base64 was close enough while the
* payloads were tiny, but `+` and `/` appear as soon as one grows (a `properties` bag
* is enough), and a strict decoder rejects the token outright: the SDK reports signed
* out and the app sits on its loader forever.
*/
const b64 = (o: object): string =>
Buffer.from(JSON.stringify(o)).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
/** The default identity render specs run as — a hanzo-org admin. */
export const DEFAULT_CLAIMS: Required<SessionClaims> = {
export const DEFAULT_CLAIMS: Required<Omit<SessionClaims, 'properties' | 'issuedAt'>> = {
owner: 'hanzo',
name: 'z',
email: 'z@hanzo.ai',
@@ -40,20 +52,24 @@ export const DEFAULT_CLAIMS: Required<SessionClaims> = {
isAdmin: true,
}
/** An unsigned JWT whose payload carries the claims + a far-future `exp`. */
export function forgeToken(claims: Required<SessionClaims>): string {
const payload = { ...claims, sub: `${claims.owner}/${claims.name}`, exp: Math.floor(Date.now() / 1000) + 3600 }
/** An unsigned JWT whose payload carries the claims, an `iat` and a far-future `exp`. */
export function forgeToken(claims: SessionClaims): string {
const iat = claims.issuedAt ?? Math.floor(Date.now() / 1000)
const payload = { ...claims, sub: `${claims.owner}/${claims.name}`, iat, exp: iat + 86_400 }
return `${b64({ alg: 'none' })}.${b64(payload)}.x`
}
/** Seed tokens + gate keys and register the IAM endpoint mocks. */
export async function primeSession(page: Page, overrides: Partial<SessionClaims> = {}): Promise<void> {
const claims: Required<SessionClaims> = { ...DEFAULT_CLAIMS, ...overrides }
const claims: SessionClaims = { ...DEFAULT_CLAIMS, ...overrides }
await page.addInitScript(
({ org, token }: { org: string; token: string }) => {
try {
sessionStorage.setItem('hanzo_iam_access_token', token)
sessionStorage.setItem('hanzo_iam_expires_at', String(Date.now() + 3600_000))
// localStorage, not sessionStorage: the `@hanzo/iam` token store is shared
// across tabs (that IS the session), so seeding a per-tab area would leave
// the SDK reading an empty store and every primed spec signed out.
localStorage.setItem('hanzo_iam_access_token', token)
localStorage.setItem('hanzo_iam_expires_at', String(Date.now() + 3600_000))
localStorage.setItem('hanzo.console.org', org)
localStorage.setItem('hanzo.console.org.selected', '1')
localStorage.setItem(`hz_onboarding_done:${org}`, '1')
+25 -10
View File
@@ -1,10 +1,17 @@
/**
* The ONE account control, at the foot of the rail.
* The ONE account control, at the foot of the rail — and the ONE org switch.
*
* There used to be three: an org switcher at the top of the sidebar, an account
* popover at the bottom, and a third menu in the mobile drawer — with four ways
* to sign out between them. This spec pins the replacement: one control, both
* switchers, at the bottom, in a shell that ships no Tailwind.
* There used to be three account-ish menus: an org switcher at the top of the
* sidebar, an account popover at the bottom, and a third in the mobile drawer,
* with four ways to sign out between them. They became one control that answered
* BOTH "who am I" and "where am I".
*
* They have now been split again, but by QUESTION rather than by accident: the
* account control at the foot answers who you are (identity, team, personal
* settings, balance, the way out), and `ContextSwitcher` at the TOP-LEFT answers
* where you are (organization + project, together, beside the tenant's mark).
* So the cross-tenant reach is asserted against the context switcher below, and
* the account menu is asserted to no longer offer a tenant at all.
*
* Everything is asserted on computed style and geometry. The failure this guards
* against is a menu that is present in the DOM and unreadable — a library that
@@ -40,6 +47,9 @@ const accountTrigger = (page: Page) => page.getByTestId('nav-user').first()
/** The trigger inside the phone's account sheet — the last mount in the document. */
const drawerTrigger = (page: Page) => page.getByTestId('nav-user').last()
/** The org + project control at the top-left — the only thing that switches tenant. */
const contextTrigger = (page: Page) => page.getByTestId('switcher-context').first()
async function mountConsole(page: Page, seen: Scoped[]) {
// The standalone console reaches the cross-tenant list through its own gated
// `/admin/iam` proxy; the go:embed build reaches cloud's `/v1/iam` directly.
@@ -182,18 +192,23 @@ test.describe('account control', () => {
expect(typedInCaps).toEqual([])
})
test('the org switcher reaches a tenant the caller is not a member of', async ({ page }) => {
test('the context switcher reaches a tenant the caller is not a member of', async ({ page }) => {
const seen: Scoped[] = []
await mountConsole(page, seen)
await accountTrigger(page).click()
await page.locator('[role=menu]').waitFor()
// Tenancy is the TOP-LEFT control's job now, not the account menu's.
await contextTrigger(page).click()
// Acme is nobody's membership — it exists only in the cross-tenant list an
// admin may search. A memberships-only switcher could not offer it at all.
await page.getByLabel('Find an organization').fill('acme')
const acme = page.getByRole('option', { name: 'Acme Industrial' })
// `radiogroup`/`radio`, not `listbox`/`option`: @hanzo/gui's `role` union is
// React Native's a11y set, which carries `option` but NOT `listbox`.
const orgList = page.getByRole('radiogroup', { name: 'Organizations' })
const acme = orgList.getByRole('radio', { name: 'Acme Industrial' })
await acme.waitFor()
await expect(page.getByRole('option')).toHaveCount(1)
// Scoped to the ORG group — the same popover also lists projects, and a bare
// getByRole('radio') would silently count those too.
await expect(orgList.getByRole('radio')).toHaveCount(1)
await page.screenshot({ path: 'e2e-shots/account-menu-find-org.png', animations: 'disabled' })
+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()
})
+170
View File
@@ -0,0 +1,170 @@
/**
* e2e: the assistant's ONE entry point, and the All-products directory you can act in.
*
* Three claims, each measured in a real browser rather than inferred from source:
*
* 1. The assistant opens from a FLOATING bottom-right control, not from the header —
* asserted on GEOMETRY (the control's box is in the bottom-right quadrant of the
* viewport) and on the header carrying no assistant control at all.
* 2. Clicking an app in the All-products directory NAVIGATES to that app. This is the
* regression that matters: the rows rendered, hovered, and did nothing, so the
* directory looked interactive and was not. Asserted on where the browser LANDS.
* 3. A pin made in the directory survives a reload EVEN WHEN the identity token
* carries an older preferences snapshot — the exact production condition (the
* token is minted at sign-in; a pin made after it is not in it).
*
* Local dev server + mocked network; `primeSession` supplies the IAM-PKCE identity.
*
* Run: BASE_URL=http://localhost:4000 npx playwright test assistant-fab-and-apps
*/
import { test, expect, type Route, type Page } 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'
requireFixtureServer()
const SHOTS = join(process.cwd(), 'e2e-shots')
const API_RE = /\/(v1|cloud|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations)(\/|$|\?)/
async function mock(route: Route) {
const req = route.request()
if (req.resourceType() === 'document') return route.continue()
const url = new URL(req.url())
const sameOrigin = url.origin === new URL(BASE_URL).origin
if (sameOrigin && !API_RE.test(url.pathname)) return route.continue()
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ status: 'ok', msg: '', data: [], data2: 0 }),
})
}
/** Sign in and land on `path`, waiting for the signed-in shell to have mounted. */
async function boot(page: Page, path = '/', claims?: Parameters<typeof primeSession>[1]) {
await page.route('**/*', mock)
await primeSession(page, claims)
await page.goto(`${BASE_URL}${path}`, { waitUntil: 'domcontentloaded' })
await expect(page.getByRole('button', { name: 'Ask Hanzo' })).toBeVisible({ timeout: 60_000 })
}
test.beforeAll(() => mkdirSync(SHOTS, { recursive: true }))
test('the assistant opens from the bottom-right, and the header carries no AI control', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const page = await ctx.newPage()
await boot(page)
const fab = page.getByRole('button', { name: 'Ask Hanzo' })
const box = await fab.boundingBox()
expect(box).not.toBeNull()
// Bottom-right quadrant: the whole point of the relocation.
expect(box!.x).toBeGreaterThan(1440 / 2)
expect(box!.y).toBeGreaterThan(900 / 2)
// A comfortable target, not a hairline.
expect(box!.width).toBeGreaterThanOrEqual(44)
expect(box!.height).toBeGreaterThanOrEqual(44)
// The topbar itself holds no assistant control any more — it used to carry two
// (a brand-H "Chat with Hanzo" and a "Talk to Hanzo" mic) beside the search box.
const inTopbar = await page.evaluate(() =>
Array.from(document.querySelectorAll('.hz-topbar [aria-label]')).map((n) => n.getAttribute('aria-label') ?? ''),
)
expect(inTopbar).not.toHaveLength(0) // the topbar was found at all
expect(inTopbar.filter((l) => /Hanzo/i.test(l))).toHaveLength(0)
await page.screenshot({ path: join(SHOTS, 'assistant-fab-desktop.png') })
// It opens the SAME assistant surface.
await fab.click()
await expect(page.getByText('Assistant').first()).toBeVisible({ timeout: 15_000 })
await page.screenshot({ path: join(SHOTS, 'assistant-open-desktop.png') })
await ctx.close()
})
test('the assistant control is reachable on a phone and never scrolls the body sideways', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 390, height: 844 } })
const page = await ctx.newPage()
await boot(page)
const fab = page.getByRole('button', { name: 'Ask Hanzo' })
const box = await fab.boundingBox()
expect(box).not.toBeNull()
expect(box!.x + box!.width).toBeLessThanOrEqual(390)
expect(box!.y).toBeGreaterThan(844 / 2)
const [scrollW, clientW] = await page.evaluate(() => [
document.documentElement.scrollWidth,
document.documentElement.clientWidth,
])
expect(scrollW).toBe(clientW)
await page.screenshot({ path: join(SHOTS, 'assistant-fab-mobile.png') })
await ctx.close()
})
test('clicking an app in All products opens that app', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const page = await ctx.newPage()
await boot(page)
await page.getByRole('button', { name: 'All products' }).first().click()
const row = page.getByRole('button', { name: 'Open Agents' })
await expect(row).toBeVisible({ timeout: 15_000 })
await page.screenshot({ path: join(SHOTS, 'all-products-desktop.png') })
await row.click()
// Where the browser LANDS is the claim — not that a handler fired.
await expect(page).toHaveURL(/\/agents$/, { timeout: 15_000 })
await ctx.close()
})
test('a pin made in All products survives a reload under a STALE token snapshot', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const page = await ctx.newPage()
// The production condition: the identity token was minted an hour ago and carries a
// preferences SNAPSHOT from then. Treating that snapshot as authoritative is what
// silently threw away every pin made since — the pin reads as pinned, and is gone
// after a reload.
const snapshot = { pins: [{ id: 'models', group: '' }], pinGroups: [] }
await boot(page, '/', {
properties: { 'hanzo.preferences': JSON.stringify(snapshot) },
issuedAt: Math.floor(Date.now() / 1000) - 3600,
})
const openDirectory = async () => {
await page.getByRole('button', { name: 'All products' }).first().click()
// "…to sidebar" / "…from sidebar" are the directory's own labels — the home page's
// Apps map carries a plain "Pin Agents", so the short form is ambiguous.
await expect(page.getByRole('button', { name: /Agents (to|from) sidebar/ })).toBeVisible({ timeout: 15_000 })
}
// The snapshot the token carries is what the sidebar starts from.
await openDirectory()
await page.getByRole('button', { name: 'Pin Agents to sidebar' }).click()
await expect(page.getByRole('button', { name: 'Remove Agents from sidebar' })).toBeVisible()
// Only a write the SERVER acknowledged earns the stamp that out-ranks the snapshot.
await expect
.poll(() => page.evaluate(() => localStorage.getItem('hanzo.console2.prefs.z.writtenAt')), { timeout: 10_000 })
.not.toBeNull()
await page.reload({ waitUntil: 'domcontentloaded' })
await expect(page.getByRole('button', { name: 'Ask Hanzo' })).toBeVisible({ timeout: 60_000 })
// Still pinned — the hour-old snapshot did not win. Asserted on what the user sees…
await openDirectory()
await expect(page.getByRole('button', { name: 'Remove Agents from sidebar' })).toBeVisible({ timeout: 15_000 })
// …and on what was actually kept (models from the snapshot, agents from the write).
const pins = await page.evaluate(() => {
const raw = JSON.parse(localStorage.getItem('hanzo.console2.prefs.z') ?? '{}')
return (raw.pins ?? []).map((p: { id: string }) => p.id)
})
expect(pins).toContain('agents')
expect(pins).toContain('models')
await ctx.close()
})
+10 -9
View File
@@ -1,12 +1,12 @@
/**
* e2e: two-tenant BILLING ISOLATION through the `/billing/*` proxy.
* e2e: two-tenant BILLING ISOLATION through the `/v1/billing/*` proxy.
*
* The proxy (app/billing/v1/[...path]/route.ts) resolves the billing subject from the
* The proxy (app/v1/billing/[...path]/route.ts) resolves the billing subject from the
* session server-side and pins the full subject-key set (user/userId/customerId) +
* the X-Org-Id header, so a tenant can only ever read its OWN commerce ledger. This
* spec proves that end-to-end against the LIVE proxy: two accounts in DIFFERENT orgs
* each fetch `/billing/subscriptions` (and `/payment-methods`), and we assert the
* two result sets are disjoint — neither tenant can see the other's rows.
* each fetch `/v1/billing/subscriptions` (and `/v1/billing/methods`), and we assert
* the two result sets are disjoint — neither tenant can see the other's rows.
*
* This is the regression guard for the IDOR RED found (the proxy previously pinned
* only `?user=` while commerce filters subscriptions on `?userId=`, so subscriptions
@@ -36,11 +36,12 @@ async function signIn(page: Page, email: string, password: string) {
await page.waitForLoadState('domcontentloaded')
}
/** Fetch a billing path through the same-origin DATA proxy (`/billing/v1/*`), as the
* signed-in browser. (`/billing/<slug>` without `v1/` is a UI tab, served by the SPA.) */
/** Fetch a billing path through the same-origin DATA proxy (`/v1/billing/*`), as the
* signed-in browser. (`/billing/<slug>` is a UI tab, served by the SPA — it differs at
* the FIRST path segment, so the two never collide.) */
async function billing(page: Page, path: string): Promise<{ status: number; ids: string[] }> {
return page.evaluate(async (p) => {
const res = await fetch(`/billing/v1/${p}`, { credentials: 'include', headers: { Accept: 'application/json' } })
const res = await fetch(`/v1/billing/${p}`, { credentials: 'include', headers: { Accept: 'application/json' } })
let ids: string[] = []
try {
const body = await res.json()
@@ -73,9 +74,9 @@ test.describe('billing is isolated per tenant through the proxy', () => {
await signIn(pageB, B.email, B.password)
// `invoices` is included because its row ids drive the per-invoice PDF URL
// (`/billing/v1/invoices/:id/pdf`) — proving the invoice list is tenant-isolated
// (`/v1/billing/invoices/:id/pdf`) — proving the invoice list is tenant-isolated
// proves a user can only ever build a PDF URL for their OWN org's invoices.
for (const path of ['subscriptions', 'payment-methods', 'invoices']) {
for (const path of ['subscriptions', 'methods', 'invoices']) {
const a = await billing(pageA, path)
const b = await billing(pageB, path)
+2 -2
View File
@@ -131,8 +131,8 @@ test.describe('Money/usage/o11y surface is fail-closed for anonymous (unauthenti
'/v1/billing/balance',
'/v1/billing/invoices',
'/v1/billing/usage',
'/v1/billing/payment-methods',
'/v1/billing/spend-alerts',
'/v1/billing/methods',
'/v1/billing/alerts',
'/v1/usage/summary',
'/v1/get-cloud-usages',
'/v1/o11y/observations',
+1 -1
View File
@@ -40,7 +40,7 @@ const CANONICAL_IDS: string[] = JSON.parse(readFileSync(join(process.cwd(), 'e2e
* are NOT registry ids — they must resolve via SLUG_ALIASES to a real module (never
* a 404 blank). Auditing them here proves the alias map end-to-end against the real app.
*/
const ALIAS_SLUGS = ['traces', 'deploy', 'plans-pricing', 'wallets', 'model-catalog', 'fine-tuning', 'web-search', 'mlpipelines', 'kubeflow']
const ALIAS_SLUGS = ['traces', 'deploy', 'plans-pricing', 'wallets', 'model-catalog', 'fine-tuning', 'web-search']
const IDS: string[] = [...CANONICAL_IDS, ...ALIAS_SLUGS]
/** A global-admin (sees every surface) or a tenant customer (Dave/maxpower shape). */
+4 -4
View File
@@ -3,7 +3,7 @@
*
* Runs against a LOCAL server (BASE_URL=http://localhost:4000) with the whole network
* mocked (same pattern as blank-audit): `/auth/session` → a global admin so the shell
* mounts, `/v1/billing/spend-alerts` → real-shaped budget rows (org default + project
* mounts, `/v1/billing/alerts` → real-shaped budget rows (org default + project
* warn + service over + unlimited/rate-limit-only), everything else → an empty-ok
* envelope.
*
@@ -39,7 +39,7 @@ const ACCOUNT = {
signupApplication: 'hanzo-cloud',
}
/** Real-shaped `/v1/billing/spend-alerts` rows — one per verdict/scope (threshold = cents). */
/** Real-shaped `/v1/billing/alerts` rows — one per verdict/scope (threshold = cents). */
const BUDGETS = [
{ id: 'b1', title: 'Org monthly cap', threshold: 500000, currency: 'usd', project: '', service: '', enforce: true, softPct: 80, rateLimitRpm: 0, periodSpentCents: 312000, over: false, warn: false },
{ id: 'b2', title: 'Inference budget', threshold: 200000, currency: 'usd', project: 'acme-prod', service: 'inference', enforce: false, softPct: 75, rateLimitRpm: 600, periodSpentCents: 186000, over: false, warn: true },
@@ -61,8 +61,8 @@ async function mock(route: Route) {
if (path.startsWith('/auth/')) {
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) })
}
// The page under test — the real spend-alerts contract.
if (path === '/v1/billing/spend-alerts') {
// The page under test — the real alerts contract.
if (path === '/v1/billing/alerts') {
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(BUDGETS) })
}
+32 -20
View File
@@ -1,11 +1,12 @@
/**
* e2e: brand-forward chrome + voice — mocked-network render proof.
*
* The Chrome wave: the big floating chat CIRCLE was removed; the assistant now opens
* from the TOPBAR (a small brand-H "Chat with Hanzo" + a "Talk to Hanzo" mic), the
* top-left SidebarBrand renders the org's own logo (white-label), and the Developers
* dock is drag-resizable with a live "Create key". This spec proves all of it in a
* browser.
* The Chrome wave: the big floating chat CIRCLE was removed; the assistant opens from
* ONE control — a floating bottom-right cluster (a brand-H "Ask Hanzo" + a "Talk to
* Hanzo" mic, `AssistantFab`), NOT the topbar, which carries navigation and account
* chrome only. The top-left SidebarBrand renders the org's own logo (white-label), and
* the Developers dock is drag-resizable with a live "Create key". This spec proves all
* of it in a browser.
*
* Same harness as workbench.spec (the closest sibling): a LOCAL server with the
* network mocked. `primeSession` seeds the IAM-PKCE identity AND the first-run gates
@@ -13,7 +14,7 @@
* real-shaped ledger rows for the dock's Overview, `/v1/models` → a small catalog for
* the assistant's model list; everything else → an empty-ok envelope.
*
* Voice gotcha: headless chromium ships NO webkitSpeechRecognition, so the topbar mic
* Voice gotcha: headless chromium ships NO webkitSpeechRecognition, so the mic
* (rendered only when `voiceSupported()`) would be absent for an environment reason,
* not a code one. A tiny, inert Web Speech stub is injected BEFORE load
* (`installVoiceStub`) so `voiceSupported()` is deterministically true and the mic
@@ -107,20 +108,20 @@ function installVoiceStub(page: Page) {
})
}
/** Prime + navigate; the topbar brand-H is on EVERY viewport, so it is the mount signal. */
/** Prime + navigate; the floating brand-H is on EVERY viewport, so it is the mount signal. */
async function openHome(page: Page, waitForMount = true) {
await installVoiceStub(page)
await page.route('**/*', mock)
await primeSession(page)
await page.goto(`${BASE_URL}/`, { waitUntil: 'domcontentloaded' })
if (waitForMount) {
await expect(page.locator('[aria-label="Chat with Hanzo"]').first()).toBeVisible({ timeout: 20_000 })
await expect(page.locator('[aria-label="Ask Hanzo"]').first()).toBeVisible({ timeout: 20_000 })
}
}
test.beforeAll(() => mkdirSync(SHOTS, { recursive: true }))
test('the floating circle is gone; the topbar carries chat + voice, the sidebar brand + docked assistant + Developers dock work', async ({ browser }) => {
test('one floating control carries chat + voice; the topbar carries neither; sidebar brand + docked assistant + Developers dock work', async ({ browser }) => {
// laptop (≥ lg 1024): the persistent sidebar, the Developers dock, and the docked
// assistant column are all present (they are desktop-only concerns).
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } })
@@ -130,10 +131,18 @@ test('the floating circle is gone; the topbar carries chat + voice, the sidebar
// 1. The OLD floating circle is GONE — the bubble that covered page content.
await expect(page.locator('[aria-label="Open AI assistant"]')).toHaveCount(0)
// 2. Topbar: the small brand-H "Chat with Hanzo" AND the "Talk to Hanzo" mic, both
// visible (the mic renders because the Web Speech stub makes voiceSupported() true).
await expect(page.locator('[aria-label="Chat with Hanzo"]').first()).toBeVisible()
await expect(page.locator('[aria-label="Talk to Hanzo"]').first()).toBeVisible()
// 2. ONE floating control, bottom-right: the brand-H "Ask Hanzo" AND the "Talk to
// Hanzo" mic (the mic renders because the Web Speech stub makes voiceSupported()
// true) — and the topbar carries no assistant control at all. The two used to live
// up there beside the search box, which put the assistant in a third place.
// Scoped to the control itself: the assistant's own composer carries a mic with
// the same label, mounted-but-hidden until the panel opens, so a bare
// `[aria-label="Talk to Hanzo"]` matches that one first and reads "hidden".
const fab = page.getByTestId('assistant-fab')
await expect(fab.locator('[aria-label="Ask Hanzo"]')).toBeVisible()
await expect(fab.locator('[aria-label="Talk to Hanzo"]')).toBeVisible()
await expect(page.locator('.hz-topbar [aria-label="Ask Hanzo"]')).toHaveCount(0)
await expect(page.locator('.hz-topbar [aria-label="Talk to Hanzo"]')).toHaveCount(0)
// 3. The top-left SidebarBrand renders the org logo / BrandMark (an <img> or <svg>).
const brand = page.locator('[aria-label*="right-click for brand menu"]').first()
@@ -147,15 +156,18 @@ test('the floating circle is gone; the topbar carries chat + voice, the sidebar
await expect(page.locator('[title="Drag to resize"]').first()).toBeVisible()
await expect(page.locator('text=Create key').first()).toBeVisible({ timeout: 15_000 })
// 5. Clicking "Chat with Hanzo" opens the DOCKED assistant surface — the "Assistant"
// header + its Undock control appear (uniquely the docked panel at lg+).
await page.locator('[aria-label="Chat with Hanzo"]').first().click()
// 5. Clicking "Ask Hanzo" opens the DOCKED assistant surface — the "Assistant"
// header + its Undock control appear (uniquely the docked panel at lg+). The
// floating control then steps aside: at lg+ the docked column IS the assistant,
// so keeping a button to open it on top of itself would be a second way in.
await fab.locator('[aria-label="Ask Hanzo"]').click()
await expect(page.locator('[aria-label^="Undock"]').first()).toBeVisible({ timeout: 15_000 })
await expect(page.getByText('Assistant', { exact: true }).filter({ visible: true }).first()).toBeVisible()
await expect(page.locator('[aria-label="Ask Hanzo"]')).toHaveCount(0)
// 6. The mic is wired: "Talk to Hanzo" → startVoice → the conversation opens the
// recognition (voiceSignal effect → voice.start() → the stub records the call).
await page.locator('[aria-label="Talk to Hanzo"]').first().click()
// 6. The mic is wired: "Talk to Hanzo" (now the open conversation's own) → the
// recognition opens (voice.start() → the stub records the call).
await page.locator('[aria-label="Talk to Hanzo"]').filter({ visible: true }).first().click()
await expect
.poll(() => page.evaluate(() => (window as unknown as { __voiceStarted?: number }).__voiceStarted ?? 0), { timeout: 15_000 })
.toBeGreaterThan(0)
@@ -179,7 +191,7 @@ test('renders across breakpoints with no horizontal body scroll on a phone; scre
// (real render, or an honest blank shell if the sandbox can't paint the SPA).
await openHome(page, false)
await page
.locator('[aria-label="Chat with Hanzo"]')
.locator('[aria-label="Ask Hanzo"]')
.first()
.waitFor({ state: 'visible', timeout: 20_000 })
.catch(() => {})
+10 -10
View File
@@ -14,7 +14,7 @@
*
* Run:
* HANZO_PASSWORD=xxx pnpm e2e
* HANZO_PASSWORD=xxx HANZO_API_KEY=hk-xxx pnpm e2e
* HANZO_PASSWORD=xxx HANZO_API_KEY=sk-xxx pnpm e2e
*/
import { test, expect, type Page } from '@playwright/test'
@@ -151,15 +151,15 @@ test.describe('Hanzo Cloud Console e2e', () => {
if (needsCreate) {
await createBtn.click()
// One-time reveal card with the hk- key
await expect(page.locator('text=/hk-/')).toBeVisible({ timeout: 25_000 })
// One-time reveal card with the sk- key
await expect(page.locator('text=/sk-/')).toBeVisible({ timeout: 25_000 })
await expect(page.locator('text=/shown only once/i')).toBeVisible()
await expect(page.locator('button:has-text("Copy")')).toBeVisible()
console.log('✓ API key created (hk- one-time reveal shown)')
console.log('✓ API key created (sk- one-time reveal shown)')
} else {
// Key already exists
await expect(hasKey).toBeVisible({ timeout: 10_000 })
await expect(page.locator('text=/hk-…|hk-[A-Za-z0-9]{3,}/i')).toBeVisible({ timeout: 5_000 })
await expect(page.locator('text=/sk-…|sk-[A-Za-z0-9]{3,}/i')).toBeVisible({ timeout: 5_000 })
console.log('✓ API key already exists (prefix shown)')
}
})
@@ -183,19 +183,19 @@ test.describe('Hanzo Cloud Console e2e', () => {
} else if (await createBtn.isVisible({ timeout: 1_000 }).catch(() => false)) {
await createBtn.click()
}
await expect(page.locator('text=/hk-/')).toBeVisible({ timeout: 25_000 })
await expect(page.locator('text=/sk-/')).toBeVisible({ timeout: 25_000 })
// Grab the FULL key from the one-time reveal — never the masked display
// (the account card shows `hk-2f18…` with an ellipsis, which is not a
// usable credential). Match only a full hk- token (no `…`/`...`).
const fullKey = /hk-[A-Za-z0-9._-]{16,}/
// (the account card shows `sk-2f18…` with an ellipsis, which is not a
// usable credential). Match only a full sk- token (no `…`/`...`).
const fullKey = /sk-[A-Za-z0-9._-]{16,}/
const keyEl = page.locator('[style*="monospace"]').filter({ hasText: fullKey }).first()
apiKey = (((await keyEl.textContent().catch(() => '')) ?? '').match(fullKey) ?? [''])[0]
if (!apiKey) {
const m = ((await page.textContent('body')) ?? '').match(fullKey)
apiKey = m ? m[0] : ''
}
expect(apiKey, 'Could not extract hk- key from page').toMatch(/^hk-/)
expect(apiKey, 'Could not extract sk- key from page').toMatch(/^sk-/)
console.log(`✓ Extracted key prefix: ${apiKey.slice(0, 11)}`)
}
+64
View File
@@ -0,0 +1,64 @@
/**
* The OAuth return raises EXACTLY ONE toast.
*
* A unit test cannot see this bug. It is a render loop: the toast provider built
* its context value fresh on every render and used it as the value, so every
* useToast() consumer got a new identity whenever a toast was added — and the
* integrations effect both DEPENDS on the toast api and RAISES a toast. Raising
* one re-rendered the provider, which handed the effect a new api, which raised
* another. Live this stacked ~15 identical "Connected slack" cards down the
* viewport. Stripping the query params could not stop it: router.replace is
* asynchronous, so the params are still readable on the renders in between.
*
* So the assertion is a COUNT after the loop has had time to run, on the real
* rendered DOM — the only place the defect exists.
*/
import { test, expect } from '@playwright/test'
import { primeSession } from './_session'
const PROVIDERS = [
{
id: 'slack',
name: 'Slack',
description: 'Post messages and receive events in your Slack workspace.',
category: 'Communication',
available: true,
connected: true,
connection: { account: 'The Foundation', connectedAt: '2026-08-05T00:16:49Z' },
},
]
test.describe('integrations OAuth return', () => {
test.beforeEach(async ({ page }) => {
// Everything else answers empty so the module mounts standalone.
await page.route('**/v1/**', async (route) => {
const url = route.request().url()
if (url.includes('/v1/integrations')) {
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(PROVIDERS) })
}
return route.fulfill({ status: 200, contentType: 'application/json', body: '{"data":[]}' })
})
await primeSession(page)
})
test('a connected= return raises exactly one toast', async ({ page }) => {
await page.goto('/integrations?connected=slack&account=The+Foundation')
const toasts = page.getByText('Connected slack')
await expect(toasts.first()).toBeVisible({ timeout: 15_000 })
// Give the loop every chance to run: the effect re-fires on each provider
// re-render, and the pre-fix build had stacked well past a dozen by now.
await page.waitForTimeout(3_000)
expect(await toasts.count()).toBe(1)
await page.screenshot({ path: 'e2e-shots/integrations-one-toast.png', fullPage: false })
})
test('the callback params are stripped so a reload cannot replay it', async ({ page }) => {
await page.goto('/integrations?connected=slack&account=The+Foundation')
await expect(page.getByText('Connected slack').first()).toBeVisible({ timeout: 15_000 })
await expect.poll(() => new URL(page.url()).search, { timeout: 10_000 }).toBe('')
})
})
+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()
+1 -1
View File
@@ -134,7 +134,7 @@ test.describe('LIVE v8.4.15 — (a) business board + (c) billing dimension', ()
test('(c) billing Reports renders the cost-dimension surface', async ({ page }) => {
await signIn(page, CONSOLE)
// v8.4.16: the data proxy moved to /billing/v1/*, so /billing/reports now falls
// The data proxy lives at /v1/billing/*, so /billing/reports now falls
// through to the SPA (was shadowed by the /billing/[...path] proxy → raw JSON).
// A hard deep-link must render the Reports UI, not a proxy "not found".
await page.goto(`${CONSOLE}/billing/reports`, { waitUntil: 'domcontentloaded' })
+118
View File
@@ -0,0 +1,118 @@
/**
* Onboarding — Continue never moves, and Skip is always reachable.
*
* The complaint this pins: the Continue button landed at a different height on
* every step, so a user clicking through had to re-aim each time. StepActions was
* the LAST CHILD of a flex column, so its y was whatever the step's content
* happened to add up to. It is now a SLOT on StepShell above a content area with
* a reserved height — one placement, decided in one place.
*
* This is a GEOMETRY assertion on purpose. The JSX move is invisible to a unit
* test (both shapes render the same button with the same label); only the painted
* box says whether the thing the user complained about is fixed.
*/
import { test, expect, type Page } from '@playwright/test'
import { primeSession } from './_session'
/** Every step whose footer must line up, in flow order. */
const STEPS = ['Secure your account', 'Data & consent', 'Your organization', 'Free trial credits', 'AI access']
/** The y of the actions row, in page coordinates. */
async function actionsY(page: Page): Promise<number> {
const row = page.getByTestId('onboarding-actions')
await expect(row).toBeVisible()
const box = await row.boundingBox()
if (!box) throw new Error('actions row has no box')
return Math.round(box.y)
}
/** Advance past the current step, preferring Skip so the flow stays clickable. */
async function advance(page: Page): Promise<void> {
const row = page.getByTestId('onboarding-actions')
const skip = row.getByRole('button', { name: /^(Skip|Keep the default)/ })
if (await skip.count()) {
await skip.first().click()
return
}
// Consent has no Skip by design (accepting Terms is not optional), so tick the
// agreement and use Continue. Tick only if Continue is still disabled — a caller
// may already have ticked it, and toggling twice turns it back OFF.
const cont = row.getByRole('button', { name: /Continue/ })
if (await cont.isDisabled()) {
const agree = page.locator('[role="switch"], input[type="checkbox"]').first()
if (await agree.count()) await agree.click()
}
await cont.click()
}
test.beforeEach(async ({ page }) => {
// Anything the steps reach for answers empty — they are best-effort and must
// still render. Registered BEFORE primeSession so its handlers win.
await page.route('**/v1/**', (r) => r.fulfill({ status: 200, contentType: 'application/json', body: '{}' }))
await primeSession(page)
// primeSession marks onboarding DONE so other specs can reach the app. This
// spec is about the wizard, so un-mark it (the tour gate stays seeded).
await page.addInitScript(() => {
for (const k of Object.keys(localStorage)) if (k.startsWith('hz_onboarding_done:')) localStorage.removeItem(k)
})
})
test('Continue lands at the same height on every step', async ({ page }) => {
await page.goto('/')
const seen: { step: string; y: number }[] = []
for (const step of STEPS) {
await expect(page.getByTestId('onboarding-step-title')).toHaveText(step, { timeout: 15_000 })
seen.push({ step, y: await actionsY(page) })
await advance(page)
}
const ys = seen.map((s) => s.y)
const spread = Math.max(...ys) - Math.min(...ys)
expect(
spread,
`Continue moved ${spread}px across steps — ${seen.map((s) => `${s.step}:${s.y}`).join(' ')}`,
).toBeLessThanOrEqual(2)
})
test('every step always offers an enabled way forward', async ({ page }) => {
await page.goto('/')
// The real invariant behind "skip so easy to click through": on every step there
// is ALWAYS at least one enabled control that advances you — Skip where there is
// something to decline, Continue where there is not. Credits swaps between the
// two on purpose (Skip appears only when a card could be added; otherwise
// Continue carries you), so asserting a literal "Skip" everywhere would be
// asserting the wrong thing. Being STUCK is the defect.
for (const step of STEPS) {
await expect(page.getByTestId('onboarding-step-title')).toHaveText(step, { timeout: 15_000 })
// Consent gates Continue on accepting the Terms — not optional, so tick it
// first and then assert the way forward exists.
if (step === 'Data & consent') {
const agree = page.locator('[role="switch"], input[type="checkbox"]').first()
if (await agree.count()) await agree.click()
}
const row = page.getByTestId('onboarding-actions')
const forward = row.getByRole('button', { name: /^(Skip|Keep the default|Continue)/ })
const n = await forward.count()
expect(n, `${step} renders no forward control`).toBeGreaterThan(0)
let usable = 0
for (let i = 0; i < n; i++) {
const b = forward.nth(i)
if (await b.isDisabled()) continue
const box = await b.boundingBox()
if (!box || box.height < 24) continue
const hit = await b.evaluate((el) => {
const r = el.getBoundingClientRect()
return el.contains(document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2))
})
if (hit) usable++
}
expect(usable, `${step} has no enabled, clickable way forward`).toBeGreaterThan(0)
await advance(page)
}
})
+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`) })
})
}
-1
View File
@@ -18,7 +18,6 @@
"agents",
"inference",
"finetuning",
"ml-pipelines",
"embeddings",
"evals",
"gpus",
+3 -3
View File
@@ -128,11 +128,11 @@ async function mock(route: Route) {
async function openPolicy(page: Page, marker = 'Enabled models') {
await page.addInitScript((org) => {
try {
// A valid @hanzo/iam session: a non-expired access token in sessionStorage so the
// A valid @hanzo/iam session: a non-expired access token in localStorage so the
// SDK's getValidAccessToken() returns it (userinfo is network-mocked to CLAIMS).
// Without a future `expires_at` the SDK treats the token as expired → anonymous.
sessionStorage.setItem('hanzo_iam_access_token', 'mock-access-token')
sessionStorage.setItem('hanzo_iam_expires_at', String(Date.now() + 3600000))
localStorage.setItem('hanzo_iam_access_token', 'mock-access-token')
localStorage.setItem('hanzo_iam_expires_at', String(Date.now() + 3600000))
localStorage.setItem('hanzo.console.org', org)
// Scope shows the org PICKER until an org is explicitly entered — the scope
+1 -1
View File
@@ -43,7 +43,7 @@ async function mock(route: Route) {
const req = route.request()
if (req.resourceType() === 'document') return route.continue()
const url = new URL(req.url())
if (/\/v1\/admin\/block-storage(\/|$|\?)/.test(url.pathname)) {
if (/\/v1\/admin\/volumes(\/|$|\?)/.test(url.pathname)) {
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(SNAPSHOT) })
}
const sameOrigin = url.origin === new URL(BASE_URL).origin
+10 -153
View File
@@ -1,154 +1,11 @@
import { defaultConfig } from '@hanzogui/config/v5'
import { createGui } from '@hanzo/gui'
/**
* The console's GUI config IS the shared one. The type/radius/space scale used to be
* declared here; it now ships with the components it scales (`@hanzo/ui/gui-config`),
* because the dedicated Hanzo Social app renders the same @hanzo/ui/product set and a
* second copy of the ladder would fork silently — same components, different sizes.
*
* Kept as a file so `~/gui.config` stays the console's one import path.
*/
export { config, default } from '@hanzo/ui/gui-config'
// ─────────────────────────────────────────────────────────────────────────────
// THE ONE SCALE.
//
// Three scales used to disagree. `app/design/typography.css` declared the
// intended compact register (11/13/14/15/17/21/26 — the linear.app density);
// @hanzo/gui's Tamagui `$N` ladder is what components actually TYPE (thousands
// of `fontSize="$N"` call sites); and the rendered result was TEN distinct
// sizes, including 217 nodes at the retired 16px base and 30 at 10px, while the
// intended 11px label size rendered NOWHERE.
//
// The ladder is exactly why we do not edit thousands of call sites: REMAP IT
// ONCE and every surface lands on the design scale. So this file is the single
// place the console's type, radius and spacing scales are defined —
// `app/design/*.css` declares them for CSS consumers, this maps the `$N` tokens
// onto the same numbers for the component layer. Change a value here, the whole
// product moves. Adding a fourth spelling of a size is the thing to refuse.
//
// Canonical face: Geist Sans for UI, Geist Mono for anything numeric/code/id
// (`.hz-mono`, set in app/globals.css). Both self-hosted in app/fonts.css.
// ─────────────────────────────────────────────────────────────────────────────
const GEIST = "'Geist', system-ui, -apple-system, sans-serif"
/** Type — SIX sizes in the app, matching `--text-*` in app/design/typography.css.
* $1 label · $2 nav + dense body · $3 base · $4/$5 emphasis · $6 section head ·
* $7 page title · $8+ display. `$5` collapses onto 15 to retire the 16px base
* (217 stray nodes); `$9` collapses onto 26 so a page title has ONE size. */
const FONT_SIZE = {
1: 11,
2: 13,
3: 14,
4: 15,
5: 15,
6: 17,
7: 21,
8: 26,
9: 26,
10: 32,
11: 40,
12: 48,
13: 56,
14: 64,
15: 80,
16: 96,
true: 14,
} as const
/** Leading, paired 1:1 with the sizes above. A size token carries a line-height
* tuned for ONE line, so these track the type scale rather than the inherited
* ladder, which left an 11px label sitting in an 18px box. */
const LINE_HEIGHT = {
1: 16,
2: 18,
3: 20,
4: 22,
5: 22,
6: 24,
7: 28,
8: 32,
9: 32,
10: 38,
11: 46,
12: 54,
13: 62,
14: 70,
15: 86,
16: 102,
true: 20,
} as const
/** Radius — FOUR values, no more. 6 control · 8 input/row · 12 panel · pill.
* The inherited ladder had thirteen spellings rendering ten values, including
* three different spellings of "pill" (`{999}`, `{99}`, `$10`). `$10`+ IS the
* pill, so the 115 `rounded="$10"` call sites finally mean one thing. */
const RADIUS = {
0: 0,
1: 6,
2: 6,
3: 8,
4: 8,
5: 12,
6: 12,
7: 12,
8: 12,
9: 12,
10: 9999,
11: 9999,
12: 9999,
true: 8,
} as const
/** Spacing — the 4px ramp, and only the 4px ramp. The inherited ladder landed on
* odd pixels belonging to no scale: `$2`=7, `$3`=13, `$4`=18 were the three
* most-rendered paddings in the whole app. Mirrored into negative steps because
* Tamagui resolves `-$3` from this same map. */
const STEP: Record<string, number> = {
'0': 0,
'0.25': 1,
'0.5': 2,
'0.75': 3,
'1': 4,
'1.5': 6,
'2': 8,
'2.5': 10,
'3': 12,
'3.5': 14,
'4': 16,
'4.5': 20,
'5': 24,
'6': 32,
'7': 40,
'8': 48,
'9': 56,
'10': 64,
'11': 80,
'12': 96,
'13': 112,
'14': 128,
'15': 144,
'16': 160,
'17': 160,
'18': 176,
'19': 192,
'20': 208,
}
const space: Record<string, number> = {}
for (const [k, v] of Object.entries(STEP)) {
space[`$${k}`] = v
space[`-${k}`] = -v
}
space.$true = STEP['4']
space['-true'] = -STEP['4']
export const config = createGui({
...defaultConfig,
tokens: {
...defaultConfig.tokens,
radius: RADIUS,
space,
},
fonts: {
...defaultConfig.fonts,
body: { ...defaultConfig.fonts.body, family: GEIST, size: FONT_SIZE, lineHeight: LINE_HEIGHT },
heading: { ...defaultConfig.fonts.heading, family: GEIST, size: FONT_SIZE, lineHeight: LINE_HEIGHT },
},
})
export default config
export type Conf = typeof config
export type Conf = typeof import('@hanzo/ui/gui-config').config
+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
+50 -10
View File
@@ -1,4 +1,4 @@
import { readdirSync, readFileSync } from 'node:fs'
import { existsSync, readdirSync, readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
@@ -7,12 +7,16 @@ import { resolveBuildId, readGitSha } from './src/config/build-id.mjs'
/**
* Hanzo Cloud Console — Next.js config.
*
* Hanzo GUI is consumed at runtime (no optimizing compiler): the published
* `@hanzogui/next-plugin` has a broken npm dependency (`hanzogui-loader@7.3.0`
* is unpublished; the available fork renames its exports), so we transpile the
* Gui ESM packages with Next's built-in `transpilePackages` and let
* `GuiProvider` inject CSS at runtime. Gui is designed to work this way — the
* compiler is an optimization, not a requirement.
* Hanzo GUI is consumed at runtime (no optimizing compiler): we transpile the Gui
* ESM packages with Next's built-in `transpilePackages` and let `GuiProvider`
* inject CSS at runtime. Gui is designed to work this way — the compiler is an
* optimization, not a requirement.
*
* (The original reason to avoid `@hanzogui/next-plugin` no longer holds: the loader
* it depends on was unpublished at 7.3.0, but 8.x renamed it to `@hanzogui/loader`
* and both now ship. Adopting the compiler is therefore a live option — as an
* optimization to measure, not a correctness fix, so it is deliberately not bundled
* into the 8.x convergence.)
*
* `react-native` is aliased to `react-native-web` for the browser.
*
@@ -46,6 +50,9 @@ function guiPackages() {
return ['@hanzo/gui', '@hanzo/iam-js-sdk', '@hanzo/dash', '@hanzo/data', '@hanzo/canvas', '@hanzo/finance-ui', '@hanzo/usage', '@hanzo/ui', 'react-native-web', ...scoped]
}
/** A `@hanzogui/<pkg>/<subpath>/index.{js,cjs}` metro-compat shim (see `webpack()`). */
const GUI_SUBPATH_SHIM = /\/@hanzogui\/([^/]+)\/([^/]+)\/index\.c?js$/
/**
* Same-origin `/v1/*` — ZERO client-visible prefix (the CTO contract: "no prefix
* before /v1/ in any API call"). The browser ALWAYS calls its OWN origin at a clean
@@ -97,10 +104,10 @@ const AI_V1_HEADS = ['models', 'chat', 'embeddings', 'rerank', 'audio', 'images'
// (`providers/toggle`, `providers/primary`) both match the `/:path*` rewrite below,
// which is method-agnostic (Next matches on the URL), so POST is covered without a
// second entry. Keep this in sync with `admin-aggregate.ts` ADMIN_AGGREGATE_HEADS.
const ADMIN_V1_HEADS = ['overview', 'usage', 'orgs', 'audit', 'products', 'finance', 'compute', 'o11y', 'providers', 'customers', 'revenue', 'analytics', 'enablement', 'grants', 'referrals', 'affiliates', 'authors', 'treasury', 'services', 'promos', 'spend-caps', 'block-storage']
const ADMIN_V1_HEADS = ['overview', 'usage', 'orgs', 'audit', 'products', 'finance', 'compute', 'o11y', 'providers', 'customers', 'revenue', 'analytics', 'enablement', 'grants', 'referrals', 'affiliates', 'authors', 'treasury', 'services', 'promos', 'caps', 'volumes']
/**
* DEV-ONLY: proxy the client's direct-cloud `/v1/{iam,o11y}/*` calls (get-account,
* annotation-queues/users) to a real cloud backend so `npm run dev` renders the
* reviews/users) to a real cloud backend so `npm run dev` renders the
* authenticated shell locally. Enabled ONLY when `DEV_CLOUD_ORIGIN` is set (never in
* the built image), so production is unchanged — there the console host's edge routes
* `/v1` to the console, whose `/v1` catch-all forwards to cloud-api. The request cookie
@@ -206,7 +213,40 @@ const nextConfig = {
experimental: {
esmExternals: true,
},
webpack(config) {
webpack(config, { webpack }) {
// `@hanzogui/*` 8.x ships legacy metro-compat subpath DIRECTORIES (`config/v5/`,
// `themes/v5/`, `shorthands/v5/`, …) beside the `exports` map that already names
// the real entry. Each holds a CommonJS `index.js` — inside a `"type": "module"`
// package. Whatever resolves the directory therefore parses that file as ESM: the
// `export *` chain goes opaque ("'defaultConfig' is not exported from
// '@hanzogui/config/v5'") and its bare `require('../dist/cjs/v5.cjs')` survives
// into the server chunk, where it MODULE_NOT_FOUNDs at prerender (the require is
// relative to `.next/server/chunks/`, not to the package).
//
// So redirect any such shim to the ESM build sitting beside it. Pattern-based, on
// the RESOLVED file, so it holds however the request got there — and costs nothing
// the day the shims stop shipping.
config.plugins.push(
new webpack.NormalModuleReplacementPlugin(GUI_SUBPATH_SHIM, (data) => {
const resource = data.createData?.resource
if (!resource) return
const shim = GUI_SUBPATH_SHIM.exec(resource)
if (!shim) return
const esm = `${resource.slice(0, shim.index)}/@hanzogui/${shim[1]}/dist/esm/${shim[2]}.mjs`
if (!existsSync(esm)) return
// The module's CONTEXT must move with it, or its own relative imports
// (`./v5-base.mjs`) keep resolving against the shim directory.
data.createData.resource = esm
data.createData.userRequest = esm
data.createData.context = dirname(esm)
data.context = dirname(esm)
}),
)
// @hanzo/ui is consumed from SOURCE via a workspace link. Keep the symlinked path
// so its own imports (@hanzo/gui, @hanzogui/*) walk up into the CONSOLE's
// node_modules — one Tamagui instance, as the tsconfig `paths` already pin for
// types. Resolving the realpath would load a second copy and break theme context.
config.resolve.symlinks = false
config.resolve.alias = {
...config.resolve.alias,
'react-native$': 'react-native-web',
+12019
View File
File diff suppressed because it is too large Load Diff
+24 -23
View File
@@ -1,9 +1,9 @@
{
"name": "@hanzo/console",
"version": "8.5.32",
"version": "8.5.62",
"packageManager": "pnpm@11.17.0",
"private": true,
"license": "BSD-3-Clause",
"license": "MIT OR Apache-2.0",
"author": "Hanzo AI <dev@hanzo.ai>",
"description": "Hanzo Cloud Console — unified admin console for Hanzo Cloud and all cloud products.",
"scripts": {
@@ -14,25 +14,27 @@
"typecheck": "tsc --noEmit",
"test": "vitest run",
"e2e": "playwright test",
"e2e:headed": "playwright test --headed",
"postinstall": "patch-package"
"e2e:headed": "playwright test --headed"
},
"dependencies": {
"@hanzo/brand": "^1.4.0",
"@hanzo/canvas": "^0.1.0",
"@hanzo/dash": "0.3.0",
"@hanzo/data": "^1.2.0",
"@hanzo/event": "^0.3.4",
"@hanzo/finance-ui": "0.1.1",
"@hanzo/gui": "7.3.0",
"@hanzo/iam": "^0.21.1",
"@hanzo/logo": "^1.0.13",
"@hanzo/ui": "^8.0.11",
"@hanzo/brand": "^1.4.5",
"@hanzo/canvas": "^0.2.1",
"@hanzo/dash": "^0.3.0",
"@hanzo/data": "^1.2.2",
"@hanzo/design": "^0.4.6",
"@hanzo/event": "^0.3.8",
"@hanzo/finance-ui": "~0.1.1",
"@hanzo/gui": "^8.0.0",
"@hanzo/iam": "^0.21.6",
"@hanzo/logo": "^1.0.14",
"@hanzo/ui": "^8.0.56",
"@hanzo/usage": "^0.1.6",
"@hanzogui/config": "7.3.0",
"@hanzogui/core": "7.3.0",
"@hanzogui/lucide-icons-2": "7.3.0",
"@hanzogui/next-theme": "7.3.0",
"@hanzogui/config": "^8.0.0",
"@hanzogui/core": "^8.0.0",
"@hanzogui/lucide-icons-2": "^8.0.0",
"@hanzogui/next-theme": "^8.0.0",
"@hanzogui/telemetry": "^8.0.0",
"@hanzogui/shell": "^8.1.1",
"@lexical/html": "0.46.0",
"@lexical/link": "0.46.0",
"@lexical/list": "0.46.0",
@@ -52,9 +54,9 @@
"qrcode.react": "4.2.0",
"react": "19.2.7",
"react-dom": "19.2.7",
"react-native-svg": "15.15.5",
"react-native-web": "0.21.2",
"superjson": "2.2.2",
"@hanzogui/shell": "^7.6.3"
"superjson": "2.2.2"
},
"devDependencies": {
"@playwright/test": "^1.61.1",
@@ -62,8 +64,7 @@
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"react-native": "0.83.9",
"typescript": "5.9.3",
"vitest": "3.2.4",
"patch-package": "^8.0.0"
"typescript": "^5.9.3",
"vitest": "3.2.4"
}
}
-132
View File
@@ -1,132 +0,0 @@
diff --git a/node_modules/@hanzo/iam/dist/browser.cjs b/node_modules/@hanzo/iam/dist/browser.cjs
index fe3a04e..41c367e 100644
--- a/node_modules/@hanzo/iam/dist/browser.cjs
+++ b/node_modules/@hanzo/iam/dist/browser.cjs
@@ -785,6 +785,17 @@ var IAM = class {
if (tokens.expires_in) {
const expiresAt = Date.now() + tokens.expires_in * 1e3;
this.storage.setItem(KEY_EXPIRES_AT, String(expiresAt));
+ } else {
+ try {
+ const _p = tokens.access_token.split(".");
+ if (_p.length === 3) {
+ let _b = _p[1].replace(/-/g, "+").replace(/_/g, "/");
+ while (_b.length % 4) _b += "=";
+ const _j = typeof atob === "function" ? atob(_b) : Buffer.from(_b, "base64").toString("binary");
+ const _e = JSON.parse(_j).exp;
+ if (typeof _e === "number") this.storage.setItem(KEY_EXPIRES_AT, String(_e * 1e3));
+ }
+ } catch (_) {}
}
}
/** Get the stored access token (may be expired). */
diff --git a/node_modules/@hanzo/iam/dist/browser.js b/node_modules/@hanzo/iam/dist/browser.js
index 4228603..1b9db27 100644
--- a/node_modules/@hanzo/iam/dist/browser.js
+++ b/node_modules/@hanzo/iam/dist/browser.js
@@ -783,6 +783,17 @@ var IAM = class {
if (tokens.expires_in) {
const expiresAt = Date.now() + tokens.expires_in * 1e3;
this.storage.setItem(KEY_EXPIRES_AT, String(expiresAt));
+ } else {
+ try {
+ const _p = tokens.access_token.split(".");
+ if (_p.length === 3) {
+ let _b = _p[1].replace(/-/g, "+").replace(/_/g, "/");
+ while (_b.length % 4) _b += "=";
+ const _j = typeof atob === "function" ? atob(_b) : Buffer.from(_b, "base64").toString("binary");
+ const _e = JSON.parse(_j).exp;
+ if (typeof _e === "number") this.storage.setItem(KEY_EXPIRES_AT, String(_e * 1e3));
+ }
+ } catch (_) {}
}
}
/** Get the stored access token (may be expired). */
diff --git a/node_modules/@hanzo/iam/dist/index.cjs b/node_modules/@hanzo/iam/dist/index.cjs
index d49c5d4..81cfb85 100644
--- a/node_modules/@hanzo/iam/dist/index.cjs
+++ b/node_modules/@hanzo/iam/dist/index.cjs
@@ -1172,6 +1172,17 @@ var IAM = class {
if (tokens.expires_in) {
const expiresAt = Date.now() + tokens.expires_in * 1e3;
this.storage.setItem(KEY_EXPIRES_AT, String(expiresAt));
+ } else {
+ try {
+ const _p = tokens.access_token.split(".");
+ if (_p.length === 3) {
+ let _b = _p[1].replace(/-/g, "+").replace(/_/g, "/");
+ while (_b.length % 4) _b += "=";
+ const _j = typeof atob === "function" ? atob(_b) : Buffer.from(_b, "base64").toString("binary");
+ const _e = JSON.parse(_j).exp;
+ if (typeof _e === "number") this.storage.setItem(KEY_EXPIRES_AT, String(_e * 1e3));
+ }
+ } catch (_) {}
}
}
/** Get the stored access token (may be expired). */
diff --git a/node_modules/@hanzo/iam/dist/index.js b/node_modules/@hanzo/iam/dist/index.js
index 48c5699..7a85d23 100644
--- a/node_modules/@hanzo/iam/dist/index.js
+++ b/node_modules/@hanzo/iam/dist/index.js
@@ -1170,6 +1170,17 @@ var IAM = class {
if (tokens.expires_in) {
const expiresAt = Date.now() + tokens.expires_in * 1e3;
this.storage.setItem(KEY_EXPIRES_AT, String(expiresAt));
+ } else {
+ try {
+ const _p = tokens.access_token.split(".");
+ if (_p.length === 3) {
+ let _b = _p[1].replace(/-/g, "+").replace(/_/g, "/");
+ while (_b.length % 4) _b += "=";
+ const _j = typeof atob === "function" ? atob(_b) : Buffer.from(_b, "base64").toString("binary");
+ const _e = JSON.parse(_j).exp;
+ if (typeof _e === "number") this.storage.setItem(KEY_EXPIRES_AT, String(_e * 1e3));
+ }
+ } catch (_) {}
}
}
/** Get the stored access token (may be expired). */
diff --git a/node_modules/@hanzo/iam/dist/react.cjs b/node_modules/@hanzo/iam/dist/react.cjs
index 8642b04..66da7d3 100644
--- a/node_modules/@hanzo/iam/dist/react.cjs
+++ b/node_modules/@hanzo/iam/dist/react.cjs
@@ -719,6 +719,17 @@ var IAM = class {
if (tokens.expires_in) {
const expiresAt = Date.now() + tokens.expires_in * 1e3;
this.storage.setItem(KEY_EXPIRES_AT, String(expiresAt));
+ } else {
+ try {
+ const _p = tokens.access_token.split(".");
+ if (_p.length === 3) {
+ let _b = _p[1].replace(/-/g, "+").replace(/_/g, "/");
+ while (_b.length % 4) _b += "=";
+ const _j = typeof atob === "function" ? atob(_b) : Buffer.from(_b, "base64").toString("binary");
+ const _e = JSON.parse(_j).exp;
+ if (typeof _e === "number") this.storage.setItem(KEY_EXPIRES_AT, String(_e * 1e3));
+ }
+ } catch (_) {}
}
}
/** Get the stored access token (may be expired). */
diff --git a/node_modules/@hanzo/iam/dist/react.js b/node_modules/@hanzo/iam/dist/react.js
index 8f4927a..81bef42 100644
--- a/node_modules/@hanzo/iam/dist/react.js
+++ b/node_modules/@hanzo/iam/dist/react.js
@@ -717,6 +717,17 @@ var IAM = class {
if (tokens.expires_in) {
const expiresAt = Date.now() + tokens.expires_in * 1e3;
this.storage.setItem(KEY_EXPIRES_AT, String(expiresAt));
+ } else {
+ try {
+ const _p = tokens.access_token.split(".");
+ if (_p.length === 3) {
+ let _b = _p[1].replace(/-/g, "+").replace(/_/g, "/");
+ while (_b.length % 4) _b += "=";
+ const _j = typeof atob === "function" ? atob(_b) : Buffer.from(_b, "base64").toString("binary");
+ const _e = JSON.parse(_j).exp;
+ if (typeof _e === "number") this.storage.setItem(KEY_EXPIRES_AT, String(_e * 1e3));
+ }
+ } catch (_) {}
}
}
/** Get the stored access token (may be expired). */
+2030 -4648
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -195,8 +195,8 @@ try {
stdio: 'inherit',
// CONSOLE_EMBED gates the server-side build transforms; NEXT_PUBLIC_CONSOLE_EMBED
// is inlined into the CLIENT bundle so runtime code (lib/embed.ts → IS_EMBED) can
// skip the BFF-only session probes (/auth/refresh|session, /billing welcome) that
// don't exist in this static, server-less deployment.
// skip the BFF-only session probes (/auth/refresh|session) that don't exist in
// this static, server-less deployment.
env: { ...process.env, CONSOLE_EMBED: '1', NEXT_PUBLIC_CONSOLE_EMBED: '1' },
})
+48
View File
@@ -0,0 +1,48 @@
/**
* Asks the RENDERER which props @hanzo/gui 8 actually honors.
*
* gui accepts any prop and drops the ones it does not know, so a gui-7 `tag="a"`
* type-checks, builds, and ships a <div>: the link is inert and nothing anywhere says
* so. A green build cannot answer this; only the rendered markup can. Every rule in
* `src/lib/gui8-props.ts` was verified here before it was written down.
*
* gui injects its stylesheet as a leading <style>, so read the host element from the
* marked child — never from the first tag in the string.
*
* node scripts/gui-prop-probe.mjs (slow: it resolves ~184 unbundled ESM packages)
*/
import React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { defaultConfig } from '@hanzogui/config/v5'
import { XStack, Text, createGui, GuiProvider } from '@hanzo/gui'
const config = createGui(defaultConfig)
const render = (el) =>
renderToStaticMarkup(React.createElement(GuiProvider, { config, defaultTheme: 'dark' }, el))
/** The element carrying our marker id — not gui's injected <style>. */
const marked = (markup) => markup.match(/<([a-z]+)[^>]*\bid="probe"[^>]*>/)?.[0] ?? ''
const hostOf = (markup) => marked(markup).match(/^<([a-z]+)/)?.[1] ?? '(not found)'
const probe = (label, Comp, props) => {
const markup = render(React.createElement(Comp, { id: 'probe', ...props }, 'x'))
console.log(`${label.padEnd(30)} -> ${marked(markup) || '(not found)'}`)
return { host: hostOf(markup), markup }
}
console.log('--- host element: tag (gui 7) vs render (gui 8) ---')
const withTag = probe('tag="a"', XStack, { tag: 'a', href: 'https://hanzo.ai' })
const withRender = probe('render="a"', XStack, { render: 'a', href: 'https://hanzo.ai' })
console.log('\n--- style props that silently drop or mis-unit ---')
probe('lineHeight={1.1} (prop)', Text, { lineHeight: 1.1 })
probe('style lineHeight: 1.1 (ratio)', Text, { style: { lineHeight: 1.1 } })
probe("style lineHeight: '1.1' (string)", Text, { style: { lineHeight: '1.1' } })
probe('letterSpacing="-0.02em"', Text, { letterSpacing: '-0.02em' })
probe('animation="quick"', XStack, { animation: 'quick' })
probe('$sm={{...}}', XStack, { $sm: { bg: '$red10' } })
probe('$gtSm={{...}}', XStack, { $gtSm: { bg: '$red10' } })
console.log()
console.log(withTag.host === 'a' ? 'tag WORKS' : 'tag IS SILENTLY DROPPED')
console.log(withRender.host === 'a' ? 'render WORKS' : 'render IS SILENTLY DROPPED')
+20 -56
View File
@@ -1,29 +1,22 @@
'use client'
/**
* The account control — who you are, which organization you are acting in, what
* you have left to spend, and the way out. ONE control, at the foot of the rail.
* The account control — WHO you are: identity, your team, your personal
* settings, what you have left to spend, and the way out. ONE control, at the
* foot of the rail.
*
* It deliberately does NOT switch tenant. Org and project are one question —
* WHERE you are — and they are answered together by `ContextSwitcher` at the
* top-left, beside the tenant's own mark. Handing this menu an `orgState` too
* would put the org in two corners again, which is the exact confusion the
* condensed switcher removes. The cross-tenant reach, the admin-gated org list
* and the single `org-scope.switchOrg` money seam all moved there intact; there
* is still exactly one org switch in the app.
*
* It is `@hanzo/iam`'s `UserMenu`, the same component hanzo.chat mounts, so the
* identity, the switcher and the behaviour (click-away, Escape, close-before-
* navigate, never a raw uuid) are shared rather than rebuilt. This file is the
* ADAPTER — everything the console knows that the SDK does not:
*
* - ORG REACH. `useOrganizations()` reads the caller's memberships off the token
* and cannot express what an admin console does: enter ANY tenant. So the
* switcher is handed `findOrgs`, backed by the console's existing lazy,
* server-paged cross-tenant list (`IamAdminApi.organizations` through the
* gated `/admin/iam` proxy) — the same source the full-page org picker uses.
* A regular user never fires it: they see their own org, synthesized from the
* session, exactly as before. Nothing is fabricated, and nobody's reach widens.
*
* - MONEY. Switching goes through `org-scope.switchOrg` — the console's existing
* switch, passed by reference, not reimplemented. It persists the scope and
* reloads so every module refetches under the new `X-Org-Id`. That one seam is
* where tenant scoping and its billing attribution already live; this file adds
* no second switch, no header of its own, and no billing call, so the rule about
* which ledger a masquerading admin's spend lands on is exactly where it was.
* `org-state.test.ts` pins the identity so a second switch cannot creep in.
* identity and the behaviour (click-away, Escape, close-before-navigate, never a
* raw uuid) are shared rather than rebuilt. This file is the ADAPTER —
* everything the console knows that the SDK does not:
*
* - THEME. The console themes through `@hanzogui/next-theme` (which drives the
* Gui tree). That is adapted into the menu's shape rather than mounting IAM's
@@ -32,45 +25,19 @@
* - BRAND. The strip at the foot wears THIS host's brand. Passing nothing would
* paint a Hanzo mark on a Lux or Zoo console.
*/
import { useCallback, useMemo } from 'react'
import { UserMenu, type OrgState, type UserTheme } from '@hanzo/iam/react'
import { useMemo } from 'react'
import { UserMenu, type UserTheme } from '@hanzo/iam/react'
import { useThemeSetting } from '@hanzogui/next-theme'
import { config } from '~/config'
import { adminOrgState, scopedOrgRow } from '~/lib/account/org-state'
import { useSession } from '~/lib/auth/session'
import { useIsSuperAdmin } from '~/lib/auth/admin'
import { IamAdminApi, type Organization } from '~/lib/api'
import { ORG_PAGE_SIZE, orgQuery } from '~/lib/org-list'
import { currentOrg, leaveOrg, switchOrg } from '~/lib/org-scope'
import { useCloudBalance, spendableCents } from '~/lib/billing/live-balance'
export function AccountMenu() {
const { account, signOut } = useSession()
// The cross-tenant list is admin-gated at the proxy; a regular user would 403 it,
// so they are never asked to. Their own org is the honest answer.
const isSuperAdmin = useIsSuperAdmin()
const { balance } = useCloudBalance()
const { current, resolvedTheme, set } = useThemeSetting()
const scoped = currentOrg()
const findOrgs = useCallback(
async (query: string): Promise<Organization[]> => {
if (!isSuperAdmin) return scopedOrgRow(scoped)
const res = await IamAdminApi.organizations(orgQuery(0, query, ORG_PAGE_SIZE))
return res.rows ?? []
},
[isSuperAdmin, scoped],
)
// The console's own switch, by reference: persist the scope, reload, refetch
// under the new X-Org-Id. Pinned in `org-state.test.ts`.
const orgState: OrgState = useMemo(
() => adminOrgState({ scoped, findOrgs, switchOrg }),
[scoped, findOrgs],
)
// `system` is a real choice, and the console's provider already understands it.
const theme: UserTheme = useMemo(
() => ({
@@ -98,7 +65,6 @@ export function AccountMenu() {
isAuthenticated
isLoading={false}
onSignOut={() => void signOut()}
orgState={orgState}
theme={theme}
settingsUrl="/profile"
usageUrl="/billing"
@@ -106,12 +72,10 @@ export function AccountMenu() {
// Only shown when the backend actually reported a balance — never a fabricated $0.
balance={cents === null ? undefined : { amountUsd: cents / 100, topUpUrl: config.payUrl }}
items={[
{
label: 'All organizations',
// De-scope back to the full-page picker, where an org is entered — and
// where a new one is created. The dropdown never grew its own form.
onSelect: () => leaveOrg(),
},
// Your people, beside your own settings — the other half of "who am I".
// Choosing a DIFFERENT tenant is a different question and lives in the
// top-left context switcher, so this menu never re-scopes the console.
{ label: 'Members', href: '/team' },
{ label: 'Documentation', href: config.docsUrl, external: true, separatorBefore: true },
]}
brand={{ name: config.brandName }}
+57 -12
View File
@@ -1,12 +1,12 @@
'use client'
/**
* All products — the directory where you curate your sidebar. Every Hanzo product
* is always available on demand; this panel lists the FULL catalog the viewer may
* see (brand-scoped, admin surfaces gated), grouped by category, each row with a
* PIN toggle that promotes/removes it from the sidebar's Pinned quick-access section
* (via `usePins`). Rendered in the shared DetailPane (opened from the sidebar's
* "All products" row).
* All products — the directory of every Hanzo app, where you OPEN one and where you
* curate your sidebar. Every product is always available on demand; this panel lists
* the FULL catalog the viewer may see (brand-scoped, admin surfaces gated), grouped
* by category. A row OPENS its app; the pin toggle beside it promotes/removes it from
* the sidebar's Pinned quick-access section (via `usePins`). Rendered in the shared
* DetailPane (opened from the sidebar's "All products" row).
*
* Honest by construction: pinning is instant + optimistic (persisted through the
* account preferences store — no async error state). Products with REAL org usage
@@ -15,16 +15,19 @@
* the badges/filter degrade away — never a fabricated "in use".
*/
import { useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
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'
import { useDetailPane } from '~/components/DetailPane'
import { fetchUsageRecords } from '~/lib/api/aimetrics'
import { inUseProductIds } from '~/lib/products/product-usage'
import { asColor } from '~/components/ui/color'
import { EmptyState } from '~/components/ui/EmptyState'
import { EmptyState, asColor } from '@hanzo/ui/product'
/** The list narrowing controls at the top. */
type Filter = 'all' | 'inuse' | 'pinned'
@@ -41,23 +44,51 @@ function InUseBadge() {
)
}
/** One catalog row: icon · label/description (+ In-use badge) · pin toggle. */
/**
* One catalog row: icon · label/description (+ In-use badge) · pin toggle.
*
* The row itself OPENS the product. It used to be an inert `XStack` — a plain div
* with `cursor: auto`, no role and no handler — so the one place in the console that
* lists every app was a directory you could not walk: the only live control in the
* row was the pin. Opening is delegated to the shared `openProduct`, the ONE opener
* every other surface (sidebar, ⌘K, category page) already routes through.
*
* Pin stays a SEPARATE control on the same row, so curating never navigates and
* navigating never curates. It stops the press from bubbling into the row for the
* same reason.
*/
function ProductRow({
entry,
color,
pinned,
inUse,
onOpen,
onToggle,
}: {
entry: CatalogEntry
color: string
pinned: boolean
inUse: boolean
onOpen: () => void
onToggle: () => void
}) {
const Icon = entry.icon
return (
<XStack items="center" gap="$3" py="$2" px="$2" rounded="$3" minH={44} hoverStyle={{ bg: '$color2' }}>
<XStack
role="button"
tabIndex={0}
onPress={onOpen}
cursor="pointer"
items="center"
gap="$3"
py="$2"
px="$2"
rounded="$3"
minH={44}
hoverStyle={{ bg: '$color2' }}
focusStyle={{ bg: '$color2' }}
aria-label={`Open ${entry.label}`}
>
<YStack width={32} height={32} rounded="$3" bg="$color3" items="center" justify="center">
<Icon size={16} color={asColor(color)} />
</YStack>
@@ -73,7 +104,10 @@ function ProductRow({
<Button
size="$2"
icon={pinned ? <Star size={15} /> : <Plus size={15} />}
onPress={onToggle}
onPress={(e?: { stopPropagation?: () => void }) => {
e?.stopPropagation?.()
onToggle()
}}
bg={pinned ? '$color5' : 'transparent'}
borderWidth={1}
borderColor="$borderColor"
@@ -121,6 +155,15 @@ export function AddProductPanel() {
const showAdmin = useIsSuperAdmin()
const { isPinned, toggle } = usePins()
const { colorOf } = useProductColors()
const router = useRouter()
const detail = useDetailPane()
// Opening an app takes you to it: navigate, then close the pane you launched from
// (it is a directory, not a destination — leaving it open would cover the page it
// just opened).
const openEntry = (entry: CatalogEntry) => {
openProduct(entry, (path) => router.push(path))
detail.close()
}
const [query, setQuery] = useState('')
const [filter, setFilter] = useState<Filter>('all')
// Real org usage signal. `null` = not (yet) known; a Set (even empty) = a real
@@ -147,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.
@@ -236,6 +280,7 @@ export function AddProductPanel() {
color={colorOf(entry.id)}
pinned={isPinned(entry.id)}
inUse={inUse?.has(entry.id) ?? false}
onOpen={() => openEntry(entry)}
onToggle={() => toggle(entry.id)}
/>
))}
+55 -15
View File
@@ -1,27 +1,67 @@
'use client'
/**
* Bridges the console session + App-Router navigation into the shared analytics
* client (`@hanzo/event`). Rendered once, inside both `SessionProvider` and
* `AnalyticsProvider` (see `Provider.tsx`), it renders nothing.
* The console's telemetry surface — the ONE Hanzo telemetry provider, plus the one
* thing it deliberately leaves to the app.
*
* - `usePageview` emits a pageview on every path change (the provider fires the
* FIRST pageview itself, so this only covers subsequent client navigations).
* - `identify` binds the person to the STABLE `owner/name` actor id — the same id
* the API client already uses (`setCurrentActor`), never the email — once the
* session resolves. The org tenant is stamped server-side from the session, so
* we send the user id only. Anonymous placeholder sessions are skipped.
* `TelemetrySurface` mounts `@hanzogui/telemetry`, which owns the whole plane for
* this subtree: pageviews (including SPA route changes), `window.onerror` +
* `unhandledrejection`, React render errors (its internal boundary REPORTS and
* re-throws, so the app's own error UI still decides what the user sees), lazily
* imported interaction capture, and DNT/GPC consent. It replaces the hand-rolled
* `<AnalyticsProvider>` + bridge + boundary combo; it mounts @hanzo/event's
* `AnalyticsProvider` internally with its own client, so every existing
* `useAnalytics()` call site keeps working against that ONE client and one stream.
* `product="console"` is all the configuration there is — @hanzo/event's DSN
* registry resolves the hanzo-console Sentry project from it, so the error plane
* needs no `dsn` prop and no env var.
*
* It reads `usePathname()` ITSELF rather than taking a `path` prop from `Provider`:
* `Provider` memoizes its tree on `children`, so a path read up there would be
* baked into the cached element and go stale on the first client navigation.
*
* `AnalyticsBridge` is the one thing TelemetryProvider does NOT do — `identify`.
* It binds the person to the STABLE `owner/name` actor id (the same id the API
* client uses via `setCurrentActor`), never the email, once the session resolves.
* The org tenant is stamped server-side from the session, so we send the user id
* only, and anonymous placeholder sessions are skipped. It renders nothing and
* emits NO pageview — the provider owns those, and a second emitter would
* double-count every route.
*/
import { useEffect, useRef } from 'react'
import { useEffect, useRef, type ReactNode } from 'react'
import { usePathname } from 'next/navigation'
import { useAnalytics, usePageview } from '@hanzo/event/react'
import { TelemetryProvider, useTelemetry } from '@hanzogui/telemetry'
import { iamAccessToken } from '~/lib/auth/iam'
import { useSession } from '~/lib/auth/session'
import { type Account } from '~/lib/api/types'
/** The user attributes worth carrying alongside the id, from the IAM claims the
* session already decoded. A key is OMITTED rather than sent undefined, so an
* absent claim never overwrites a trait a prior identify established. */
export function identityTraits(account: Account): Record<string, unknown> {
const traits: Record<string, unknown> = {}
if (account.email) traits.email = account.email
const name = account.displayName ?? account.name
if (name) traits.name = name
return traits
}
export function TelemetrySurface({ children }: { children: ReactNode }) {
const path = usePathname()
// @hanzo/iam (PKCE) is the console's ONE credential and it is a BEARER — the same
// token `lib/api/client.ts` puts on every call — so telemetry authenticates the
// same way rather than relying on a cookie the ingest host would never receive.
return (
<TelemetryProvider product="console" path={path} getToken={iamAccessToken}>
{children}
</TelemetryProvider>
)
}
export function AnalyticsBridge() {
const analytics = useAnalytics()
const telemetry = useTelemetry()
const { account } = useSession()
usePageview(usePathname())
const identified = useRef('')
useEffect(() => {
@@ -29,8 +69,8 @@ export function AnalyticsBridge() {
const personId = `${account.owner}/${account.name}`
if (identified.current === personId) return
identified.current = personId
analytics.identify(personId)
}, [account, analytics])
telemetry.identify(personId)
}, [account, telemetry])
return null
}
+24 -6
View File
@@ -22,12 +22,13 @@ import { useIam } from '@hanzo/iam/react'
import { Button, Text, YStack } from '@hanzo/gui'
import { Loader } from '~/components/ui/Loader'
import { takeReturnTo } from '~/lib/auth/iam'
import { takeReturnTo, startReauth } from '~/lib/auth/iam'
import { classifyCallback, type CallbackVerdict } from '~/lib/auth/callback-error'
export function AuthCallback() {
const router = useRouter()
const { handleCallback } = useIam()
const [error, setError] = useState<string | null>(null)
const [verdict, setVerdict] = useState<CallbackVerdict | null>(null)
// The OAuth `code` is SINGLE-USE and the SDK removes the PKCE verifier BEFORE the
// token fetch, so the exchange must fire EXACTLY ONCE. Without this guard, React
// StrictMode's double-invoke (or any `handleCallback` identity change re-running the
@@ -47,20 +48,37 @@ export function AuthCallback() {
window.location.assign(takeReturnTo())
})
.catch(() => {
if (!cancelled) setError('Sign-in failed.')
if (cancelled) return
// The SDK is the AUTHORITY on whether a callback is a sign-in — it validates
// `state` before honouring an error branch, so an attacker-supplied
// /callback?error=… cannot mint a session. It reports every refusal the same
// way, though, so this screen used to say "Sign-in failed." to someone who had
// merely cancelled a consent screen. Read the code for WORDING only; the
// decision was already made above.
setVerdict(
classifyCallback(typeof window === 'undefined' ? '' : window.location.search) ?? {
kind: 'failed',
message: 'Sign-in failed.',
},
)
})
return () => {
cancelled = true
}
}, [handleCallback])
if (error) {
if (verdict) {
// A refusal is not always a fault. Only a genuine failure is worded as one, and
// the two benign outcomes lead with the action that actually resolves them.
const retry = verdict.kind === 'failed' ? 'Back to sign in' : 'Sign in'
return (
<YStack flex={1} minH="100vh" items="center" justify="center" gap="$3">
<Text color="$color12" fontWeight="600">
{error}
{verdict.message}
</Text>
<Button onPress={() => router.replace('/signin')}>Back to sign in</Button>
<Button onPress={() => (verdict.kind === 'failed' ? router.replace('/signin') : startReauth())}>
{retry}
</Button>
</YStack>
)
}
+6 -5
View File
@@ -73,17 +73,17 @@ import {
import { AiApi, IamAdminApi, type Organization } from '~/lib/api'
import { findEntry, type CatalogEntry } from '~/lib/products/registry'
import { commandBarSystemPrompt, hanzoAssistantSystemPrompt } from '~/lib/assistant'
import { assistantState, commandBarSystemPrompt, hanzoAssistantSystemPrompt } from '~/lib/assistant'
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 { asColor } from '~/components/ui/color'
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'
import { useSession } from '~/lib/auth/session'
import { useIsSuperAdmin } from '~/lib/auth/admin'
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
import { BackendStateCard, asColor, type BackendState } from '@hanzo/ui/product'
const titleCase = (s: string) => (s ? s[0].toUpperCase() + s.slice(1) : s)
@@ -365,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()
@@ -440,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])
@@ -542,7 +543,7 @@ function PaletteDialog({
setRun({ status: 'text', text: ans })
}
} catch (e) {
setRun({ status: 'error', state: classifyBackend(e) })
setRun({ status: 'error', state: assistantState(e) })
}
}, [mode, sub, showAdmin])
+216
View File
@@ -0,0 +1,216 @@
'use client'
/**
* Context switcher — WHERE you are: the organization and the project, in ONE
* control, at the TOP-LEFT where the tenant's mark already sits.
*
* The console used to answer "who and where am I" from three different corners:
* the org at the top of the rail, the account (which also switched org) at its
* foot, and the project chip in the top-right beside the network. Org and
* project are one question — which tenant, and which slice of it — so they are
* one control, and it sits with the org mark that already anchors the top-left.
*
* The ACCOUNT keeps the other question ("who am I": identity, team, personal
* settings, the way out) at the foot of the rail. The NETWORK stays its own
* control in the top-right, because it is a global MODE rather than a place —
* and its tier dot is a destructive-environment guard, not decoration.
*
* There is still exactly ONE org switch. `switchOrg` is passed by reference from
* `~/lib/org-scope` (the seam that persists the scope and reloads so every
* module refetches under the new `X-Org-Id`, which is where tenant scoping and
* its billing attribution already live). This control does not mint a second
* one, add a header of its own, or make a billing call — `org-state.test.ts`
* pins that identity. Cross-tenant reach is the SAME admin-gated, server-paged
* list the full-page picker uses; a regular user never fires it and sees only
* their own org.
*/
import { useCallback, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { Button, Popover, Text, XStack, YStack } from '@hanzo/gui'
import { ChevronsUpDown, FolderGit2, Plus } from '@hanzogui/lucide-icons-2'
import { useScope } from '~/lib/scope-context'
import { useOrgIdentity } from '~/components/ui/BrandLogo'
import { useIsSuperAdmin } from '~/lib/auth/admin'
import { IamAdminApi, type Organization } from '~/lib/api'
import { ORG_PAGE_SIZE, orgQuery } from '~/lib/org-list'
import { currentOrg, leaveOrg, switchOrg } from '~/lib/org-scope'
import { contextLabel, scopedOrgRow, titleCase } from '~/lib/account/org-state'
import { MenuRow } from '~/components/ui/MenuRow'
import { paper } from '~/components/ui/paper'
import { SearchInput } from '@hanzo/ui/product'
export function ContextSwitcher() {
const router = useRouter()
const org = useOrgIdentity()
const scoped = currentOrg()
const isSuperAdmin = useIsSuperAdmin()
const { scope, projects, loadingProjects, selectProject } = useScope()
const [open, setOpen] = useState(false)
const [orgs, setOrgs] = useState<Organization[] | null>(null)
const [query, setQuery] = useState('')
// IAM's display name when it has one; otherwise the slug, titled the same way
// `scopedOrgRow` titles it — one rule, so the trigger and the list agree.
const orgLabel = org.displayName || titleCase(org.name || scoped)
// The cross-tenant list is admin-gated at the proxy; a regular user would 403
// it, so they are never asked to — their own org is the honest answer. An admin
// searches the SERVER (the list is paged and far longer than one page), which is
// the only way to reach a tenant nobody is a member of.
const loadOrgs = useCallback(
async (q: string) => {
if (!isSuperAdmin) return setOrgs(scopedOrgRow(scoped) as Organization[])
const res = await IamAdminApi.organizations(orgQuery(0, q, ORG_PAGE_SIZE))
setOrgs(res.rows ?? [])
},
[isSuperAdmin, scoped],
)
const onOpenChange = useCallback(
(next: boolean) => {
setOpen(next)
if (next && orgs === null) void loadOrgs('')
},
[orgs, loadOrgs],
)
const search = useCallback(
(q: string) => {
setQuery(q)
void loadOrgs(q)
},
[loadOrgs],
)
const pick = useCallback(
(fn: () => void) => () => {
setOpen(false)
fn()
},
[],
)
const orgRows = useMemo(() => orgs ?? [], [orgs])
return (
<Popover open={open} onOpenChange={onOpenChange} placement="bottom-start">
<Popover.Trigger asChild>
<Button
size="$3"
chromeless
justify="flex-start"
px="$2"
data-testid="switcher-context"
iconAfter={<ChevronsUpDown size={13} opacity={0.6} />}
aria-label={`Organization and project — ${contextLabel(orgLabel, scope.project)}`}
>
{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>
<Popover.Content {...paper} p="$2" width={280}>
<YStack gap="$0.5">
<Text px="$2" py="$1" fontSize="$1" color="$color10" fontWeight="500">
Organization
</Text>
{/* Admins only: the cross-tenant list is server-paged and longer than
one page, so reaching a tenant nobody is a member of means SEARCHING
it, not scrolling. A regular user has one org and no field. */}
{isSuperAdmin ? (
<YStack px="$1" pb="$1">
{/* A search landmark names the control for assistive tech — the shared
SearchInput has no accessible-name prop of its own. */}
<div role="search" aria-label="Find an organization">
<SearchInput value={query} onChange={search} placeholder="Find an organization" name="org" />
</div>
</YStack>
) : null}
<YStack role="radiogroup" aria-label="Organizations" gap="$0.5">
{orgRows.map((o) => (
<MenuRow
key={o.name}
label={o.displayName || o.name}
active={scoped === o.name}
onPress={pick(() => {
if (o.name !== scoped) switchOrg(o.name)
})}
/>
))}
</YStack>
{orgRows.length === 0 ? (
<Text px="$2" py="$1.5" fontSize="$2" color="$color10">
{orgs === null ? 'Loading…' : 'No organization matches that.'}
</Text>
) : null}
<MenuRow label="All organizations" icon={<Plus size={14} />} onPress={pick(leaveOrg)} />
<XStack height={1} bg="$borderColor" my="$1" />
<Text px="$2" py="$1" fontSize="$1" color="$color10" fontWeight="500">
Project
</Text>
<YStack role="radiogroup" aria-label="Projects" gap="$0.5">
{/* Org-level scope — no X-Project-Id sent. */}
<MenuRow
label="All projects"
sub="Org-level"
active={!scope.project}
onPress={pick(() => selectProject(undefined))}
/>
{projects.map((p) => (
<MenuRow
key={p.name}
label={p.displayName || p.name}
active={scope.project === p.name}
onPress={pick(() => selectProject(p.name))}
/>
))}
</YStack>
{projects.length === 0 && !loadingProjects ? (
<Text px="$2" py="$1.5" fontSize="$2" color="$color10">
No projects yet.
</Text>
) : null}
<MenuRow
label="New project"
icon={<FolderGit2 size={14} />}
onPress={pick(() => router.push('/projects'))}
/>
</YStack>
</Popover.Content>
</Popover>
)
}
+1 -1
View File
@@ -28,8 +28,8 @@ import { Button, ScrollView, Text, XStack, YStack } from '@hanzo/gui'
import { X } from '@hanzogui/lucide-icons-2'
import { SlideOver } from '~/components/ui/SlideOver'
import { asColor, type IconLike } from '~/components/ui/color'
import { Z } from '~/lib/z'
import { asColor, type IconLike } from '@hanzo/ui/product'
export type DetailDescriptor = {
/** Pane title (the item's name). */
+120 -59
View File
@@ -15,7 +15,23 @@
*
* Docking is a desktop concern (a phone has no room for a permanent column), so on
* `<lg` the assistant is ALWAYS the floating bubble/sheet regardless of the dock
* choice; `docked` only reserves the right column at `lg+`.
* choice. That fact — the persisted CHOICE against a viewport that can honor it —
* meets in exactly one place, `column`, and it is what every shape is chosen by.
*
* It is not a detail. The sheet is a MODAL dialog, so leaving it open behind a hidden
* column put a full-viewport dialog over the page: the column painted, and every click
* on it landed on the dialog instead — an assistant you could read and could not type
* into. Hiding one of two open surfaces with CSS cannot fix that, because `display:
* none` on the dialog's own content does not make the dialog stop being modal. So only
* one is ever open, and `column` is the one fact that says which.
*
* The assistant has ONE entry point and it lives HERE: `AssistantFab`, a floating
* control fixed bottom-right over every dashboard page. It used to be two small
* buttons in the topbar (a brand-H and a mic), which put the assistant in a third
* place — beside the search box, competing with the org/theme/alert chrome — while
* this module owned every other shape it can take. Chat and voice are the same
* surface opened two ways, so they sit together, in the corner the assistant
* actually appears in.
*
* Every shape REUSES the one working chat surface (`ChatConversation` → `AiApi.chat`
* → the keyless `/ai` proxy → /v1/chat/completions). Nothing about AI is rebuilt
@@ -25,27 +41,33 @@
*/
import { createContext, useCallback, useContext, useState, type ReactNode } from 'react'
import { usePathname, useRouter } from 'next/navigation'
import { Button, Dialog, Text, VisuallyHidden, XStack, YStack } from '@hanzo/gui'
import { PanelRight, PanelRightClose, Sparkles, X } from '@hanzogui/lucide-icons-2'
import { Button, Dialog, Text, VisuallyHidden, XStack, YStack, useMedia } from '@hanzo/gui'
import { Mic, PanelRight, PanelRightClose, Sparkles, X } from '@hanzogui/lucide-icons-2'
import { ChatConversation } from '~/components/products/chat/ChatConversation'
// NB: the old `BrandMark` bubble import was removed with the floating circle — the
// assistant now opens from the topbar's small brand-H + mic controls.
import { BrandMark } from '~/components/ui/BrandLogo'
import { usePreferences } from '~/lib/products/preferences'
import { voiceSupported } from '~/lib/voice'
import { Z } from '~/lib/z'
type FloatingChatApi = {
isOpen: boolean
open: () => void
close: () => void
toggle: () => void
/** True when the assistant is docked as a permanent right column (persisted). */
docked: boolean
/**
* True when the permanent right column IS the assistant right now: the dock choice,
* a viewport wide enough to hold it, and a page that is not already a composer.
* Every surface reads this rather than the raw choice, so exactly one composer is
* on screen at any width.
*/
column: boolean
setDocked: (v: boolean) => void
/**
* Open the assistant with a PRE-FILLED prompt (e.g. "Ask AI about this code" from the
* Code hub). The composer is seeded and focused; the user reviews and sends (never an
* auto-send — no surprise billing), matching the suggested-prompt UX. Opens the floating
* sheet when floating; when docked, the permanent column receives the seed.
* auto-send — no surprise billing), matching the suggested-prompt UX. The column takes
* the seed when it is the assistant; otherwise the sheet opens with it.
*/
ask: (prompt: string) => void
/** The current pending seed for the composer (consumed once by the active conversation). */
@@ -114,17 +136,14 @@ function ChatSheet({
onOpenChange,
onHistory,
onDock,
docked,
seed,
voiceSignal,
}: {
/** Open ONLY when the sheet is the assistant — never alongside the column. */
open: boolean
onOpenChange: (o: boolean) => void
onHistory: () => void
onDock: () => void
/** When docked (desktop), the permanent column is the surface — so the floating
* sheet is suppressed at lg+ (it still serves phones, which have no column). */
docked: boolean
/** Pre-fill seed for the composer (from `useFloatingChat().ask`). */
seed?: string | null
/** Voice-start signal forwarded to the conversation ("talk to Hanzo"). */
@@ -136,12 +155,7 @@ function ChatSheet({
return (
<Dialog modal open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay
key="chat-overlay"
className="hz-scrim-in"
bg="rgba(0,0,0,0.5)"
$lg={{ bg: 'transparent', display: docked ? 'none' : undefined }}
/>
<Dialog.Overlay key="chat-overlay" className="hz-scrim-in" bg="rgba(0,0,0,0.5)" $lg={{ bg: 'transparent' }} />
<Dialog.Content
key="chat-content"
className="hz-paper hz-pop-in"
@@ -158,18 +172,8 @@ function ChatSheet({
width="100vw"
height="100dvh"
rounded="$0"
// Desktop (≥lg): a compact popover bottom-right, above the bubble. Docked →
// hidden at lg+ (the permanent right column replaces it).
$lg={{
t: 'auto',
l: 'auto',
b: 88,
r: 24,
width: 380,
height: 560,
rounded: '$6',
display: docked ? 'none' : undefined,
}}
// Desktop (≥lg): a compact popover bottom-right, above the bubble.
$lg={{ t: 'auto', l: 'auto', b: 88, r: 24, width: 380, height: 560, rounded: '$6' }}
>
<VisuallyHidden>
<Dialog.Title>Assistant</Dialog.Title>
@@ -204,6 +208,64 @@ function ChatSheet({
)
}
/**
* The floating assistant control — bottom-right, on every dashboard page.
*
* Two ways into one surface, side by side: the brand mark opens the assistant (the
* docked right column on a laptop, the full sheet on a phone) and the mic opens it
* listening. The mic renders only where the browser can actually listen, so there is
* never a dead control.
*
* It sits ABOVE the Developers dock at `lg+` (that dock's collapsed bar is 44px and
* exists only there), and its caller suppresses it exactly where the assistant is
* already on screen: while the sheet is open, on the pages that ARE a composer
* (`/chat`, `/playground`), and while the assistant IS the column.
*/
function AssistantFab({ onOpen, onVoice }: { onOpen: () => void; onVoice: () => void }) {
const [voiceOk] = useState(() => voiceSupported())
return (
<XStack
testID="assistant-fab"
position="fixed"
r={20}
b={20}
$lg={{ b: 64 }}
items="center"
gap="$2"
style={{ zIndex: Z.raised }}
>
{voiceOk ? (
<Button
className="hz-paper"
size="$3"
circular
width={44}
height={44}
bg="$color2"
borderWidth={1}
borderColor="$borderColor"
icon={<Mic size={18} />}
onPress={onVoice}
aria-label="Talk to Hanzo"
/>
) : null}
<Button
className="hz-paper"
size="$4"
circular
width={52}
height={52}
bg="$color2"
borderWidth={1}
borderColor="$borderColor"
icon={<BrandMark size={22} />}
onPress={onOpen}
aria-label="Ask Hanzo"
/>
</XStack>
)
}
/**
* The DOCKED assistant — a permanent right column. Rendered by `Dashboard`
* inside the layout's reserved right rail (lg+ only), so it reserves space beside
@@ -229,30 +291,36 @@ export function Chat({ children }: { children: ReactNode }) {
const { get, set } = usePreferences()
const docked = get<boolean>('chatDocked', false)
const setDocked = useCallback((v: boolean) => set('chatDocked', v), [set])
// The bubble is redundant — and OVERLAPS the composer's send control — on the
// pages that ARE a full chat/composer surface. Suppress it there (the assistant
// is still openable programmatically via `useFloatingChat`); every other page
// keeps the one-tap bubble.
const media = useMedia()
// The pages that ARE a full composer already show the assistant, so no other shape
// of it belongs on them — the bubble would overlap the page's own send control, and
// the column would be a second composer beside the first.
const onChatSurface =
pathname === '/chat' ||
pathname.startsWith('/chat/') ||
pathname === '/playground' ||
pathname.startsWith('/playground/')
// Every fact about which shape the assistant takes meets here and nowhere else: the
// persisted choice, a viewport wide enough to honor it, and whether this page is
// already a composer. A JS fact, not a media prop, because it decides what MOUNTS —
// a modal dialog that is merely hidden is still modal, and still eats every click.
const column = docked && media.lg && !onChatSurface
const [isOpen, setIsOpen] = useState(false)
const open = useCallback(() => setIsOpen(true), [])
const close = useCallback(() => setIsOpen(false), [])
const toggle = useCallback(() => setIsOpen((v) => !v), [])
// Seed the composer from anywhere (e.g. the Code hub's "Ask AI about this code").
// Open the floating sheet when floating; when docked, the permanent column is already
// on screen and receives the seed, so opening the (hidden) sheet is skipped.
// The column is already on screen and receives the seed; otherwise open the sheet —
// including on a phone whose owner once docked on a laptop, where the choice is
// remembered but no column exists to deliver the prompt.
const [seed, setSeed] = useState<string | null>(null)
const ask = useCallback(
(prompt: string) => {
setSeed(prompt)
if (!docked) setIsOpen(true)
if (!column) setIsOpen(true)
},
[docked],
[column],
)
const onHistory = useCallback(() => {
@@ -270,10 +338,9 @@ export function Chat({ children }: { children: ReactNode }) {
// conversation opens the mic on change.
const [voiceSignal, setVoiceSignal] = useState(0)
// The topbar brand-H entry: TOGGLE the assistant. Desktop → the docked right
// sidebar column; phones (no column) → the full sheet. Setting both in tandem is
// correct because at lg+ the sheet is suppressed while docked, and below lg the
// dock column is display:none — so one control opens the right surface per viewport.
// The topbar brand-H entry: TOGGLE the assistant. Desktop → the right column;
// phones (no column) → the full sheet. Both are set because `column` then admits
// exactly one of them per viewport.
const openChat = useCallback(() => {
const next = !docked
setDocked(next)
@@ -289,29 +356,23 @@ export function Chat({ children }: { children: ReactNode }) {
}, [setDocked])
return (
<Ctx.Provider value={{ isOpen, open, close, toggle, docked, setDocked, ask, seed, openChat, startVoice, voiceSignal }}>
<Ctx.Provider value={{ isOpen, open, close, toggle, column, setDocked, ask, seed, openChat, startVoice, voiceSignal }}>
{children}
{/* The bubble — fixed bottom-right over every page. Hidden while open (the
sheet's own close is the single dismiss). Hidden on the chat/playground
surfaces (would overlap the page composer). At lg+ it is ALSO hidden when
docked (the permanent column is the surface); on phones it always shows,
since docking has no room there. */}
{/* NO floating bubble — the big circle that covered page content is gone. The
assistant is opened from the topbar (the small brand-H "chat" control + the
"talk to Hanzo" mic), so the user's OWN brand leads the chrome and AI help is
one small press away. `open`/`toggle`/`ask` still drive it programmatically
(e.g. the Code hub's "Ask AI"). */}
{/* The assistant's ONE entry point — bottom-right, over every page. Hidden
while the sheet is open (its own close is the single dismiss), on the pages
that ARE a composer, and while the column is the surface. `open`/`toggle`/
`ask` still drive the assistant programmatically (e.g. "Ask AI"). */}
{isOpen || onChatSurface || column ? null : <AssistantFab onOpen={openChat} onVoice={startVoice} />}
{/* The floating sheet. Suppressed on the full chat/playground surfaces (the page
IS the composer) and, at lg+, while docked (the right column is the surface);
on phones it's the assistant even when docked. */}
{/* The floating sheet — the assistant wherever the column is not: every width on
a phone or tablet, and on a laptop until the user docks it. Never open at the
same time as the column, so there is one composer and it takes the click. */}
<ChatSheet
open={isOpen && !onChatSurface}
open={isOpen && !onChatSurface && !column}
onOpenChange={setIsOpen}
onHistory={onHistory}
onDock={dock}
docked={docked}
seed={seed}
voiceSignal={voiceSignal}
/>
+70 -4
View File
@@ -20,11 +20,27 @@ import { Button, Card, Input, Spinner, Text, XStack, YStack } from '@hanzo/gui'
import { Building2, ArrowRight, Sparkles } from '@hanzogui/lucide-icons-2'
import { useSession } from '~/lib/auth/session'
import { slugifyOrg, validateOrgName } from '~/lib/server/onboarding'
import { readOnboardRefusal, slugifyOrg, validateOrgName } from '~/lib/server/onboarding'
import { v1Url } from '~/lib/api/client'
import { FadeIn } from '~/components/ui/FadeIn'
import { FadeIn } from '@hanzo/ui/product'
type Phase = 'form' | 'done'
type Phase = 'form' | 'done' | 'exists'
// currentOwner asks the server which organization this account is in. IAM answers
// the legacy envelope ({status,msg,data}) on this address, so read through `data`
// and fall back to a bare body. Any failure returns null and the caller falls back
// to showing the server's own message — a recovery that guesses is worse than none.
async function currentOwner(): Promise<string | null> {
try {
const res = await fetch(v1Url('iam/account'), { credentials: 'include' })
if (!res.ok) return null
const body = (await res.json()) as { data?: { owner?: string }; owner?: string } | null
const owner = body?.data?.owner ?? body?.owner
return owner && owner !== 'admin' ? owner : null
} catch {
return null
}
}
export function OrgOnboarding() {
const { signIn, signOut } = useSession()
@@ -32,6 +48,9 @@ export function OrgOnboarding() {
const [busy, setBusy] = useState<false | 'create' | 'personal'>(false)
const [error, setError] = useState<string | null>(null)
const [phase, setPhase] = useState<Phase>('form')
// The org the SERVER says this account already admins, discovered only after a
// refused create. See the 409 branch in onboard().
const [existingOrg, setExistingOrg] = useState<string | null>(null)
const slug = slugifyOrg(name)
const named = validateOrgName(name)
@@ -55,7 +74,28 @@ export function OrgOnboarding() {
}
const json = (await res.json().catch(() => null)) as { org?: string; error?: string } | null
if (!res.ok || !json?.org) {
setError(json?.error || `Could not create the organization (HTTP ${res.status}).`)
// 409 = the FIRST-RUN GATE, not a name collision. Onboarding MOVES the caller
// into the org it founds, so founding a second one would orphan the org this
// account already admins — IAM refuses (provision.go: "you already have an
// organization"). This screen is only ever rendered when the client resolved
// an EMPTY owner, and an owner that is empty because a read failed looks
// exactly like a brand-new account. So the refusal is the first reliable
// signal that the session was wrong, and the only honest thing to do with it
// is recover: ask the server which org this account is actually in and offer
// the way in. Leaving the customer on a form that can never submit — with a
// message about organizations when they just typed a name — is how "it said
// the name was taken" happens.
const refusal = readOnboardRefusal(
res.status,
json?.error,
res.status === 409 ? await currentOwner() : null,
)
if (refusal.action === 'recover') {
setExistingOrg(refusal.org)
setPhase('exists')
} else {
setError(refusal.error)
}
setBusy(false)
return
}
@@ -69,6 +109,32 @@ export function OrgOnboarding() {
signIn()
}
if (phase === 'exists' && existingOrg) {
return (
<Center>
<FadeIn style={CENTER_STYLE}>
<Card p="$5" gap="$4" width={440} borderWidth={1} borderColor="$borderColor" bg="$color1" items="center">
<Building2 size={20} />
<YStack gap="$1" items="center">
<Text fontSize="$6" fontWeight="800">
You{'\u2019'}re already in {existingOrg}
</Text>
<Text fontSize="$3" color="$color11" text="center">
This account already belongs to an organization, so there was nothing to
create. Sign in again to continue there.
</Text>
</YStack>
{/* Explicit, never automatic. Re-authenticating on our own would loop
forever against whatever left the session without an owner. */}
<Button size="$3" onPress={() => signIn()}>
Continue to {existingOrg}
</Button>
</Card>
</FadeIn>
</Center>
)
}
if (phase === 'done') {
return (
<Center>
+1 -2
View File
@@ -27,10 +27,9 @@ import { useIsSuperAdmin } from '~/lib/auth/admin'
import { enterOrg } from '~/lib/org-scope'
import { IamAdminApi, type Organization } from '~/lib/api'
import { BrandMark } from '~/components/ui/BrandLogo'
import { EmptyState } from '~/components/ui/EmptyState'
import { FadeIn } from '~/components/ui/FadeIn'
import { OrgOnboarding } from '~/components/OrgOnboarding'
import { PAGE_SIZE, pickerView, type OrgCard, type PickerContext } from '~/components/org-picker/logic'
import { EmptyState, FadeIn } from '@hanzo/ui/product'
const titleCase = (s: string) => (s ? s[0].toUpperCase() + s.slice(1) : s)
+8 -11
View File
@@ -8,15 +8,13 @@ import { useMemo, type ReactNode } from 'react'
import { GuiProvider } from '@hanzo/gui'
import { NextThemeProvider, useRootTheme } from '@hanzogui/next-theme'
import { registerDefaultFields, registerField } from '@hanzo/data'
import { AnalyticsProvider } from '@hanzo/event/react'
import { IamProvider } from '@hanzo/iam/react'
import config from '../../gui.config'
import { SessionProvider } from '~/lib/auth/session'
import { iamConfig } from '~/lib/auth/iam'
import { EntitlementsProvider } from '~/lib/entitlements-context'
import { eventClient } from '~/lib/event'
import { AnalyticsBridge } from './Analytics'
import { AnalyticsBridge, TelemetrySurface } from './Analytics'
import { OrgAccentProvider } from './OrgAccentProvider'
import { RichTextDisplay, RichTextInput } from './fields/RichTextField'
@@ -64,16 +62,15 @@ export function Provider({ children }: { children: ReactNode }) {
{/* Entitlements live inside the session (they read the signed-in account +
active org scope) so the sidebar/palette gate from ONE fetch. */}
<EntitlementsProvider>
{/* Analytics lives INSIDE the session so `identify` binds the signed-in
actor. The ONE shared `eventClient` (same-origin /v1/event; the tenant is
stamped server-side, so the client never sends an org) is also referenced
by the error boundaries, so every signal rides one stream. The provider
fires the first pageview (autoPageview); `AnalyticsBridge` wires the
per-navigation pageviews + identity. */}
<AnalyticsProvider client={eventClient}>
{/* Telemetry lives INSIDE the session so `identify` binds the signed-in
actor. `TelemetrySurface` is the ONE provider — pageviews, errors,
interaction capture, consent — and it mounts @hanzo/event internally,
so `useAnalytics()` call sites share its client. `AnalyticsBridge`
adds only identity; the provider owns pageviews. */}
<TelemetrySurface>
<AnalyticsBridge />
{children}
</AnalyticsProvider>
</TelemetrySurface>
</EntitlementsProvider>
</SessionProvider>
</IamProvider>
-186
View File
@@ -1,186 +0,0 @@
'use client'
/**
* PublicLanding — what an UNAUTHENTICATED visitor sees at `/` (the marketing face
* of the console). The SAME one `cloud` binary that serves the signed-in console
* serves this to anon — one binary, one way, no separate marketing service.
*
* Content is DERIVED from the real product taxonomy (categoriesForBrand +
* CATEGORY_SUMMARY), brand-scoped via getBrand — so it is honest (every tile is a
* real category the platform ships) and white-labels for free. The primary CTA is
* the ONE sign-in surface (/signin); "Learn more" goes to the brand's own site.
*/
import { useRouter } from 'next/navigation'
import { Button, Text, XStack, YStack } from '@hanzo/gui'
import { HanzoHeader, HANZO_PRODUCT_CATEGORIES, findSurfaceByHost, type HanzoSurface } from '@hanzogui/shell'
import { config } from '~/config'
import { getBrand } from '~/lib/branding/brands'
import { categoriesForBrand, CATEGORY_SUMMARY } from '~/lib/products/brand-scope'
import { landingSurface } from '~/lib/products/landing-surface'
import { ConsoleFooter } from '~/components/ConsoleFooter'
/** The shared header, with its CTAs re-pointed at this landing's own sign-in
* (the canonical surface aims them AT the console — a self-link from here). */
const LANDING_SURFACE: HanzoSurface = landingSurface(findSurfaceByHost('cloud.hanzo.ai'))
/**
* Decline the shell's account control. `HanzoHeader` renders its OWN text "Sign in"
* link whenever `account` is nullish (`account ?? <DefaultAccount/>`), so omitting it
* put a second sign-in beside the primary CTA that IS the sign-in — the desktop
* logged-out header read `[Get API key] [Sign in] [Sign in]`. `false` is how a caller
* says "no account node": it is not nullish, so the default never renders, and React
* renders nothing for it — including in the mobile sheet, which would otherwise draw
* an empty bordered identity row around it. Exactly ONE sign-in affordance.
*/
const NO_ACCOUNT = false
/**
* The house hero buttons — the same pill pair every Hanzo landing wears: a white
* primary carrying the weight, and a hairline secondary that is still visibly a
* button. Tamagui's default Button is a grey chip that reads as DISABLED next to
* the white sign-in pill in the header, and `chromeless` has no edge at all.
*/
const CTA_PRIMARY = {
rounded: 999,
bg: '$color12',
color: '$color1',
borderWidth: 0,
hoverStyle: { bg: '$color12', opacity: 0.85 },
pressStyle: { bg: '$color12', opacity: 0.7 },
} as const
const CTA_SECONDARY = {
rounded: 999,
bg: 'transparent',
color: '$color12',
borderWidth: 1,
borderColor: '$color6',
hoverStyle: { bg: '$color3', borderColor: '$color8' },
pressStyle: { bg: '$color4' },
} as const
export function PublicLanding() {
const router = useRouter()
const brand = getBrand()
const categories = categoriesForBrand(brand.id).filter((c) => c !== 'Settings')
const signIn = (
<Button size="$3" onPress={() => router.push('/signin')}>
Sign in
</Button>
)
return (
<YStack minH="100vh" bg="$color1">
{/*
Top bar. On the Hanzo brand this is the UNIFIED @hanzogui/shell HanzoHeader
(Meet Hanzo + the rich ten-category Products mega-menu + brand tokens) — the
SAME header every Hanzo surface wears. White-label brands (lux/zoo/…) keep the
brand-neutral bar so no Hanzo ecosystem URL leaks onto their console.
*/}
{brand.id === 'hanzo' ? (
// The header's own primary CTA IS the sign-in (see LANDING_SURFACE), so the
// account control is declined explicitly (see NO_ACCOUNT) — one way in, not
// two competing sign-ins.
<HanzoHeader
surface={LANDING_SURFACE}
productsTaxonomy={HANZO_PRODUCT_CATEGORIES}
account={NO_ACCOUNT}
/>
) : (
<XStack
items="center"
justify="space-between"
px="$4"
py="$3"
borderBottomWidth={1}
borderColor="$borderColor"
$md={{ px: '$6' }}
>
<Text fontSize="$6" fontWeight="700" color="$color12">
{config.brandName}
</Text>
{signIn}
</XStack>
)}
{/* Hero */}
<YStack items="center" gap="$4" px="$4" py="$10" $md={{ py: '$12' }}>
<YStack items="center" gap="$3" maxW={760}>
{/* `hz-display` gives the headline a unitless line-height (see globals.css):
the size token's own line-height is tuned for ONE line, so on a phone —
where this always wraps — the two lines overprint without it. */}
<Text
render="h1"
className="hz-display"
fontSize="$11"
$md={{ fontSize: '$13' }}
fontWeight="800"
color="$color12"
style={{ textAlign: 'center' }}
>
The AI cloud, one platform
</Text>
<Text fontSize="$5" color="$color10" style={{ textAlign: 'center' }}>
Models, compute, training, data, and the tools to ship with usage-based billing and a single API.
</Text>
</YStack>
<XStack gap="$3" mt="$2" flexWrap="wrap" justify="center">
<Button
size="$4"
{...CTA_PRIMARY}
onPress={() => router.push('/signin')}
>
Get started
</Button>
<Button
size="$4"
{...CTA_SECONDARY}
onPress={() => typeof window !== 'undefined' && window.open(brand.websiteUrl, '_blank', 'noopener')}
>
Learn more
</Button>
</XStack>
</YStack>
{/* Real product categories — derived from the live taxonomy */}
<XStack justify="center" px="$4" pb="$10" $md={{ px: '$6' }}>
<YStack width="100%" maxW={1080} gap="$4">
<div
style={{
display: 'grid',
gap: 16,
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
}}
>
{categories.map((c) => (
<YStack
key={c}
gap="$1.5"
p="$4"
rounded="$4"
borderWidth={1}
borderColor="$borderColor"
bg="$color2"
>
<Text fontSize="$5" fontWeight="700" color="$color12">
{c}
</Text>
<Text fontSize="$3" color="$color10">
{CATEGORY_SUMMARY[c]}
</Text>
</YStack>
))}
</div>
</YStack>
</XStack>
<XStack justify="center" px="$4" $md={{ px: '$6' }}>
<YStack width="100%" maxW={1080}>
<ConsoleFooter />
</YStack>
</XStack>
</YStack>
)
}
+21 -115
View File
@@ -1,32 +1,35 @@
'use client'
/**
* Scope switcher — the project + network pickers that scope every module.
* Scope switcher — the NETWORK picker, and only that.
*
* Two chips next to the org switcher: the active PROJECT (or "All projects" for
* org-level scope) and the active NETWORK. The network picker offers the stock
* tiers (Mainnet/Testnet/Devnet — the live Hanzo networks), a Local option for a
* self-hosted cloud binary, and any custom networks the user adds (their own
* networkID / EVM chainID / RPC / API). Selecting a network writes through
* `useScope`, which updates the module-level scope the API client reads — so it
* re-scopes every module (via `X-Environment`) AND retargets chain/RPC/API at once.
* A "New project" affordance routes to the Projects module; we never fabricate one.
* The network is a global MODE, not a place, so it stays its own control in the
* top-right while org and project condense into the top-left `ContextSwitcher`.
* Its tier dot is a destructive-environment guard (mainnet live green, testnet
* caution amber), which is why it keeps a distinct, always-visible chip rather
* than folding into a menu you have to open to read.
*
* The picker offers the stock tiers (Mainnet/Testnet/Devnet — the live Hanzo
* networks), a Local option for a self-hosted cloud binary, and any custom
* networks the user adds (their own networkID / EVM chainID / RPC / API).
* Selecting a network writes through `useScope`, which updates the module-level
* scope the API client reads — so it re-scopes every module (via
* `X-Environment`) AND retargets chain/RPC/API at once.
*/
import { useMemo, useState, type ReactNode } from 'react'
import { useRouter } from 'next/navigation'
import { useMemo, useState } from 'react'
import { Button, Popover, Text, XStack, YStack } from '@hanzo/gui'
import { Check, ChevronsUpDown, FolderGit2, Layers, Plus, Trash } from '@hanzogui/lucide-icons-2'
import { Check, ChevronsUpDown, Layers, Plus, Trash } from '@hanzogui/lucide-icons-2'
import { useScope } from '~/lib/scope-context'
import { STOCK_ENVIRONMENTS } from '~/lib/scope'
import { isStockNetwork, parseCustomNetwork, type Network } from '~/lib/network'
import { FieldText } from '~/components/ui/Field'
import { MenuRow, type DotColor } from '~/components/ui/MenuRow'
import { paper } from '~/components/ui/paper'
import { FieldText } from '@hanzo/ui/product'
/** A small dot keyed to the network tier. Monochrome by default; only the genuine
* states carry a hue — mainnet is live (green), testnet is a caution (amber). Every
* other tier is a neutral off the design ladder (Tamagui $colorN). */
type DotColor = '$green10' | '$yellow10' | '$color10' | '$color9' | '$color8'
const NET_DOT: Record<string, DotColor> = {
mainnet: '$green10',
testnet: '$yellow10',
@@ -41,59 +44,6 @@ const titleCase = (s: string) => (s ? s[0].toUpperCase() + s.slice(1) : s)
const idSub = (n: Network): string =>
n.networkID === n.evmChainID ? `Chain ${n.evmChainID}` : `Net ${n.networkID} · Chain ${n.evmChainID}`
function ProjectPicker() {
const router = useRouter()
const { scope, projects, loadingProjects, selectProject } = useScope()
const label = scope.project ? scope.project : 'All projects'
return (
<Popover placement="bottom-end">
<Popover.Trigger asChild>
<Button size="$2" chromeless icon={<FolderGit2 size={14} />} iconAfter={<ChevronsUpDown size={13} />}>
{label}
</Button>
</Popover.Trigger>
<Popover.Content {...paper} p="$2" width={260}>
<YStack gap="$0.5">
<Text px="$2" py="$1" fontSize="$1" color="$color10" fontWeight="500">
Project
</Text>
{/* Org-level scope — no X-Project-Id sent. */}
<Row
label="All projects"
sub="Org-level"
active={!scope.project}
onPress={() => selectProject(undefined)}
/>
{projects.map((p) => (
<Row
key={p.name}
label={p.displayName || p.name}
active={scope.project === p.name}
onPress={() => selectProject(p.name)}
/>
))}
{projects.length === 0 && !loadingProjects ? (
<Text px="$2" py="$1.5" fontSize="$2" color="$color10">
No projects yet.
</Text>
) : null}
<XStack height={1} bg="$borderColor" my="$1" />
<Row
label="New project"
icon={<Plus size={14} />}
onPress={() => router.push('/projects')}
/>
</YStack>
</Popover.Content>
</Popover>
)
}
const EMPTY_FORM = { label: '', networkID: '', evmChainID: '', rpcEndpoint: '', apiEndpoint: '' }
function AddNetworkForm({
@@ -200,7 +150,7 @@ function NetworkPicker() {
Project environments
</Text>
{extraEnvs.map((env) => (
<Row
<MenuRow
key={env}
label={titleCase(env)}
sub="Custom"
@@ -216,7 +166,7 @@ function NetworkPicker() {
{adding ? (
<AddNetworkForm taken={takenCustomIds} onAdd={add} onCancel={() => setAdding(false)} />
) : (
<Row label="Add custom network" icon={<Plus size={14} />} onPress={() => setAdding(true)} />
<MenuRow label="Add custom network" icon={<Plus size={14} />} onPress={() => setAdding(true)} />
)}
</YStack>
</Popover.Content>
@@ -263,54 +213,10 @@ function NetworkRow({
)
}
/** One selectable row in a picker popover. */
function Row({
label,
sub,
dot,
icon,
active,
onPress,
}: {
label: string
sub?: string
dot?: DotColor
icon?: ReactNode
active?: boolean
onPress: () => void
}) {
return (
<XStack
onPress={onPress}
cursor="pointer"
items="center"
gap="$2"
px="$2"
py="$2"
rounded="$3"
hoverStyle={{ bg: '$color4' }}
>
{dot ? <YStack width={8} height={8} rounded="$10" bg={dot} /> : icon}
<YStack flex={1}>
<Text fontSize="$2" color="$color12" numberOfLines={1}>
{label}
</Text>
{sub ? (
<Text fontSize="$1" color="$color10">
{sub}
</Text>
) : null}
</YStack>
{active ? <Check size={14} /> : null}
</XStack>
)
}
/** Project + network pickers as a unit (topbar). */
/** The network picker (topbar). Org + project live in `ContextSwitcher`, top-left. */
export function ScopeSwitcher() {
return (
<XStack items="center" gap="$1">
<ProjectPicker />
<XStack items="center" gap="$1" data-testid="switcher-network">
<NetworkPicker />
</XStack>
)
+1 -1
View File
@@ -26,13 +26,13 @@
import { useCallback, useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react'
import { useRouter } from 'next/navigation'
import { Text, XStack, YStack } from '@hanzo/gui'
import { OrgMark } from '@hanzo/ui/product'
import { BookOpen, Globe, Info, SlidersHorizontal } from '@hanzogui/lucide-icons-2'
import { config } from '~/config'
import { getBrand } from '~/lib/branding/brands'
import { useOrgIdentity } from '~/components/ui/BrandLogo'
import { Z } from '~/lib/z'
import { OrgMark } from '@hanzo/ui/product'
type MenuItem = {
icon: typeof SlidersHorizontal
+1 -2
View File
@@ -22,9 +22,8 @@ import { COLOR_SWATCHES } from '~/lib/products/colors'
import { DEFAULT_GROUP, DEFAULT_GROUP_LABEL, type PinGroupView } from '~/lib/products/pins-core'
import { usePins, useProductColors } from '~/lib/products/pins'
import { findEntry } from '~/lib/products/registry'
import { Reorder } from '~/components/ui/Reorder'
import { asColor } from '~/components/ui/color'
import { contrastText } from '~/lib/theme/accent'
import { Reorder, asColor } from '@hanzo/ui/product'
/** A round color swatch button; ringed + checked when selected. */
function SwatchButton({ hex, selected, onPress }: { hex: string; selected: boolean; onPress: () => void }) {
+1 -1
View File
@@ -24,9 +24,9 @@ import { useIam } from '@hanzo/iam/react'
import { Text, YStack } from '@hanzo/gui'
import { Loader } from '~/components/ui/Loader'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import { config } from '~/config'
import { useSession } from '~/lib/auth/session'
import { PrimaryButton } from '@hanzo/ui/product'
export function SignIn() {
const { account, loading } = useSession()
+33 -10
View File
@@ -34,9 +34,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { Button, Card, Spinner, Text, XStack, YStack } from '@hanzo/gui'
import { Bot, Plus, Terminal, X } from '@hanzogui/lucide-icons-2'
import { ComboBox, type ComboOption } from '~/components/ui/ComboBox'
import { FieldRow, FieldSelect, FieldSlider, FieldSwitch, FieldText, FieldTextArea } from '~/components/ui/Field'
import { SelectMenu, type SelectOption } from '~/components/ui/SelectMenu'
import {
canSubmit,
classifyBuilderError,
@@ -48,6 +45,7 @@ import {
toCreateBody,
} from './logic'
import type { AgentBuilderLoaders, AgentConfig, AgentSpec, BuilderOption, BuilderPrompt, ReasoningEffort } from './types'
import { ComboBox, FieldRow, FieldSelect, FieldSlider, FieldSwitch, FieldText, FieldTextArea, SelectMenu, type ComboOption, type SelectOption } from '@hanzo/ui/product'
/** Async option-list state for the live pickers (model/tool). */
type OptState =
@@ -60,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)
@@ -167,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)
@@ -204,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>
@@ -241,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). */
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import { identityTraits } from './Analytics'
import { type Account } from '~/lib/api/types'
const account = (over: Partial<Account> = {}): Account => ({
owner: 'hanzo',
name: 'z',
userId: 'sub-1',
...over,
})
describe('identityTraits', () => {
it('carries the email and the human name off the IAM claims', () => {
expect(
identityTraits(account({ email: 'z@hanzo.ai', displayName: 'Z Hanzo' })),
).toEqual({ email: 'z@hanzo.ai', name: 'Z Hanzo' })
})
it('falls back to the login handle when no display name was claimed', () => {
expect(identityTraits(account({ email: 'z@hanzo.ai' }))).toEqual({
email: 'z@hanzo.ai',
name: 'z',
})
})
// An absent claim must be ABSENT, not `undefined`: a trait sent as undefined
// is a trait written, and it would blank a value an earlier identify had set.
it('omits a key it has no claim for rather than sending undefined', () => {
const traits = identityTraits(account({ displayName: 'Z Hanzo' }))
expect(traits).toEqual({ name: 'Z Hanzo' })
expect('email' in traits).toBe(false)
})
// The tenant is stamped server-side from the validated bearer. A tenant the
// client can name is a tenant the client can get wrong.
it('never sends the org', () => {
const traits = identityTraits(
account({ email: 'z@hanzo.ai', organization: 'hanzo', owner: 'hanzo' }),
)
expect(traits).not.toHaveProperty('org')
expect(traits).not.toHaveProperty('organization')
expect(traits).not.toHaveProperty('owner')
})
})
+1 -1
View File
@@ -20,9 +20,9 @@ import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
import { RefreshCw, TriangleAlert } from '@hanzogui/lucide-icons-2'
import { RecordsView, type FieldDefinition } from '@hanzo/data'
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
import { BaseDataApi, type BaseRecord } from '~/lib/base-data/api'
import { baseCollectionToFields } from '~/lib/base-data/fields'
import { BackendStateCard, classifyBackend, type BackendState } from '@hanzo/ui/product'
type LoadState =
| { phase: 'loading' }
@@ -21,11 +21,10 @@ import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
import { ArrowLeft, Pencil, Save, Trash2, TriangleAlert, X } from '@hanzogui/lucide-icons-2'
import { RecordDetail, RecordForm, type FieldDefinition } from '@hanzo/data'
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import { BaseDataApi, type BaseRecord } from '~/lib/base-data/api'
import { baseCollectionToFields } from '~/lib/base-data/fields'
import { recordLabel, savePayload } from './records'
import { BackendStateCard, PrimaryButton, classifyBackend, type BackendState } from '@hanzo/ui/product'
export interface RecordDetailViewProps {
/** A configured Base client (transport already wired — e.g. the `/superbase` proxy). */
+1 -3
View File
@@ -16,9 +16,6 @@ import { useCallback, useMemo, useState, type CSSProperties } from 'react'
import { Button, Card, Input, Label, Text, XStack, YStack } from '@hanzo/gui'
import { GripVertical, Plus, Trash2, TriangleAlert, ArrowUp, ArrowDown } from '@hanzogui/lucide-icons-2'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import { FieldSwitch } from '~/components/ui/Field'
import { classifyBackend } from '~/components/ui/BackendState'
import type { FrameworkClient } from '~/lib/framework/client'
import type { DocType, Fieldtype } from '~/lib/framework/types'
import {
@@ -32,6 +29,7 @@ import {
type BuilderField,
} from './builder-logic'
import { CHEVRON } from '~/components/ui/Field'
import { FieldSwitch, PrimaryButton, classifyBackend } from '@hanzo/ui/product'
// A value≠label native <select>, themed with the app CSS vars (same idiom as
// ui/Field.tsx FieldSelect, but here options carry a distinct value + label).
@@ -16,15 +16,12 @@ import { useCallback, useEffect, useState } from 'react'
import { Card, Text, XStack, YStack } from '@hanzo/gui'
import { Boxes, Plus, TriangleAlert } from '@hanzogui/lucide-icons-2'
import { PageHeader } from '~/components/ui/PageHeader'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import { EmptyState } from '~/components/ui/EmptyState'
import { Loader } from '~/components/ui/Loader'
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
import type { FrameworkClient } from '~/lib/framework/client'
import type { DocType } from '~/lib/framework/types'
import { moduleDoctypes } from '~/lib/framework/fields'
import { CollectionBuilder } from './CollectionBuilder'
import { BackendStateCard, EmptyState, PageHeader, PrimaryButton, classifyBackend, type BackendState } from '@hanzo/ui/product'
export interface CollectionsBrowserProps {
client: FrameworkClient
+1 -2
View File
@@ -19,12 +19,11 @@ import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
import { ArrowLeft, Ban, Globe, Pencil, PenOff, Save, Send, Trash2, TriangleAlert, X } from '@hanzogui/lucide-icons-2'
import { RecordDetail, RecordForm, type FieldDefinition, type SelectOption } from '@hanzo/data'
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import type { FrameworkClient } from '~/lib/framework/client'
import type { DocType, FrameworkDoc } from '~/lib/framework/types'
import { docTypeToFields, toRecord, enrichLinks, savePayload, newDraft, statusField, titleOf, hasProjectField, PROJECT_FIELD } from '~/lib/framework/fields'
import { loadLinkOptions, makeFieldOptions } from './data'
import { BackendStateCard, PrimaryButton, classifyBackend, type BackendState } from '@hanzo/ui/product'
export interface DocTypeDetailProps {
client: FrameworkClient
+1 -1
View File
@@ -19,12 +19,12 @@ import { Button } from '@hanzo/gui'
import { RefreshCw } from '@hanzogui/lucide-icons-2'
import { RecordsView, type FieldDefinition, type SelectOption } from '@hanzo/data'
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
import type { FrameworkClient } from '~/lib/framework/client'
import type { DocType, FrameworkDoc } from '~/lib/framework/types'
import { docTypeToFields, toRecord, enrichLinks, savePayload, isMediaDoctype, hasProjectField, PROJECT_FIELD } from '~/lib/framework/fields'
import { loadLinkOptions, makeFieldOptions } from './data'
import { MediaGrid } from './MediaGrid'
import { BackendStateCard, classifyBackend, type BackendState } from '@hanzo/ui/product'
type LoadState =
| { phase: 'loading' }
+1 -3
View File
@@ -17,13 +17,11 @@ import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
import { Image as ImageIcon, Trash2, TriangleAlert, Upload } from '@hanzogui/lucide-icons-2'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import { EmptyState } from '~/components/ui/EmptyState'
import { classifyBackend } from '~/components/ui/BackendState'
import type { FrameworkClient } from '~/lib/framework/client'
import type { DocType, FrameworkDoc } from '~/lib/framework/types'
import { mediaFileField, titleOf } from '~/lib/framework/fields'
import { uploadMedia, resolveMediaUrl, deleteMediaObject } from './media-upload'
import { EmptyState, PrimaryButton, classifyBackend } from '@hanzo/ui/product'
export interface MediaGridProps {
client: FrameworkClient
+8 -4
View File
@@ -43,7 +43,6 @@ import {
Zap,
} from '@hanzogui/lucide-icons-2'
import { FadeIn } from '~/components/ui/FadeIn'
import { GuidedTour } from '~/components/tour/GuidedTour'
import { useGuideSignals } from '~/lib/guide/use-signals'
import { stepAnchorId } from '~/lib/guide/signals'
@@ -58,6 +57,7 @@ import {
type ProductGuide,
} from '~/lib/guide/spec'
import type { ProductIcon } from '~/lib/products/registry'
import { FadeIn } from '@hanzo/ui/product'
/** Pitch-point icon name → Lucide glyph (kept out of the pure data layer). */
const PITCH_ICON: Record<PitchIcon, ProductIcon> = {
@@ -129,8 +129,8 @@ export function PitchHero({ guide }: { guide: ProductGuide }) {
<Text fontSize="$1" color="$color10" fontWeight="500">
Get started
</Text>
{/* `hz-display` — the ONE way this app sets display leading (globals.css,
same as PublicLanding). It was `style={{ lineHeight: 1.12 }}`, which is a
{/* `hz-display` — the ONE way this app sets display leading (globals.css).
It was `style={{ lineHeight: 1.12 }}`, which is a
correct ratio in plain React but NOT under @hanzo/gui: react-native-web
appends `px` to any numeric style value absent from its unitless list, and
`lineHeight` is absent — so it compiled to `line-height: 1.12px`, a 1px box
@@ -143,8 +143,12 @@ export function PitchHero({ guide }: { guide: ProductGuide }) {
</Text>
</YStack>
<XStack gap="$2" items="center">
{/* Neutral, not filled: the ONE filled action in this card is the
checklist's ACTIVE step below — the thing to do next. A white "Take
the tour" beside it made two buttons compete for the same emphasis,
and the tour is the aside. */}
{tourSteps.length ? (
<Button size="$2" theme="light" icon={<Compass size={14} />} onPress={() => setTourOpen(true)}>
<Button size="$2" icon={<Compass size={14} />} onPress={() => setTourOpen(true)}>
Take the tour
</Button>
) : null}
@@ -22,7 +22,6 @@ import { config } from '~/config'
import { useSession } from '~/lib/auth/session'
import { usePreferences } from '~/lib/products/preferences'
import { BrandMark } from '~/components/ui/BrandLogo'
import { FadeIn } from '~/components/ui/FadeIn'
import {
ONBOARDING_STEPS,
LAST_INDEX,
@@ -42,6 +41,7 @@ import { TeamStep } from '~/components/onboarding/steps/TeamStep'
import { CreditsStep } from '~/components/onboarding/steps/CreditsStep'
import { AiAccessStep } from '~/components/onboarding/steps/AiAccessStep'
import { LaunchStep } from '~/components/onboarding/steps/LaunchStep'
import { FadeIn } from '@hanzo/ui/product'
const STEP_COMPONENTS: Record<StepId, (p: StepProps) => ReactNode> = {
secure: SecureStep,
+24 -4
View File
@@ -10,30 +10,50 @@ import type { ReactNode } from 'react'
import { Button, Card, Spinner, Text, XStack, YStack } from '@hanzo/gui'
import { ArrowLeft, ArrowRight, Check } from '@hanzogui/lucide-icons-2'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import { ONBOARDING_STEPS, type StepId, type StepStatus } from '~/lib/onboarding/steps'
import { PrimaryButton } from '@hanzo/ui/product'
/** Title + subtitle header + body for one step. */
/**
* The content area's reserved height. Every step's body occupies at least this
* much, so `actions` lands at the SAME y on every step and Continue never moves
* under the pointer between steps — the whole reason actions is a slot on the
* shell rather than the last child a step happens to render.
*
* A taller step still grows (the area is flex), so this reserves space without
* capping it.
*/
const CONTENT_MIN_HEIGHT = 320
export function StepShell({
title,
subtitle,
children,
actions,
}: {
title: string
subtitle: string
children: ReactNode
/**
* The step's StepActions. It is a SLOT, not a child, so the shell decides
* where it sits — one placement for every step, decided in one place.
*/
actions?: ReactNode
}) {
return (
<YStack gap="$4" flex={1} minW={0}>
<YStack gap="$1.5">
<Text fontSize="$8" fontWeight="800" color="$color12">
<Text testID="onboarding-step-title" fontSize="$8" fontWeight="800" color="$color12">
{title}
</Text>
<Text fontSize="$4" color="$color11">
{subtitle}
</Text>
</YStack>
{children}
<YStack gap="$4" flex={1} minH={CONTENT_MIN_HEIGHT}>
{children}
</YStack>
{actions}
</YStack>
)
}
@@ -61,7 +81,7 @@ export function StepActions({
busy?: boolean
}) {
return (
<XStack gap="$3" items="center" justify="space-between" flexWrap="wrap" pt="$2">
<XStack testID="onboarding-actions" gap="$3" items="center" justify="space-between" flexWrap="wrap" pt="$2">
<XStack>
{onBack ? (
<Button size="$3" chromeless disabled={busy} icon={<ArrowLeft size={16} />} onPress={onBack}>
@@ -24,12 +24,11 @@ import { Wand2, KeyRound, LogIn, Check, Plus } from '@hanzogui/lucide-icons-2'
import { AiAccountsApi } from '~/lib/api/ai-accounts'
import { AiConnectionsApi, AI_CONNECTION_PROVIDERS, type AiConnection, type AiConnectionProvider } from '~/lib/api/ai-connections'
import { ApiError } from '~/lib/api/client'
import { FieldSelect, FieldText } from '~/components/ui/Field'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import { useToast } from '~/components/ui/Toast'
import { ChoiceCard, StepShell, StepActions } from '~/components/onboarding/parts'
import { withAiChoice, type AiChoice } from '~/lib/onboarding/steps'
import type { StepProps } from '~/components/onboarding/types'
import { FieldSelect, FieldText, PrimaryButton } from '@hanzo/ui/product'
const providerLabel = (id: AiConnectionProvider): string => AI_CONNECTION_PROVIDERS.find((p) => p.id === id)?.label ?? id
const providerFromLabel = (label: string): AiConnectionProvider =>
@@ -121,7 +120,19 @@ export function AiAccessStep({ state, patch, next, skip, back, isFirst }: StepPr
const connectedLabels = connections.map((c) => c.provider).join(', ')
return (
<StepShell title="AI access" subtitle="Choose how you want to power AI. You can change or combine these anytime in AI Accounts.">
<StepShell
title="AI access"
subtitle="Choose how you want to power AI. You can change or combine these anytime in AI Accounts."
actions={
<StepActions
onBack={isFirst ? undefined : back}
onSkip={skip}
skipLabel="Decide later"
onContinue={next}
continueLabel="Continue"
/>
}
>
<ChoiceCard
icon={<Wand2 size={20} />}
title="Let Hanzo power it"
@@ -247,14 +258,6 @@ export function AiAccessStep({ state, patch, next, skip, back, isFirst }: StepPr
{err}
</Text>
) : null}
<StepActions
onBack={isFirst ? undefined : back}
onSkip={skip}
skipLabel="Decide later"
onContinue={next}
continueLabel="Continue"
/>
</StepShell>
)
}
@@ -12,9 +12,9 @@ import { FileText, Sparkles } from '@hanzogui/lucide-icons-2'
import { config } from '~/config'
import { getBrand } from '~/lib/branding/brands'
import { FieldSwitch } from '~/components/ui/Field'
import { StepShell, StepActions, InlineLink } from '~/components/onboarding/parts'
import type { StepProps } from '~/components/onboarding/types'
import { FieldSwitch } from '@hanzo/ui/product'
export function ConsentStep({ state, patch, next, back, isFirst }: StepProps) {
const brand = getBrand()
@@ -33,7 +33,17 @@ export function ConsentStep({ state, patch, next, back, isFirst }: StepProps) {
}
return (
<StepShell title="Data & consent" subtitle={`A couple of choices about how ${config.brandName} handles your data.`}>
<StepShell
title="Data & consent"
subtitle={`A couple of choices about how ${config.brandName} handles your data.`}
actions={
// No Skip here on purpose: accepting the Terms is not optional, so an
// affordance that skips past them would be dishonest. Continue stays
// disabled until the box is ticked. The data-sharing choice beside it IS
// optional and defaults to off — leaving it alone is the skip.
<StepActions onBack={isFirst ? undefined : back} onContinue={commit} continueDisabled={!agreed} />
}
>
<Card p="$4" gap="$3" borderWidth={1} borderColor="$borderColor">
<XStack gap="$3" items="flex-start" justify="space-between">
<XStack gap="$3" items="center" flex={1} minW={0}>
@@ -80,7 +90,6 @@ export function ConsentStep({ state, patch, next, back, isFirst }: StepProps) {
</Text>
) : null}
<StepActions onBack={isFirst ? undefined : back} onContinue={commit} continueDisabled={!agreed} />
</StepShell>
)
}
+22 -18
View File
@@ -7,8 +7,8 @@
* browser — the console never sees a PAN. REAL: `BillingApi.paymentConfig` mounts the
* element (`useSquareCard`), `createPaymentMethod({token})` vaults the card (the
* commerce handler grants/extends the trial credit as a side-effect, $1 verify-then-
* void, no charge), `welcome()` claims the fixed starter grant (idempotent), and
* `balance()` shows the granted balance. Skippable — credits can be added later.
* void, no charge), and `balance()` shows the granted balance. Skippable — credits
* can be added later.
*/
import { useEffect, useRef, useState } from 'react'
import { Card, Spinner, Text, XStack, YStack } from '@hanzo/gui'
@@ -19,10 +19,10 @@ import type { CloudBalance } from '~/lib/api/wallet'
import { useSquareCard } from '~/lib/billing/use-square-card'
import { trialCents, spendableCents, balanceSplitLabel, invalidateBalance } from '~/lib/billing/live-balance'
import { ApiError } from '~/lib/api/client'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import { useToast } from '~/components/ui/Toast'
import { StepShell, StepActions } from '~/components/onboarding/parts'
import type { StepProps } from '~/components/onboarding/types'
import { PrimaryButton } from '@hanzo/ui/product'
const usd = (c: number): string => `$${(c / 100).toFixed(2)}`
@@ -70,9 +70,9 @@ export function CreditsStep({ next, skip, back, isFirst }: StepProps) {
try {
const token = await card.tokenize()
await BillingApi.createPaymentMethod({ type: 'card', token })
// Claim the fixed starter grant too (idempotent server-side); the card-added
// handler also extends the trial — both are safe to run.
await BillingApi.welcome().catch(() => undefined)
// The trial credit is granted SERVER-SIDE as a side-effect of vaulting the card.
// The browser never mints its own credit — the only credit mint is the
// mint-gated POST /v1/billing/credit, which a tenant session cannot call.
const bal = await BillingApi.balance().catch(() => balance)
if (!mounted.current) return
setBalance(bal)
@@ -91,7 +91,21 @@ export function CreditsStep({ next, skip, back, isFirst }: StepProps) {
const split = balanceSplitLabel(balance)
return (
<StepShell title="Free trial credits" subtitle="Add a card to unlock free trial credits. No charge now — it just keeps your account ready when the trial ends.">
<StepShell
title="Free trial credits"
subtitle="Add a card to unlock free trial credits. No charge now — it just keeps your account ready when the trial ends."
actions={
<StepActions
onBack={isFirst ? undefined : back}
onSkip={phase === 'ready' && !isUnlocked ? skip : undefined}
skipLabel="Skip for now"
onContinue={next}
continueLabel="Continue"
continueDisabled={phase === 'ready' && !isUnlocked}
busy={adding}
/>
}
>
{phase === 'loading' ? (
<Card p="$5" items="center" borderWidth={1} borderColor="$borderColor">
<Spinner size="large" color="$color11" />
@@ -121,7 +135,7 @@ export function CreditsStep({ next, skip, back, isFirst }: StepProps) {
<XStack gap="$2" items="center">
<CreditCard size={18} color="var(--color10)" />
<Text fontSize="$4" fontWeight="700" color="$color12">
Payments aren't set up on this deployment
Payments aren't set up for this organization
</Text>
</XStack>
<Text fontSize="$3" color="$color11">
@@ -195,16 +209,6 @@ export function CreditsStep({ next, skip, back, isFirst }: StepProps) {
</XStack>
</Card>
)}
<StepActions
onBack={isFirst ? undefined : back}
onSkip={phase === 'ready' && !isUnlocked ? skip : undefined}
skipLabel="Skip for now"
onContinue={next}
continueLabel="Continue"
continueDisabled={phase === 'ready' && !isUnlocked}
busy={adding}
/>
</StepShell>
)
}
@@ -12,8 +12,8 @@ import { EVENTS } from '@hanzo/event'
import { config } from '~/config'
import { StepShell, ChoiceCard } from '~/components/onboarding/parts'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import type { StepProps } from '~/components/onboarding/types'
import { PrimaryButton } from '@hanzo/ui/product'
const TILES: { icon: typeof MessageSquare; title: string; description: string; to: string }[] = [
{ icon: MessageSquare, title: 'Start a chat', description: 'Talk to the latest Zen model right now.', to: '/chat' },
@@ -30,11 +30,11 @@ export function LaunchStep({ finish, back, isFirst }: StepProps) {
<XStack gap="$2" items="center">
<PartyPopper size={20} color="var(--green11)" />
<Text fontSize="$5" fontWeight="700" color="$green11">
Your workspace is set up
Your organization is set up
</Text>
</XStack>
<Text fontSize="$3" color="$color11">
Two-factor, consent, workspace, credits, and AI access are all configured.
Two-factor, consent, organization, credits, and AI access are all configured.
</Text>
</Card>
+16 -12
View File
@@ -13,9 +13,9 @@ import { ShieldCheck, KeyRound, Copy } from '@hanzogui/lucide-icons-2'
import { useSession } from '~/lib/auth/session'
import { ApiError } from '~/lib/api/client'
import { MfaApi, type MfaSetup } from '~/lib/api/mfa'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import { StepShell, StepActions } from '~/components/onboarding/parts'
import type { StepProps } from '~/components/onboarding/types'
import { PrimaryButton } from '@hanzo/ui/product'
function copy(value: string) {
if (typeof navigator !== 'undefined' && navigator.clipboard) void navigator.clipboard.writeText(value).catch(() => {})
@@ -62,7 +62,21 @@ export function SecureStep({ next, skip, back, isFirst }: StepProps) {
}
return (
<StepShell title="Secure your account" subtitle="Add two-factor authentication so a stolen password isn't enough to sign in.">
<StepShell
title="Secure your account"
subtitle="Add two-factor authentication so a stolen password isn't enough to sign in."
actions={
<StepActions
onBack={isFirst ? undefined : back}
onSkip={enabled ? undefined : skip}
skipLabel="Skip securing my account"
onContinue={next}
continueLabel="Continue"
continueDisabled={!enabled}
busy={busy}
/>
}
>
{enabled ? (
<Card p="$4" gap="$3" borderWidth={1} borderColor="$green7" bg="$green2">
<XStack gap="$2" items="center">
@@ -161,16 +175,6 @@ export function SecureStep({ next, skip, back, isFirst }: StepProps) {
</XStack>
</Card>
)}
<StepActions
onBack={isFirst ? undefined : back}
onSkip={enabled ? undefined : skip}
skipLabel="Skip securing my account"
onContinue={next}
continueLabel="Continue"
continueDisabled={!enabled}
busy={busy}
/>
</StepShell>
)
}
+21 -15
View File
@@ -1,11 +1,11 @@
'use client'
/**
* Step 3 — Your workspace. Confirms the org the user is in (created at first-run
* Step 3 — Your organization. Confirms the org the user is in (created at first-run
* org onboarding) and lets them optionally NAME it. REAL: reads/writes the org via
* the org-admin `TeamApi` (`get-organization` / `update-organization`, pinned to the
* caller's own org server-side). Renaming is best-effort — a read/write failure never
* blocks the flow (the workspace already exists).
* blocks the flow (the organization already exists).
*/
import { useEffect, useState } from 'react'
import { Card, Input, Spinner, Text, XStack, YStack } from '@hanzo/gui'
@@ -20,7 +20,7 @@ import { useToast } from '~/components/ui/Toast'
import { StepShell, StepActions } from '~/components/onboarding/parts'
import type { StepProps } from '~/components/onboarding/types'
export function TeamStep({ next, back, isFirst }: StepProps) {
export function TeamStep({ next, skip, back, isFirst }: StepProps) {
const { account } = useSession()
const toast = useToast()
const org = account?.owner || currentOrg()
@@ -52,9 +52,9 @@ export function TeamStep({ next, back, isFirst }: StepProps) {
setBusy(true)
try {
await TeamApi.updateOrganization({ ...record, displayName: name })
toast.success('Workspace renamed', name)
toast.success('Organization renamed', name)
} catch (e) {
toast.error('Could not rename the workspace', e instanceof ApiError ? e.message : undefined)
toast.error('Could not rename the organization', e instanceof ApiError ? e.message : undefined)
} finally {
setBusy(false)
}
@@ -63,7 +63,20 @@ export function TeamStep({ next, back, isFirst }: StepProps) {
}
return (
<StepShell title="Your workspace" subtitle="This is where your projects, usage, and billing live. Name it now, or keep the default.">
<StepShell
title="Your organization"
subtitle="This is where your projects, usage, and billing live. Name it now, or keep the default."
actions={
<StepActions
onBack={isFirst ? undefined : back}
onSkip={skip}
skipLabel="Keep the default"
onContinue={() => void commit()}
continueLabel="Continue"
busy={busy}
/>
}
>
<Card p="$4" gap="$3" borderWidth={1} borderColor="$borderColor">
<XStack gap="$3" items="center">
<YStack width={44} height={44} rounded="$4" items="center" justify="center" bg="$color3">
@@ -82,7 +95,7 @@ export function TeamStep({ next, back, isFirst }: StepProps) {
{record ? (
<YStack gap="$1.5">
<Text fontSize="$2" color="$color11" fontWeight="600">
Workspace name
Organization name
</Text>
<Input value={displayName} onChangeText={setDisplayName} placeholder="Acme Inc" autoCapitalize="words" />
</YStack>
@@ -92,16 +105,9 @@ export function TeamStep({ next, back, isFirst }: StepProps) {
<XStack gap="$2" items="center">
<Users size={16} color="var(--color10)" />
<Text fontSize="$2" color="$color10">
Invite teammates and switch workspaces anytime from the top bar.
Invite teammates and switch organizations anytime from the top bar.
</Text>
</XStack>
<StepActions
onBack={isFirst ? undefined : back}
onContinue={() => void commit()}
continueLabel="Continue"
busy={busy}
/>
</StepShell>
)
}
@@ -16,9 +16,8 @@ import { useCallback, useState } from 'react'
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
import { Accessibility, ExternalLink } from '@hanzogui/lucide-icons-2'
import { PageHeader } from '~/components/ui/PageHeader'
import { DataTable, type Column } from '~/components/ui/DataTable'
import { toIssues, summarize, IMPACTS, type A11yIssue, type A11ySummary, type Impact } from '~/lib/a11y/scan'
import { DataTable, PageHeader, type Column } from '@hanzo/ui/product'
type State =
| { phase: 'idle' }
@@ -11,9 +11,7 @@ import { useRouter } from 'next/navigation'
import type { CatalogEntry, ProductSubpage } from '~/lib/products/registry'
import { config } from '~/config'
import { PageHeader } from '~/components/ui/PageHeader'
import { EmptyState } from '~/components/ui/EmptyState'
import { FadeIn } from '~/components/ui/FadeIn'
import { EmptyState, FadeIn, PageHeader } from '@hanzo/ui/product'
export function AdminManagedNotice({
entry,
+3 -5
View File
@@ -23,10 +23,8 @@ import {
} from '~/lib/api/admin'
import { config } from '~/config'
import { currentOrg } from '~/lib/org-scope'
import { PageHeader } from '~/components/ui/PageHeader'
import { DataTable, type Column } from '~/components/ui/DataTable'
import { FieldRow, FieldText } from '~/components/ui/Field'
import { ErrorState, asApiError, isForbidden, SuperAdminRequired, type HonestCopy } from '~/components/ui/States'
import { DataTable, FieldRow, FieldText, PageHeader, SecretInput, type Column } from '@hanzo/ui/product'
/** IAM-specific guidance for the honest 404 / unauthorized states. */
const IAM_COPY: HonestCopy = {
@@ -107,7 +105,7 @@ type Tone = { tone: 'ok' | 'err'; text: string }
/**
* Users with full CRUD — create, promote/demote admin, and delete — over the
* ready IamAdminApi mutations (add/update/delete-user) through the server-gated
* /admin/iam proxy, scoped to `owner`. This is the casdoor user surface, in
* /admin/iam proxy, scoped to `owner`. This is the IAM user surface, in
* console: no link-out for the common lifecycle. Honest states throughout.
*/
function UsersAdminView({ owner }: { owner: string }) {
@@ -221,7 +219,7 @@ function UsersAdminView({ owner }: { owner: string }) {
<Text fontSize="$4" fontWeight="700">New user in {owner}</Text>
<FieldRow label="Name"><FieldText value={name} onChange={setName} placeholder="jdoe" disabled={saving} /></FieldRow>
<FieldRow label="Email"><FieldText value={email} onChange={setEmail} placeholder="jdoe@hanzo.ai" disabled={saving} /></FieldRow>
<FieldRow label="Password"><FieldText value={password} onChange={setPassword} placeholder="initial password" secure disabled={saving} /></FieldRow>
<FieldRow label="Password"><SecretInput value={password} onChange={setPassword} placeholder="initial password" disabled={saving} copy={false} id="initial-password" /></FieldRow>
<XStack gap="$2" items="center">
<Button self="flex-start" icon={<Plus size={15} />} disabled={saving} onPress={() => void create()}>
{saving ? 'Creating…' : 'Create user'}
+1 -1
View File
@@ -20,11 +20,11 @@ import { Activity, AlertTriangle, Boxes, Building2, Coins, Cpu, Gauge, ScrollTex
import { AdminO11yApi, type FleetO11y, type O11yRange } from '~/lib/api/admin-o11y'
import { MetricCard } from '~/components/ui/Metric'
import { DataTable, type Column } from '~/components/ui/DataTable'
import { LineChart, type ChartPoint } from '~/components/ui/Charts'
import { asApiError, ErrorState, SuperAdminRequired } from '~/components/ui/States'
import { useIsSuperAdmin } from '~/lib/auth/admin'
import { formatMetric } from '~/components/products/overview/living/logic'
import { DataTable, type Column } from '@hanzo/ui/product'
const RANGES: O11yRange[] = ['24h', '7d', '30d']
const pct1 = (v: number): string => (Number.isFinite(v) ? `${v.toFixed(1)}%` : '—')

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