Compare commits

..
47 Commits
Author SHA1 Message Date
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
440 changed files with 21285 additions and 5473 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
# console2 — Hanzo Cloud Console (Next.js 15 + @hanzo/gui). BSD-3-Clause.
# console2 — Hanzo Cloud Console (Next.js 15 + @hanzo/gui). MIT OR Apache-2.0.
# NEXT_PUBLIC_* are inlined at build time (browser config), so they are build args.
FROM public.ecr.aws/docker/library/node:24-alpine AS build
WORKDIR /app
+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.
+177 -3
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/*.
@@ -3588,8 +3588,8 @@ and the rail hid them.
rail, `SubNav`, and ⌘K all say the same word. The eight products missing `subpages`
now declare them; the icons the strips were carrying moved onto the declarations.
- **`components/ui/SubNav.tsx` is the ONE strip**, rendered from `productSubpages` and
hidden at `lg+` (`$lg={{ display: 'none' }}`) because the sidebar's `DrillNav` owns
level 2 there. One declaration, two mounts — never two navs painting at once. It
hidden at `lg+` (`$lg={{ display: 'none' }}`) because the sidebar owns level 2
there (then `DrillNav`; now `SubRows` — see "The rail stopped drilling" below). One declaration, two mounts — never two navs painting at once. It
takes an optional `href` for a product whose tabs carry URL state (Containers keeps
its `?cluster=` selection across tabs). `subpageIcon` moved here and `dashboard.tsx`
imports it, so the sub-page icon defaults exist once.
@@ -3747,3 +3747,177 @@ configuration, not a property of the deployment. Both now name the organization,
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')}
/>
-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
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
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'
+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')
+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()
})
+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). */
+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(() => {})
+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()
+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
+48 -8
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
@@ -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
+8 -8
View File
@@ -1,9 +1,9 @@
{
"name": "@hanzo/console",
"version": "8.5.35",
"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,26 +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.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.2",
"@hanzo/iam": "^0.21.6",
"@hanzo/logo": "^1.0.14",
"@hanzo/ui": "^8.0.38",
"@hanzo/ui": "^8.0.56",
"@hanzo/usage": "^0.1.6",
"@hanzogui/config": "^8.0.0",
"@hanzogui/core": "^8.0.0",
"@hanzogui/lucide-icons-2": "^8.0.0",
"@hanzogui/next-theme": "^8.0.0",
"@hanzogui/shell": "^8.0.1",
"@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",
@@ -62,7 +63,6 @@
"@types/node": "22.20.0",
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"patch-package": "^8.0.0",
"react-native": "0.83.9",
"typescript": "^5.9.3",
"vitest": "3.2.4"
+527 -269
View File
File diff suppressed because it is too large Load Diff
+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')
+1 -1
View File
@@ -75,7 +75,7 @@ export function AccountMenu() {
// 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: 'Team', href: '/team' },
{ 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)}
/>
))}
+45 -38
View File
@@ -1,40 +1,38 @@
'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 user to their Hanzo IAM user id (`account.userId`, the
* OIDC `sub`) once the session resolves, AND carries the attributes that make
* that id legible. 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.
*
* WHY NOT `owner/name`. This used to identify by the `${owner}/${name}` actor ref.
* That is an org-relative REFERENCE, not a user id: it is a different id space
* from the one hanzo.ai and hanzo.chat identify by (the IAM `sub`), so the same
* user counted twice the moment they used two Hanzo surfaces — and every
* cross-property funnel, retention curve and path silently measured nothing. It
* also moves when an org or a login handle is renamed, which rewrites history.
* 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.
*
* WHY TRAITS. A bare `identify(id)` writes a user id and nothing else, so the
* warehouse held a population of opaque subjects: every funnel could count users
* but no one could say WHICH user, and answering "who hit this error" meant a
* manual IAM lookup per row. Email and name are FIRST-PARTY facts about our own
* users — they arrive in the same IAM claims this file already decodes to get the
* id, and were simply dropped on the floor. Sent as traits they are exactly as
* sensitive as they were in the token, and the id stays the join key.
*
* WHAT IS STILL NOT SENT: the org. The tenant is stamped SERVER-SIDE from the
* validated bearer, so org-level cohorts are already queryable — and a tenant the
* client can name is a tenant the client can get wrong. Traits describe the user,
* never the scope they are trusted with.
* `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'
@@ -49,21 +47,30 @@ export function identityTraits(account: Account): Record<string, unknown> {
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(() => {
if (!account || account.type === 'anonymous-user') return
// No IAM subject means no IAM user — leave the visitor anonymous rather than
// inventing an id for them. Identity flows from the token's own claims.
const userId = account.userId
if (!userId || identified.current === userId) return
identified.current = userId
analytics.identify(userId, identityTraits(account))
}, [account, analytics])
if (!account?.owner || !account?.name || account.type === 'anonymous-user') return
const personId = `${account.owner}/${account.name}`
if (identified.current === personId) return
identified.current = personId
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])
+31 -10
View File
@@ -36,9 +36,9 @@ 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 { FieldText } from '~/components/ui/Field'
import { MenuRow } from '~/components/ui/MenuRow'
import { paper } from '~/components/ui/paper'
import { SearchInput } from '@hanzo/ui/product'
export function ContextSwitcher() {
const router = useRouter()
@@ -105,9 +105,31 @@ export function ContextSwitcher() {
iconAfter={<ChevronsUpDown size={13} opacity={0.6} />}
aria-label={`Organization and project — ${contextLabel(orgLabel, scope.project)}`}
>
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1} flex={1}>
{contextLabel(orgLabel, scope.project)}
</Text>
{org.logo ? (
// The org's own logo IS the label — the uploaded mark takes the
// slot the name held, height-capped to the row so any aspect fits.
// A scoped project keeps its text beside it; the full text stays
// in the aria-label either way. Arbitrary tenant URL/data URL, so
// a raw <img> (next/image would need a per-tenant remote
// allow-list) — same call BrandLogo makes.
<XStack items="center" gap="$2" flex={1} minW={0}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={org.logo}
alt={orgLabel}
style={{ height: 22, width: 'auto', maxWidth: 140, objectFit: 'contain', display: 'block' }}
/>
{scope.project ? (
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1} flex={1}>
/ {scope.project}
</Text>
) : null}
</XStack>
) : (
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1} flex={1}>
{contextLabel(orgLabel, scope.project)}
</Text>
)}
</Button>
</Popover.Trigger>
@@ -122,12 +144,11 @@ export function ContextSwitcher() {
it, not scrolling. A regular user has one org and no field. */}
{isSuperAdmin ? (
<YStack px="$1" pb="$1">
<FieldText
value={query}
onChange={search}
placeholder="Find an organization"
ariaLabel="Find an organization"
/>
{/* 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}
+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>
)
}
+1 -1
View File
@@ -23,9 +23,9 @@ import { Check, ChevronsUpDown, Layers, Plus, Trash } from '@hanzogui/lucide-ico
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
+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). */
+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>
)
}
+16 -12
View File
@@ -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)}`
@@ -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" />
@@ -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,
+2 -4
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 = {
@@ -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)}%` : '—')
+1 -7
View File
@@ -21,14 +21,8 @@ import { Text, XStack, YStack } from '@hanzo/gui'
import { Target, Plus, RefreshCw } from '@hanzogui/lucide-icons-2'
import { PLATFORMS, STATUSES, AdsApi, type Campaign, type Summary } from '~/lib/api/ads'
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
import { DataTable, type Column } from '~/components/ui/DataTable'
import { EmptyState } from '~/components/ui/EmptyState'
import { FieldRow, FieldText, FieldSelect } from '~/components/ui/Field'
import { PageHeader } from '~/components/ui/PageHeader'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import { StatusTag } from '~/components/ui/StatusTag'
import { SlideOver } from '~/components/ui/SlideOver'
import { BackendStateCard, DataTable, EmptyState, FieldRow, FieldSelect, FieldText, PageHeader, PrimaryButton, StatusTag, classifyBackend, type BackendState, type Column } from '@hanzo/ui/product'
/** cents (int64 minor units from the backend) → a $ label. */
function money(cents: number): string {
@@ -15,15 +15,12 @@ import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
import { BadgeCheck, Ban, Coins, HandCoins, Handshake, Percent, RefreshCw, Users, Wallet, Zap } from '@hanzogui/lucide-icons-2'
import { AdminAffiliatesApi, type AdminAffiliate, type AdminAffiliatesView, type SweepResult } from '~/lib/api/admin-affiliates'
import { PageHeader } from '~/components/ui/PageHeader'
import { MetricCard } from '~/components/ui/Metric'
import { EmptyState } from '~/components/ui/EmptyState'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import { FieldRow, FieldSelect, FieldText } from '~/components/ui/Field'
import { asApiError, ErrorState, isForbidden, SuperAdminRequired } from '~/components/ui/States'
import { ApiError } from '~/lib/api'
import { dollarsToCents, percentToBps, ratePct, shortDate, statusLabel, statusColor, usd } from './affiliates/logic'
import { toneColor } from '~/components/ui/tone'
import { EmptyState, FieldRow, FieldSelect, FieldText, PageHeader, PrimaryButton } from '@hanzo/ui/product'
/** The backend caps the DIRECT (L1) rate at 9300 bps so the L1+L2+L3 schedule ≤ 100% of margin. */
const MAX_RATE_BPS = 9300
+2 -5
View File
@@ -16,14 +16,11 @@ import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
import { BadgeCheck, Check, Coins, Copy, HandCoins, Handshake, RefreshCw, Users, Wallet } from '@hanzogui/lucide-icons-2'
import { AffiliatesApi, type AffiliateOverview } from '~/lib/api/affiliates'
import { PageHeader } from '~/components/ui/PageHeader'
import { MetricCard } from '~/components/ui/Metric'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import { FieldRow, FieldText } from '~/components/ui/Field'
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
import { payoutMethodLabel, ratePct, shortDate, statusLabel, statusColor, usd } from './affiliates/logic'
import { EarningsPanel, LinksPanel, LeaderboardPanel } from './affiliates/panels'
import { toneColor } from '~/components/ui/tone'
import { BackendStateCard, FieldRow, FieldText, PageHeader, PrimaryButton, classifyBackend, type BackendState } from '@hanzo/ui/product'
type Async<T> =
| { phase: 'loading' }
@@ -203,7 +200,7 @@ function AffiliateDashboard({
</Text>
) : (
<Text fontSize="$3" color="$color11">
Share your link. Every team that signs up through it earns you {ratePct(data.rateBps)} of their spend, every
Share your link. Every organization that signs up through it earns you {ratePct(data.rateBps)} of their spend, every
period.
</Text>
)}
+43 -32
View File
@@ -14,12 +14,13 @@
* facade (`AgentsApi.metrics`); until that route is bound they show a truthful "not
* connected" note, never a placeholder trend. When the org has ZERO agents (or the
* `/v1/agents` route isn't bound yet) the board is replaced by a polished
* "create your first agent" empty state with the real New-Agent flow — never the
* mockup's sample data.
* "create your first agent" empty state that opens the QUICKSTART — the one way to
* create an agent here — never the mockup's sample data.
*
* Style props use the @hanzo/gui v5 shorthand set (bg/p/px/py/gap/rounded/items/…).
*/
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { Button, Card, Input, Spinner, Text, XStack, YStack } from '@hanzo/gui'
import { useAnalytics } from '@hanzo/event/react'
import { EVENTS } from '@hanzo/event'
@@ -64,10 +65,6 @@ import {
type MetricsRange,
} from '~/lib/api/agents'
import { productColorHex } from '~/lib/products/colors'
import { PageHeader } from '~/components/ui/PageHeader'
import { DataTable, type Column } from '~/components/ui/DataTable'
import { EmptyState } from '~/components/ui/EmptyState'
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
import { LineChart, Sparkline, type ChartPoint } from '~/components/ui/Charts'
import { useDetailPane } from '~/components/DetailPane'
import { MetricCard, Delta } from './functions/parts'
@@ -81,7 +78,10 @@ import {
TopAgents,
VersionBadge,
} from './agents/parts'
import { AgentDetailView, NewAgentForm } from './agents/forms'
import { AgentDetailView } from './agents/forms'
import { agentBuilderLoaders } from './agents/loaders'
import { AgentQuickstart } from '~/components/agent-builder'
import { BackendStateCard, DataTable, EmptyState, PageHeader, classifyBackend, type BackendState, type Column } from '@hanzo/ui/product'
const PAGE_SIZE = 8
const DASH = '—'
@@ -137,6 +137,7 @@ function useAgents() {
}
export function AgentsModule(props: { params: Record<string, string> }) {
const router = useRouter()
const detail = useDetailPane()
const { agents, loading, error, live, activity, reload, setAgents } = useAgents()
@@ -229,26 +230,11 @@ export function AgentsModule(props: { params: Record<string, string> }) {
const analytics = useAnalytics()
const openNew = useCallback(
() =>
detail.open({
title: 'New agent',
subtitle: 'Define a model, prompt, and tools',
icon: Bot,
iconColor: agentColor,
content: (
<NewAgentForm
onCancel={detail.close}
onCreated={() => {
analytics.capture(EVENTS.AGENT_CREATED)
detail.close()
void reload()
}}
/>
),
}),
[detail, agentColor, reload, analytics],
)
// ONE way to create an agent, and it is the quickstart. This used to open the
// builder in a side pane — the same component, reached by a different shape, with
// no templates, no drafting and nowhere to run what it made. Two entrances to one
// builder is two things to keep in step; the pane was the lesser of them.
const openNew = useCallback(() => router.push('/agents/quickstart'), [router])
const header = (
<PageHeader
@@ -282,6 +268,36 @@ export function AgentsModule(props: { params: Record<string, string> }) {
/>
)
// Owned sub-pages: Status/Logs/Metrics render focused slices of the agents' OWN
// runs (from /v1/agents), so they are never the empty generic o11y/ledger subpage.
// Overview ('') shows everything. Metrics = counts + invocation trend + resource;
// Status = health donut + agents table; Logs = the invocation activity feed.
const routeTab = props.params?.tab ?? ''
// ── Quickstart ──────────────────────────────────────────────────────────────
// Its own surface, and it answers BEFORE the list's loading/error/empty states on
// purpose: building an agent does not depend on reading the ones that exist, and
// the moments you most need it — no agents yet, or the registry not answering —
// are exactly the ones those early returns would have swallowed it in.
if (routeTab === 'quickstart') {
return (
<>
<PageHeader
title="Build an agent"
subtitle="Describe what you want, or start from a template. Four steps, and every one is a real call."
/>
<AgentQuickstart
loaders={agentBuilderLoaders}
apiBase={config.apiUrl}
onFinished={() => {
analytics.capture(EVENTS.AGENT_CREATED)
void reload()
}}
/>
</>
)
}
// ── Initial loading ─────────────────────────────────────────────────────────
if (loading && agents.length === 0 && !error) {
return (
@@ -400,11 +416,6 @@ export function AgentsModule(props: { params: Record<string, string> }) {
const tabs: StatusTab[] = ['all', ...AGENT_STATUSES]
const tabCount = (t: StatusTab): number => (t === 'all' ? agents.length : health[t])
// Owned sub-pages: Status/Logs/Metrics render focused slices of the agents' OWN
// runs (from /v1/agents), so they are never the empty generic o11y/ledger subpage.
// Overview ('') shows everything. Metrics = counts + invocation trend + resource;
// Status = health donut + agents table; Logs = the invocation activity feed.
const routeTab = props.params?.tab ?? ''
const showMetrics = routeTab === '' || routeTab === 'metrics'
const showStatus = routeTab === '' || routeTab === 'status'
const showLogs = routeTab === '' || routeTab === 'logs'
+1 -3
View File
@@ -18,10 +18,8 @@ import { Button, Text } from '@hanzo/gui'
import { RefreshCw } from '@hanzogui/lucide-icons-2'
import { restGet, cloudProxyV1Url } from '~/lib/api/client'
import { PageHeader } from '~/components/ui/PageHeader'
import { DataTable, type Column } from '~/components/ui/DataTable'
import { StatusTag } from '~/components/ui/StatusTag'
import { interpretPlatformError, PlatformStateCard, type PlatformError } from './platform/state'
import { DataTable, PageHeader, StatusTag, type Column } from '@hanzo/ui/product'
type Alert = {
id: string
+1 -3
View File
@@ -35,12 +35,10 @@ import {
type Top,
} from '~/lib/api/analytics'
import { fmtUsd, fmtInt } from '~/lib/api/functions'
import { PageHeader } from '~/components/ui/PageHeader'
import { DataTable, type Column } from '~/components/ui/DataTable'
import { LineChart, Donut, type ChartPoint, type Slice } from '~/components/ui/Charts'
import { RAMP } from '~/lib/theme/ramp'
import { classifyBackend, BackendStateCard, type BackendState } from '~/components/ui/BackendState'
import { MetricCard } from './functions/parts'
import { BackendStateCard, DataTable, PageHeader, classifyBackend, type BackendState, type Column } from '@hanzo/ui/product'
const fmtPct = (n: number): string => (Number.isFinite(n) ? `${(n * 100).toFixed(1)}%` : '—')
@@ -21,11 +21,10 @@ import {
type AnnotationQueueItem,
type O11yPageMeta,
} from '~/lib/api'
import { PageHeader } from '~/components/ui/PageHeader'
import { DataTable, type Column } from '~/components/ui/DataTable'
import { RuntimeNotice } from './observability/RuntimeNotice'
import { Pager } from './observability/Pager'
import { fmtDate } from './observability/format'
import { DataTable, PageHeader, type Column } from '@hanzo/ui/product'
const PAGE_LIMIT = 50
+2 -33
View File
@@ -22,8 +22,8 @@ import { EVENTS } from '@hanzo/event'
import { ApiError, KeysApi, type KeyStatus } from '~/lib/api'
import { useSession } from '~/lib/auth/session'
import { config } from '~/config'
import { PageHeader } from '~/components/ui/PageHeader'
import { ErrorState } from '~/components/ui/States'
import { CopyButton, PageHeader, SecretInput } from '@hanzo/ui/product'
// The docs site serves everything under the `/docs` base path (a bare
// `docs.hanzo.ai/<slug>` 404s), so the API reference is `/docs/api` — white-labeled
@@ -37,25 +37,6 @@ function fmtKeyDate(iso?: string): string {
return Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
}
/** Copy-to-clipboard button with a transient confirmed state. */
function CopyButton({ value, label = 'Copy' }: { value: string; label?: string }) {
const [copied, setCopied] = useState(false)
const copy = async () => {
try {
await navigator.clipboard?.writeText(value)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
} catch {
/* clipboard blocked (insecure context) — the value is already visible */
}
}
return (
<Button size="$2" icon={copied ? <Check size={14} /> : <Copy size={14} />} onPress={() => void copy()}>
{copied ? 'Copied' : label}
</Button>
)
}
/** The full secret, shown ONCE right after creation. */
function NewKeyCard({ accessKey, onDone }: { accessKey: string; onDone: () => void }) {
return (
@@ -66,19 +47,7 @@ function NewKeyCard({ accessKey, onDone }: { accessKey: string; onDone: () => vo
Your new API key
</Text>
</XStack>
<XStack gap="$2" items="center" flexWrap="wrap">
<Text
fontSize="$3"
color="$color12"
flex={1}
minW={260}
selectable
style={{ fontFamily: 'monospace' }}
>
{accessKey}
</Text>
<CopyButton value={accessKey} />
</XStack>
<SecretInput value={accessKey} id="api-key" />
<XStack gap="$2" items="center">
<TriangleAlert size={14} color="$yellow10" />
<Text fontSize="$2" color="$color11">
+1 -6
View File
@@ -35,12 +35,7 @@ import { AppWindow, Cpu, ExternalLink, Globe, HardDrive, Pencil, RefreshCw, Rock
import { config } from '~/config'
import { AppsApi, builderEditUrl, type App, type AppDeployment } from '~/lib/api/apps'
import { PlatformSitesApi } from '~/lib/api/platform-sites'
import { PageHeader } from '~/components/ui/PageHeader'
import { DataTable, type Column } from '~/components/ui/DataTable'
import { StatusTag } from '~/components/ui/StatusTag'
import { EmptyState } from '~/components/ui/EmptyState'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
import { BackendStateCard, DataTable, EmptyState, PageHeader, PrimaryButton, StatusTag, classifyBackend, type BackendState, type Column } from '@hanzo/ui/product'
type Async<T> =
| { phase: 'loading' }
@@ -15,10 +15,8 @@ import { Button, Text } from '@hanzo/gui'
import { RefreshCw } from '@hanzogui/lucide-icons-2'
import { restGet } from '~/lib/api/client'
import { PageHeader } from '~/components/ui/PageHeader'
import { DataTable, type Column } from '~/components/ui/DataTable'
import { StatusTag } from '~/components/ui/StatusTag'
import { interpretPlatformError, PlatformStateCard, type PlatformError } from './platform/state'
import { DataTable, PageHeader, StatusTag, type Column } from '@hanzo/ui/product'
const paas = (path: string) => `/paas/${path.replace(/^\/+/, '')}`
@@ -15,15 +15,12 @@ import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
import { BadgeCheck, Ban, Coins, Github, HandCoins, RefreshCw, Wallet, Zap } from '@hanzogui/lucide-icons-2'
import { AdminAuthorsApi, type AdminAuthor, type AdminAuthorsView, type SweepResult } from '~/lib/api/admin-authors'
import { PageHeader } from '~/components/ui/PageHeader'
import { MetricCard } from '~/components/ui/Metric'
import { EmptyState } from '~/components/ui/EmptyState'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import { FieldRow, FieldSelect, FieldText } from '~/components/ui/Field'
import { asApiError, ErrorState, isForbidden, SuperAdminRequired } from '~/components/ui/States'
import { ApiError } from '~/lib/api'
import { dollarsToCents, sharePct, shortDate, statusLabel, statusColor, usd } from './authors/logic'
import { toneColor } from '~/components/ui/tone'
import { EmptyState, FieldRow, FieldSelect, FieldText, PageHeader, PrimaryButton } from '@hanzo/ui/product'
type Async<T> =
| { phase: 'loading' }
+2 -5
View File
@@ -31,13 +31,10 @@ import {
} from '@hanzogui/lucide-icons-2'
import { AuthorsApi, type AuthorOverview } from '~/lib/api/authors'
import { PageHeader } from '~/components/ui/PageHeader'
import { MetricCard } from '~/components/ui/Metric'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import { FieldRow, FieldText } from '~/components/ui/Field'
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
import { payoutMethodLabel, sharePct, shortDate, statusLabel, statusColor, usd, verifyMethodLabel } from './authors/logic'
import { toneColor } from '~/components/ui/tone'
import { BackendStateCard, FieldRow, FieldText, PageHeader, PrimaryButton, classifyBackend, type BackendState } from '@hanzo/ui/product'
type Async<T> =
| { phase: 'loading' }
@@ -122,7 +119,7 @@ function AuthorConnect({ onConnected }: { onConnected: () => void }) {
<Text fontSize="$3" color="$color11">
The OSS Author program pays you an ongoing share of the platform spend of every organization that deploys your
open-source project on Hanzo. Connect GitHub, verify the repositories you own, and earn your share every period
for as long as teams build on your work. New authors are reviewed by our team before earnings begin.
for as long as organizations build on your work. New authors are reviewed by our team before earnings begin.
</Text>
<YStack gap="$1">
<FieldRow label="GitHub login (optional)">
Binary file not shown.
@@ -26,15 +26,8 @@ import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
import { Blocks, ListChecks, Play, Plus, Power, RefreshCw, Trash2, Workflow } from '@hanzogui/lucide-icons-2'
import { AutomationsApi, type AutomationFlow, type FlowRun, type Piece } from '~/lib/api/automations'
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
import { DataTable, type Column } from '~/components/ui/DataTable'
import { EmptyState } from '~/components/ui/EmptyState'
import { FieldSelect, FieldText } from '~/components/ui/Field'
import { Loader } from '~/components/ui/Loader'
import { PageHeader } from '~/components/ui/PageHeader'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import { SlideOver } from '~/components/ui/SlideOver'
import { StatusTag } from '~/components/ui/StatusTag'
import {
authLabel,
capabilitySummary,
@@ -49,6 +42,7 @@ import {
validateFlowName,
} from './automations/logic'
import { toneColor } from '~/components/ui/tone'
import { BackendStateCard, DataTable, EmptyState, FieldSelect, FieldText, PageHeader, PrimaryButton, StatusTag, classifyBackend, type BackendState, type Column } from '@hanzo/ui/product'
/** Cap the rendered connector cards; search/category narrow the rest (honest note). */
const CATALOG_RENDER_CAP = 240
@@ -14,9 +14,8 @@ import { FlaskConical, RefreshCw } from '@hanzogui/lucide-icons-2'
import { ApiError } from '~/lib/api'
import { EnablementApi, type UserEnablementItem } from '~/lib/api/admin-cockpit'
import { PageHeader } from '~/components/ui/PageHeader'
import { EmptyState } from '~/components/ui/EmptyState'
import { ErrorState, asApiError } from '~/components/ui/States'
import { EmptyState, PageHeader } from '@hanzo/ui/product'
export function BetaFeaturesModule() {
const [view, setView] = useState<{ items: UserEnablementItem[]; betas: UserEnablementItem[] } | null>(null)
+1 -2
View File
@@ -14,8 +14,7 @@ import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
import { RefreshCw } from '@hanzogui/lucide-icons-2'
import { ApiError, BotApi } from '~/lib/api'
import { PageHeader } from '~/components/ui/PageHeader'
import { StatusTag } from '~/components/ui/StatusTag'
import { PageHeader, StatusTag } from '@hanzo/ui/product'
/** Operator surfaces for the bot — public product domains, opened in a tab. */
const SURFACES: ReadonlyArray<{ label: string; href: string; hint: string }> = [
+1 -2
View File
@@ -31,8 +31,7 @@ import { Bot, Cpu, ExternalLink, Monitor, Play, RefreshCw, Square, Terminal } fr
import { ApiError } from '~/lib/api'
import { BotsApi, type BotRunRow, type BotSurface } from '~/lib/api/bots'
import { PageHeader } from '~/components/ui/PageHeader'
import { StatusTag } from '~/components/ui/StatusTag'
import { PageHeader, StatusTag } from '@hanzo/ui/product'
/** Statuses a run can't be stopped from — the Stop button is disabled on these. */
const TERMINAL = new Set([
+1 -3
View File
@@ -13,10 +13,8 @@ import { Button, Text } from '@hanzo/gui'
import { RefreshCw } from '@hanzogui/lucide-icons-2'
import { restGet, cloudProxyV1Url } from '~/lib/api/client'
import { PageHeader } from '~/components/ui/PageHeader'
import { DataTable, type Column } from '~/components/ui/DataTable'
import { StatusTag } from '~/components/ui/StatusTag'
import { interpretPlatformError, PlatformStateCard, type PlatformError } from './platform/state'
import { DataTable, PageHeader, StatusTag, type Column } from '@hanzo/ui/product'
type Build = {
id: string
+2 -8
View File
@@ -54,21 +54,15 @@ import {
type Safe,
type Round,
} from '~/lib/api/captable'
import { PageHeader } from '~/components/ui/PageHeader'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import { DataTable, type Column } from '~/components/ui/DataTable'
import { EmptyState } from '~/components/ui/EmptyState'
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
import { StatusTag } from '~/components/ui/StatusTag'
import { SlideOver } from '~/components/ui/SlideOver'
import { FieldRow, FieldText, FieldSelect, FieldOptionSelect } from '~/components/ui/Field'
import { FieldOptionSelect } from '~/components/ui/Field'
import { MetricCard } from '~/components/ui/Metric'
import { Donut } from '~/components/ui/Charts'
import { RAMP } from '~/lib/theme/ramp'
import { Loader } from '~/components/ui/Loader'
import { useToast } from '~/components/ui/Toast'
import { ConfirmDelete } from '~/components/ui/ConfirmDelete'
import { toneColor } from '~/components/ui/tone'
import { BackendStateCard, ConfirmDelete, DataTable, EmptyState, FieldRow, FieldSelect, FieldText, PageHeader, PrimaryButton, StatusTag, classifyBackend, type BackendState, type Column } from '@hanzo/ui/product'
type Tab = 'summary' | 'stakeholders' | 'shares' | 'classes' | 'fundraising'
const tabPath = (id: Tab): string => (id === 'summary' ? '/captable' : `/captable/${id}`)
+1 -5
View File
@@ -27,12 +27,7 @@ import { Boxes, Check, Pencil, Plus, RefreshCw, Save, Trash2 } from '@hanzogui/l
import { ApiError } from '~/lib/api'
import { CatalogAdminApi, type CatalogEntry, type CatalogEntryInput } from '~/lib/api/catalog-admin'
import { config } from '~/config'
import { PageHeader } from '~/components/ui/PageHeader'
import { DataTable, type Column } from '~/components/ui/DataTable'
import { FieldRow, FieldSelect, FieldSwitch, FieldText, FieldTextArea } from '~/components/ui/Field'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import { SlideOver } from '~/components/ui/SlideOver'
import { ConfirmDelete } from '~/components/ui/ConfirmDelete'
import { ErrorState, asApiError, isForbidden, SuperAdminRequired, type HonestCopy } from '~/components/ui/States'
import { MetadataEditor } from './pricing/MetadataEditor'
import {
@@ -49,6 +44,7 @@ import {
specSummary,
} from './catalog/logic'
import { toneVar } from '~/components/ui/tone'
import { ConfirmDelete, DataTable, FieldRow, FieldSelect, FieldSwitch, FieldText, FieldTextArea, PageHeader, PrimaryButton, type Column } from '@hanzo/ui/product'
const CATALOG_COPY: HonestCopy = {
notFound:

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