Compare commits

...
57 Commits
Author SHA1 Message Date
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 AIandzooqueen a7a7fc9ef6 console: the cross-app launcher it never had
Console had no way to reach another Hanzo surface. The two "switchers" in its
topbar are about where you are INSIDE console — ContextSwitcher picks the org
and project, ScopeSwitcher picks the network — and the LayoutGrid in the sidebar
lists console's own products. A fullscreen launcher existed once and was removed
as a duplicate of that product filter, which it was; nothing replaced the part
it was actually for.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

(cherry picked from commit c9a4d92554fa8b8274801218a2c14a0044cf17bf)

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

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

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

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

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

tsc --noEmit: exit 0, 0 errors.

(cherry picked from commit e9524b5c4588ca6664ca23c341863faadaa48141)

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

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

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

(cherry picked from commit 781db541e8476cb7e0bf1800c55f86f6d9162b50)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  tsc --noEmit  exit 0

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-28 10:58:12 -07:00
hanzo-dev b55eb201d6 WIP on main: 9d6190a78 merge: console enso leaderboard + reported-vs-measured source toggle 2026-07-21 15:05:19 -07:00
hanzo-dev e7b36af216 index on main: 9d6190a78 merge: console enso leaderboard + reported-vs-measured source toggle 2026-07-21 15:05:19 -07:00
hanzo-dev 9d6190a783 merge: console enso leaderboard + reported-vs-measured source toggle 2026-07-21 15:04:25 -07:00
190 changed files with 20109 additions and 8378 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.
+137 -5
View File
@@ -1,7 +1,7 @@
# console2 — Hanzo Cloud Console
Unified admin console for **Hanzo Cloud** and all cloud products. Our code,
BSD-3-Clause, built on **@hanzo/gui** (the Tamagui-based cross-platform UI).
`MIT OR Apache-2.0` (HIP-0137), built on **@hanzo/gui** (the Tamagui-based cross-platform UI).
NOT an observability-console fork, NOT casibase — it is a clean client over the unified `/v1`
backend (`hanzoai/cloud`), reached at the ONE Hanzo API endpoint https://api.hanzo.ai/v1/*.
@@ -92,7 +92,8 @@ keyed by `owner/modelName`, so modelName is form-entered, not generated).
One `request()` in `lib/api/client.ts`: always `credentials: 'include'` (the
backend sets a session cookie at `/v1/signin`), forwards `Accept-Language`,
unwraps the casibase `{ status, msg, data, data2 }` envelope, throws typed
unwraps the casibase `{ status, msg, data, total }` envelope (named `total`
first, legacy `data2` count accepted until the emitters finish renaming), throws typed
`ApiError` (401/403 carry status). Base URL = `config.cloudUrl` (default
`https://cloud.hanzo.ai`, override `NEXT_PUBLIC_CLOUD_URL`).
@@ -325,14 +326,14 @@ keep all credentials server-side (the browser only ever sends its session cookie
embeddings|rerank` (not a general tunnel). `playground.ts` now points at this
proxy (`<origin>/ai`), so Models/Playground/Chat/cmd+K all work with no key in
the browser and no rotation on a chat turn.
- **`app/keys/route.ts`** — per-user `hk-` Cloud API key. POST mint/rotate, DELETE
- **`app/keys/route.ts`** — per-user `sk-` Cloud API key. POST mint/rotate, DELETE
revoke, GET status (no secret). Same app-on-behalf pattern via
`/v1/iam/mint-user-keys` + `/v1/iam/revoke-user-keys`. The `hk-` secret is shown
`/v1/iam/mint-user-keys` + `/v1/iam/revoke-user-keys`. The `sk-` secret is shown
ONCE (POST). `ApiKeysModule` is now create/copy/rotate/revoke.
- Shared trust boundary: `src/lib/server/identity.ts` (server-only) — `resolveUser`
+ `mintUserKey`/`revokeUserKey`/`issueUserToken`. The `hanzo-console` client is
allow-listed in IAM `IAM_KEY_MINT_ALLOWED_APPS`; verified end-to-end that a
minted `hk-` key and an issued user JWT both 200 on `api.hanzo.ai/v1/chat/
minted `sk-` key and an issued user JWT both 200 on `api.hanzo.ai/v1/chat/
completions`.
- **Chat is interactive** (`chat/ChatConversation.tsx`): a real multi-turn
conversation over `AiApi.chat` (→ the `/ai` proxy), with a Zen default model,
@@ -3701,3 +3702,134 @@ and I will not type a password; one SuperAdmin session re-running this spec clos
that. Untouched and flagged for the caps pass: `MarketplaceModule`'s "CATEGORIES"
and the palette's own uppercased section labels are `textTransform` sites that
belong to that lane, not this one.
## Billing calls the route names the server actually registers
Commerce dropped the compound prefixes from its billing routes — the `/v1/billing/`
namespace already says "billing", so `billing/payment-methods` stuttered. Both servers
register only the short names, measured against the live edge: `/v1/billing/methods`
401, `/v1/billing/settings` 403, `/v1/billing/alerts` 403, while `payment-methods`,
`payment-config` and `spend-alerts` are all 404. The console never followed. Its card
reads, its card writes and its Square-config read were all addressed at routes that no
longer exist, so a new user could not add a card — the revenue path was broken in
production.
The client had already been repointed for alerts, so `payment-methods` (list, save,
detach) and `payment-config` were the ones still dead. They now build `methods` and
`settings`. There is deliberately no alias and no fallback: one name per concept.
The tests were part of the defect, not the safety net. Every suite around payment
methods stubbed a response body and asserted the normalization, so a client pointed at
a 404 stayed green — the exact reason this survived. The URL is now pinned where the
request is actually made, including the two reads nothing had ever asserted
(`methods`, `settings`) and `alerts` beside them. Reverting any of the four short names
turns the suite red, which was checked rather than assumed.
`POST /v1/billing/me/welcome` and everything feeding it is deleted, not repointed.
Commerce removed that route on purpose: it was a self-service mint, a browser could
grant its own org $5, and commerce's own `api/billing/mint_gates_test.go` names it the
TOCTOU double-mint. Credit is minted only through the mint-gated `POST
/v1/billing/credit`. The call had been failing silently, so restoring it would have
re-opened a closed money hole to fix nothing. The trial credit still lands — commerce
grants it server-side when a card is vaulted, and signup grants it server-side — and
that path is untouched. `src/lib/billing/welcome.ts` had no callers left at all.
Scope, checked rather than assumed: `/v1/finance/payment-methods` is still 401 (alive)
and `/v1/finance/methods` is 404, so the finance ledger keeps the compound name — a
blanket repo-wide rename would have broken it. The Billing Center's tab slugs
(`/billing/payment-methods`, `/billing/credits`) are console page URLs, not server
routes, and are unchanged.
Two headlines were lying about which layer failed. "Card top-up isn't available on this
deployment yet" and "Adding a card isn't available on this deployment yet" both fire
when the ORG has no Square `applicationId`/`locationId` — a per-organization
configuration, not a property of the deployment. Both now name the organization, as
does the onboarding step's "Payments aren't set up", which had the same defect. The
stale `GET /v1/billing/payment-config` endpoint hints under those cards now read
`settings`.
## The assistant has one home, and the app directory is one you can walk
Three fixes to the console's own chrome. Each root cause was measured in a browser
on computed style, geometry or where the browser lands — never inferred from source.
**The assistant lived in a third place.** `FloatingChat` owned every shape the
assistant can take (the sheet, the docked column, the dock state) except the way
in, which was two small buttons in the TOPBAR — a brand-H "Chat with Hanzo" and a
"Talk to Hanzo" mic — wedged between the search box and the org/theme/alert
cluster. On a 390px phone that put five controls in the header and squeezed the
search field to "Search or jump…". Both controls moved into `AssistantFab`, one
floating cluster fixed bottom-right, in the corner the assistant actually appears
in. Chat and voice are the same surface opened two ways, so they sit together.
Nothing about the assistant was rewritten: the FAB calls the same `openChat` /
`startVoice` the topbar called, and `open`/`toggle`/`ask` still drive it
programmatically (the Code hub's "Ask AI").
It is suppressed exactly where the assistant is already on screen — while the
sheet is open, on the pages that ARE a composer, and, at `lg+` only, while it is
docked as a column. That last half is a CSS media prop rather than a JS branch so
SSR and first paint agree, and it sits above the Developers dock (whose collapsed
bar is 44px and exists only at `lg+`).
**[BUG, measured] All products was a directory you could not walk.** The pane that
lists every Hanzo app — the sidebar's "All products", the one place the whole
catalog is browsable — rendered each app as an inert `XStack`: a plain `DIV` with
`role=null` and `cursor: auto`, no handler, no pointer affordance. Measured, not
read. The only live control in the row was the pin, so a user could curate the
sidebar but could not open anything from the list. The row now opens its app
through the shared `openProduct` — the ONE opener the sidebar, ⌘K and the category
pages already route through — and closes the pane behind it, because a directory is
not a destination. Pin stays a separate control on the same row and stops the press
from bubbling: curating never navigates, navigating never curates.
**[BUG] A pin made after sign-in was thrown away on the next reload.** Preferences
are read off `properties['hanzo.preferences']` in the IAM access token's claims — a
SNAPSHOT taken when that token was minted. An earlier lane fixed the case where the
snapshot is SILENT about a key. The other half was never closed: once a user has
saved anything, the next token CARRIES a snapshot, and the merge let it win over a
newer local write. So the second pin onward read as pinned and was gone after F5.
The merge is now told the ordering it was missing. `Account` carries the token's
own `iat`; the provider stamps `…prefs.<user>.writtenAt` when — and only when — the
SERVER acknowledges a write; `mergePrefs(cached, fromAccount, order)` lets the cache
win only when a confirmed write is newer than the snapshot. Last writer wins, and
both writers are now identifiable. A fresh device (no cache, no stamp) and a fresh
sign-in (token minted after the write) both still take the account wholesale, so
cross-device is preserved. Stamping only server-confirmed writes is what keeps this
from being localStorage impersonating a backend: a save that never landed earns
nothing and the account stays authoritative.
**Backend gap, named rather than papered over.** There is no READ for this
document. `PATCH /v1/ai/preferences` (hanzoai/ai `UpdatePreferences`) writes it to
the IAM user's `properties['hanzo.preferences']` and returns the merged result;
nothing serves a GET, so the token's snapshot is the only read the console has. The
smallest seam that removes the ordering problem entirely is `GET
/v1/ai/preferences` returning that property after the handler's existing
`refreshSessionUser` — the write path already does every part of it. Better still
is `GET/PATCH /v1/prefs` (hanzoai/cloud `apps/prefs`), the canonical cross-surface
plane, which answers 503 on api.hanzo.ai today.
**House rule: one filled CTA.** Counted by computed background luminance on the
rendered home, not by reading JSX: FIVE white-filled buttons competed — "Take the
tour", the getting-started card's active step, and all three `PrimaryActionTile`
CTAs (two of them saying "Get API key"). The same measurement now returns ONE: the
checklist's ACTIVE step, the thing to do next. The tour is a neutral aside beside its
dismiss, and the three tiles are neutral because they are PEERS — a menu of things
you can do, not a call to action, and three primaries are none.
**Verification.** `tsc --noEmit` clean; `vitest` 3175 passed / 8 skipped (256 files,
+6 ordering tests). RED→GREEN proven both ways: disabling `cacheIsNewer` turns the
two new ordering tests red and the browser test with it. `e2e/assistant-fab-and-apps.spec.ts`
(4 tests, 1440 and 390) asserts the control's BOX is in the bottom-right quadrant and
≥44px, that `.hz-topbar` carries no assistant control, that clicking an app in All
products LANDS on `/agents`, and that a pin survives a reload under a token whose
snapshot is an hour old. `e2e/chrome-brand-voice.spec.ts` was retargeted, not
deleted — every claim it made still holds, only the location moved.
Two spec gotchas worth keeping. `_session.ts`'s `b64` emitted plain base64; that is
fine while a forged payload is tiny, but `+`/`/` appear as soon as one grows (a
`properties` bag is enough) and a strict decoder rejects the token outright — the SDK
reports signed out and the app sits on its loader forever. It emits base64URL now,
which is what a JWT segment actually is. And the assistant's composer carries its own
mic with the same `Talk to Hanzo` label, mounted-but-hidden until the panel opens, so
a bare attribute locator matches that one first: scope to `getByTestId('assistant-fab')`.
+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`).
+8 -3
View File
@@ -22,7 +22,6 @@ 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'
@@ -148,13 +147,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>
)
+4 -4
View File
@@ -95,7 +95,7 @@ export async function GET(req: NextRequest, ctx: Ctx) {
/**
* POST — the GLOBAL-admin mutations that ride the same god-view gate
* (`/v1/admin/providers/{toggle,primary}`, `/v1/admin/spend-caps` create). Identical
* (`/v1/admin/providers/{toggle,primary}`, `/v1/admin/caps` create). Identical
* path through `getAdminGate` (fail-closed 403) → `forwardWithUserBearer`, which applies
* the same-origin CSRF check to this mutating method BEFORE resolving the user, streams
* the JSON body through, and re-validates the path against `allowAdminSurface` (so a POST
@@ -116,16 +116,16 @@ export async function PUT(req: NextRequest, ctx: Ctx) {
}
/**
* PATCH — the GLOBAL-admin partial edits (`PATCH /v1/admin/spend-caps/:id?org=<slug>`,
* PATCH — the GLOBAL-admin partial edits (`PATCH /v1/admin/caps/:id?org=<slug>`,
* override an org's usage cap). Same gate + same CSRF/traversal hardening; the `:id`
* sub-path passes because `allowAdminSurface` admits `v1/admin/spend-caps[/...]`.
* sub-path passes because `allowAdminSurface` admits `v1/admin/caps[/...]`.
*/
export async function PATCH(req: NextRequest, ctx: Ctx) {
return handle(req, ctx)
}
/**
* DELETE — the GLOBAL-admin removals (`DELETE /v1/admin/spend-caps/:id?org=<slug>`,
* DELETE — the GLOBAL-admin removals (`DELETE /v1/admin/caps/:id?org=<slug>`,
* remove an org's usage cap). Same gate + CSRF/traversal hardening as the other
* mutating verbs; only an allow-listed head/sub-path is ever reached.
*/
+1 -1
View File
@@ -3,7 +3,7 @@
*
* `/v1/chat/completions` (and friends) REQUIRE an `Authorization: Bearer` token; a
* browser session cookie alone is rejected. Rather than ship the user's durable
* `hk-` key to the browser, the console calls its OWN origin at the canonical, prefix-free
* `sk-` key to the browser, the console calls its OWN origin at the canonical, prefix-free
* `/v1/<aihead>` (the /v1-first law); `next.config.mjs` dispatches those heads to THIS `/ai`
* proxy (re-rooting the upstream at `v1/` — invisible to the client). `forwardWithUserBearer`
* resolves the user, mints a SHORT-LIVED, user-bound IAM token (shared per-user cache in
-125
View File
@@ -1,125 +0,0 @@
/* Hanzo is monochrome. One hue rendered through an opacity ladder.
Base ladder = Tailwind neutral (tailwind.config.ts). Semantic names match
hanzo.ai's CSS variables exactly, so code copies over 1:1.
DARK IS THE DEFAULT THEME (hanzo.ai mounts ThemeProvider defaultTheme="dark"). */
:root{
/* ——— base neutral ladder ——— */
--neutral-50:#FAFAFA;
--neutral-100:#F5F5F5;
--neutral-200:#E5E5E5;
--neutral-300:#D4D4D4;
--neutral-400:#A3A3A3;
--neutral-500:#737373;
--neutral-600:#525252;
--neutral-700:#404040;
--neutral-800:#262626;
--neutral-900:#171717;
--neutral-950:#0A0A0A;
--pure-black:#000000;
--pure-white:#FFFFFF;
/* Press-kit brand constants (public/press/hanzo/README.md). */
--hanzo-black:#0A0A0B;
--hanzo-white:#FFFFFF;
/* ——— the opacity ladder: the real palette ——— */
--white-05:rgb(255 255 255 / .05);
--white-10:rgb(255 255 255 / .10);
--white-15:rgb(255 255 255 / .15);
--white-20:rgb(255 255 255 / .20);
--white-30:rgb(255 255 255 / .30);
--white-40:rgb(255 255 255 / .40);
--white-60:rgb(255 255 255 / .60);
--white-80:rgb(255 255 255 / .80);
/* ——— semantic aliases (dark, the default) ——— */
--background:#000000;
--foreground:#ededed;
--card:#0a0a0a;
--card-foreground:#f5f5f5;
--popover:#0a0a0a;
--popover-foreground:#f5f5f5;
--primary:#ffffff;
--primary-foreground:#000000;
--secondary:#1a1a1a;
--secondary-foreground:#f5f5f5;
--muted:#101010;
--muted-foreground:#888888;
--accent:#1a1a1a;
--accent-foreground:#f5f5f5;
--destructive:#666666;
--destructive-foreground:#f5f5f5;
--border:#1f1f1f;
--input:#1f1f1f;
--ring:#333333;
--brand:#e4e4e7;
--brand-foreground:#09090b;
--brand-muted:#a3a3a3;
--black:#000000;
--white:#f5f5f5;
/* ——— surface recipes (card fills used across hanzo.ai) ——— */
--surface-page:var(--background);
--surface-card:rgb(23 23 23 / .5); /* bg-neutral-900/50 — grid tiles */
--surface-card-emphasis:rgb(23 23 23 / .8);/* bg-neutral-900/80 — featured */
--surface-card-quiet:rgb(23 23 23 / .4); /* bg-neutral-900/40 — story cards */
--surface-overlay:rgb(10 10 10 / .95); /* dropdown / popover panels */
--surface-header:rgb(0 0 0 / .7); /* fixed nav, with backdrop blur */
--border-hairline:var(--neutral-800);
--border-card:var(--white-10);
--border-strong:var(--neutral-700);
/* ——— text ranks ——— */
--text-primary:var(--pure-white);
--text-secondary:var(--white-80);
--text-tertiary:var(--white-60);
--text-helper:var(--muted-foreground);
--text-disabled:var(--white-30);
/* ——— the ONLY permitted hues (DESIGN.md §2.4) ——— */
--state-error:#ef4444; /* red-500 — destructive / blocking error */
--state-error-text:#fca5a5; /* red-300 */
--state-error-bg:rgb(239 68 68 / .1);
--state-online:#4ade80; /* green-400 — live status dot */
--state-success:#22c55e; /* green-500 — "Free" / "Save N%" callouts */
--chrome-dot-red:rgb(239 68 68 / .6);
--chrome-dot-yellow:rgb(234 179 8 / .6);
--chrome-dot-green:rgb(34 197 94 / .6);
}
/* Light theme — the same tokens, inverted. Rare: only /brand-style docs pages. */
.light{
--background:#ffffff;
--foreground:#0a0a0a;
--card:#f5f5f5;
--card-foreground:#0a0a0a;
--popover:#ffffff;
--popover-foreground:#0a0a0a;
--primary:#0a0a0a;
--primary-foreground:#ffffff;
--secondary:#f5f5f5;
--secondary-foreground:#0a0a0a;
--muted:#f5f5f5;
--muted-foreground:#525252;
--accent:#f5f5f5;
--accent-foreground:#0a0a0a;
--destructive:#999999;
--destructive-foreground:#ffffff;
--border:#e5e5e5;
--input:#e5e5e5;
--ring:#d4d4d4;
--black:#0a0a0a;
--white:#ffffff;
--surface-card:#f5f5f5;
--surface-card-emphasis:#ffffff;
--surface-card-quiet:#fafafa;
--surface-overlay:rgb(255 255 255 / .95);
--surface-header:rgb(255 255 255 / .8);
--border-hairline:var(--neutral-200);
--border-card:rgb(0 0 0 / .1);
--border-strong:var(--neutral-300);
--text-primary:var(--neutral-950);
--text-secondary:rgb(10 10 10 / .8);
--text-tertiary:rgb(10 10 10 / .6);
--text-disabled:rgb(10 10 10 / .3);
}
-19
View File
@@ -1,19 +0,0 @@
/* Hanzo barely uses shadow: on black, elevation reads as a hairline border plus
a wide, very dark drop. Only two levels ship (Tailwind's shadow-2xl for
floating surfaces) plus the ambient radial glow used behind heroes. */
:root{
--shadow-none:none;
--shadow-floating:0 25px 50px -12px rgb(0 0 0 / .25); /* shadow-2xl: composer, dropdowns, mega panel */
--shadow-inset-hairline:inset 0 0 0 1px var(--white-10);
--ring-focus:0 0 0 2px var(--ring);
/* Ambient hero glow — a single white radial, blurred 120px, low opacity. */
--glow-hero:radial-gradient(circle,rgb(255 255 255 / .12) 0%,transparent 68%); /* @kind color */
--glow-hero-blur:120px;
/* Card top-corner sheen used on the story cards. */
--sheen-card:radial-gradient(120% 120% at 80% 0%,rgb(255 255 255 / .08) 0%,transparent 55%); /* @kind color */
/* Chrome text: the canonical headline gradient. Never a saturated rainbow. */
--gradient-chrome:linear-gradient(to right,#ffffff,var(--white-80),var(--white-60));
--gradient-chrome-2:linear-gradient(to right,#ffffff,var(--neutral-500));
/* Section-top protection gradient (hero overlays). */
--gradient-protect:linear-gradient(to bottom,var(--white-10),transparent);
}
+19 -21
View File
@@ -1,30 +1,28 @@
/* ─────────────────────────────────────────────────────────────────────────────
Hanzo Design System tokens — VENDORED from hanzoai/design (@hanzo/design).
Hanzo Design System tokens — the PUBLISHED @hanzo/design package.
This directory is the canonical Hanzo design language expressed as CSS custom
properties: the monochrome neutral ladder + opacity ladder, the ONLY permitted
semantic hues (live/success/warning/error), type scale, 4px spacing ramp,
radius, elevation and motion. It is the SINGLE SOURCE OF TRUTH for how every
Hanzo surface looks.
These were vendored under app/design/ (synced 2026-07-24) only because the
package was not yet on npm. It is now (@hanzo/design ≥ 0.4.6), so the console
reads the real dependency and can no longer drift a border rework behind the
rest of the fleet. The token subpaths are named one by one rather than pulling
`@hanzo/design/styles.css`: that entry chains relative `@import url(...)`s that
Next's CSS pipeline resolves as modules, not sibling files, so the explicit
published subpaths are the resolvable form of the same import.
@hanzo/design is not yet published to npm, so its token layer is vendored here
verbatim (the README's stated contract: "code copies over 1:1"). Do NOT edit
these files in the console — edit them in hanzoai/design and re-sync. Synced
from hanzoai/design tokens/ on 2026-07-24.
The console's Tamagui theme (app/globals.css) DERIVES its --colorN ladder and
borders from this neutral ladder, so the whole product reads from one palette.
Fonts are deliberately NOT imported from the package: the console loads the
Geist faces via app/fonts.css, and the `:root` shim below lets the vendored
typography roles resolve without a second copy.
───────────────────────────────────────────────────────────────────────────── */
@import './colors.css';
@import './typography.css';
@import './spacing.css';
@import './radius.css';
@import './elevation.css';
@import './motion.css';
@import './z.css';
@import '@hanzo/design/tokens/colors.css';
@import '@hanzo/design/tokens/typography.css';
@import '@hanzo/design/tokens/spacing.css';
@import '@hanzo/design/tokens/radius.css';
@import '@hanzo/design/tokens/elevation.css';
@import '@hanzo/design/tokens/motion.css';
@import '@hanzo/design/tokens/z.css';
/* Font families — the console loads the Geist faces via app/fonts.css; these vars
let the vendored typography roles (--type-*) resolve without re-importing fonts. */
let the typography roles (--type-*) resolve without re-importing fonts. */
:root {
--font-sans: 'Geist', 'Geist Sans', ui-sans-serif, system-ui, sans-serif;
--font-display: var(--font-sans);
-24
View File
@@ -1,24 +0,0 @@
/* Motion is restrained: fade + small rise, CSS-only hovers, one breathing glow.
No springs, no bounce, no parallax, no autoplay carousels. */
:root{
--duration-fast:150ms; /* @kind other */ /* dropdown / panel open */
--duration-base:300ms; /* @kind other */ /* slide-up-fade */
--duration-slow:400ms; /* @kind other */ /* hero element entry */
--duration-slower:500ms; /* @kind other */ /* section entry */
--duration-glow:9s; /* @kind other */ /* ambient radial breathe */
--ease-out:cubic-bezier(0,0,0.2,1); /* @kind other */
--ease-in-out:cubic-bezier(0.4,0,0.2,1); /* @kind other */
--stagger:60ms; /* @kind other */ /* per-element delay in a group */
--entry-rise:16px; /* hero y-offset */
--entry-rise-lg:24px; /* card y-offset */
}
@keyframes hanzo-fade-up{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}
@keyframes hanzo-fade-down{from{opacity:0;transform:translateY(-10px)}to{opacity:1;transform:translateY(0)}}
@keyframes hanzo-slide-up-fade{from{opacity:0;transform:translateY(20px)}to{opacity:1;transform:translateY(0)}}
@keyframes hanzo-glow{0%,100%{transform:scale(1);opacity:.45}50%{transform:scale(1.08);opacity:.65}}
@keyframes hanzo-pulse-dot{0%,100%{opacity:1}50%{opacity:.35}}
@media (prefers-reduced-motion:reduce){
*,*::before,*::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important}
}
-10
View File
@@ -1,10 +0,0 @@
:root{
--radius:0.5rem; /* the base token (globals.css) */
--radius-sm:0.375rem; /* rounded-md — buttons, inputs */
--radius-md:0.5rem;
--radius-lg:0.75rem; /* rounded-xl — cards */
--radius-xl:1rem; /* rounded-2xl — dropdown panels */
--radius-2xl:1.5rem; /* rounded-3xl — story / hero cards */
--radius-composer:28px; /* the chat composer, exactly 28px */
--radius-full:9999px; /* pills, CTAs, avatars, badges */
}
-45
View File
@@ -1,45 +0,0 @@
/* Spacing: the 4px Tailwind ramp is what ships. The golden-ratio ramp below is
declared in hanzo.ai's tailwind.config.ts (legacy v3 config, kept for
reference) — use it for editorial layouts, not for component padding. */
:root{
--space-0:0;
--space-1:0.25rem;
--space-2:0.5rem;
--space-3:0.75rem;
--space-4:1rem;
--space-5:1.25rem;
--space-6:1.5rem;
--space-8:2rem;
--space-10:2.5rem;
--space-12:3rem;
--space-14:3.5rem;
--space-16:4rem;
--space-20:5rem;
--space-24:6rem;
--space-32:8rem;
/* golden ramp (φ) — hanzo.ai tailwind.config.ts */
--golden-1:0.25rem;
--golden-2:0.405rem;
--golden-3:0.654rem;
--golden-4:1.059rem;
--golden-5:1.713rem;
--golden-6:2.772rem;
--golden-7:4.487rem;
--golden-8:7.26rem;
--golden-9:11.749rem;
--golden-split:38.2% 61.8%; /* @kind other */
/* layout rules (DESIGN.md §1.3) */
--container-max:80rem; /* max-w-7xl — grids */
--container-prose:48rem; /* max-w-3xl — centered text */
--container-wide:72rem; /* max-w-6xl — landing sections */
--gutter:1rem; /* px-4 */
--gutter-sm:1.5rem; /* sm:px-6 */
--gutter-lg:2rem; /* lg:px-8 */
--section-y:4rem; /* py-16 — content sections */
--section-y-lg:6rem; /* py-24 — landing sections */
--hero-y:5rem; /* py-20 … */
--hero-y-lg:8rem; /* … lg:py-32 */
--header-height:4rem;
}
-46
View File
@@ -1,46 +0,0 @@
/* TIGHT app-first type scale — the compact developer-app register (linear.app /
vercel.com / the Codex desktop look), the Hanzo default across chat / app /
desktop. Base is 14px, nav 13px, labels 11px; display sizes tightened. Kept in
lockstep with @hanzo/brand (styles/variables.css --font-size-* + typography.ts)
— the two are the SAME scale, mirrored. A surface/tenant overrides any --text-*
on :root to retune density on demand. */
:root{
--text-xs:0.6875rem; --leading-xs:1rem; /* 11px — eyebrows / section labels */
--text-sm:0.8125rem; --leading-sm:1.15rem; /* 13px — nav labels, dense body */
--text-base:0.875rem; --leading-base:1.35rem; /* 14px — base app text (was 16px) */
--text-lg:0.9375rem; --leading-lg:1.4rem; /* 15px */
--text-xl:1.0625rem; --leading-xl:1.55rem; /* 17px */
--text-2xl:1.3125rem; --leading-2xl:1.7rem; /* 21px */
--text-3xl:1.625rem; --leading-3xl:1.95rem; /* 26px */
--text-4xl:2rem; --leading-4xl:2.25rem; /* 32px */
--text-5xl:2.5rem; --leading-5xl:1.05; /* 40px */
--text-6xl:3.25rem; --leading-6xl:1; /* 52px */
--text-7xl:4rem; --leading-7xl:1; /* 64px */
--weight-normal:400;
--weight-medium:500;
--weight-semibold:600;
--weight-bold:700;
--tracking-tight:-0.025em;
--tracking-normal:0em;
--tracking-wide:0.025em;
--tracking-widest:0.1em; /* eyebrows / uppercase category labels */
--leading-none:1;
--leading-tight:1.25;
--leading-snug:1.375;
--leading-normal:1.5;
--leading-relaxed:1.625;
--leading-golden:1.618;
/* named roles */
--type-hero:600 var(--text-5xl)/1.05 var(--font-display);
--type-h2:700 var(--text-4xl)/var(--leading-4xl) var(--font-display);
--type-h3:600 var(--text-xl)/var(--leading-xl) var(--font-display);
--type-lead:400 var(--text-lg)/var(--leading-relaxed) var(--font-sans);
--type-body:400 var(--text-sm)/var(--leading-sm) var(--font-sans);
--type-caption:400 var(--text-xs)/var(--leading-xs) var(--font-sans);
--type-code:400 var(--text-sm)/var(--leading-relaxed) var(--font-mono);
--type-eyebrow:600 0.625rem/1 var(--font-sans);
}
-14
View File
@@ -1,14 +0,0 @@
/* Stacking order — the one z-index ladder. Layers are named by role, never by a
magic number, so a dropdown opened from the fixed header always sits above it
and nothing ever reaches for 9999. Below --z-raised is ordinary document flow. */
:root{
--z-base:0;
--z-raised:10; /* hover-lifted cards, sticky table headers */
--z-sticky:200; /* pinned section rails */
--z-header:300; /* the fixed site header */
--z-dropdown:400; /* menus, selects, comboboxes */
--z-overlay:500; /* dialog / sheet scrim */
--z-modal:600; /* dialogs, sheets, command palette */
--z-popover:700; /* popovers, tooltips (also when anchored in modals)*/
--z-toast:800; /* toasts / notifications — always on top */
}
+5 -5
View File
@@ -1,5 +1,5 @@
/**
* Per-user `hk-` Cloud API key — the SAME-ORIGIN console route (the fix for the
* Per-user `sk-` Cloud API key — the SAME-ORIGIN console route (the fix for the
* API-keys "sign in to manage API keys" / CORS crack).
*
* The browser calls this OWN-origin route (`/keys`) with just its first-party
@@ -7,19 +7,19 @@
* (`resolveUser`) and mints/reads/revokes the key through IAM as the confidential
* `hanzo-console` client (`identity.ts` `mintUserKey`/`getUserKey`/`revokeUserKey`,
* over IAM `mint-user-keys`/`get-user`/`revoke-user-keys` — the WORKING key path,
* verified live). No credential ever reaches the browser; the `hk-` secret is
* verified live). No credential ever reaches the browser; the `sk-` secret is
* returned ONLY by POST (show once).
*
* Why not `cloud.hanzo.ai/v1/iam/keys` (the old path): that is a DIFFERENT
* ORIGIN than console.hanzo.ai, so a browser `fetch` is blocked by CORS ("Failed to
* fetch") — and cloud-api's own keys handler 501s ("IAM client unset") on this
* deployment anyway. The IAM confidential-client mint the console already uses for
* `hk-` keys elsewhere (`app/ai` chat) is the ONE authoritative, same-origin,
* `sk-` keys elsewhere (`app/ai` chat) is the ONE authoritative, same-origin,
* always-working path — so the Org-Settings API-keys surface uses it too (DRY: the
* exact primitives from `identity.ts`, no new IAM plumbing).
*
* GET → { hasKey, keyPrefix, createdAt } (no secret)
* POST → { accessKey } (mint/rotate; full hk- shown ONCE)
* POST → { accessKey } (mint/rotate; full sk- shown ONCE)
* DELETE → { ok: true } (revoke; the old key stops working)
*/
import { type NextRequest, NextResponse } from 'next/server'
@@ -57,7 +57,7 @@ export async function GET(req: NextRequest) {
}
}
/** POST — mint (or rotate) the key. Returns the full `hk-` secret ONCE. */
/** POST — mint (or rotate) the key. Returns the full `sk-` secret ONCE. */
export async function POST(req: NextRequest) {
// CSRF: minting mutates (and is billable-adjacent) from the auto-sent cookie —
// refuse a cross-origin request before any work.
+11 -16
View File
@@ -1,6 +1,6 @@
/**
* Same-origin proxy to the cloud ML/training surface on hanzoai/ai (`/v1/train/*`,
* `/v1/ml/models`, and the fine-tuning broker `/v1/finetune/*`).
* Same-origin proxy to the cloud ML/training surface (`/v1/ml/models` and the
* fine-tuning broker `/v1/finetune/*`).
*
* The console's Training page calls its OWN origin (`/training/...`) with just the
* first-party session cookie; this server handler resolves the signed-in user from
@@ -11,16 +11,16 @@
* this is user-scoped (resolveUser), NOT the control-plane admin gate the `/paas`
* proxy uses. The cloud backend resolves the org from the token's `owner` claim (and
* the X-Org-Id the plain-REST train sub-service reads), so a caller can only ever
* touch their own org's jobs. `POST /v1/train/jobs` is billing-gated by the live
* ResourceMeter and returns 402 on an unfunded org — that status flows straight back
* so the UI can surface it honestly.
* touch their own org's jobs. `POST /v1/finetune/jobs` is billing-gated upstream and
* returns 402 on an unfunded org — that status flows straight back so the UI can
* surface it honestly.
*
* Why a Bearer and NOT the cookie (the fix for the "Not enabled" 403): cloud-api's
* `/v1/train/*` authorizes on a VALIDATED JWT principal and returns 403 "no validated
* `/v1/*` authorizes on a VALIDATED JWT principal and returns 403 "no validated
* principal" for a cookie-only call — the raw casibase session cookie is NOT a
* principal it accepts (only the sanitizer's cookie-token names or a Bearer). Minting
* the same user-bound token the `/v1` proxy uses is the ONE way a signed-in tenant
* reaches the train surface; the cookie is deliberately dropped upstream (it can't
* reaches this surface; the cookie is deliberately dropped upstream (it can't
* authenticate, and a cookie + JWT together risks the public-gateway 431).
*
* Least privilege: only the explicit ML/training sub-paths are forwarded; anything
@@ -44,14 +44,9 @@ const CLOUD_API_URL = trim(process.env.CLOUD_API_URL ?? 'http://cloud.hanzo.svc.
/** The exact `/v1/<...>` ML/training sub-paths the console is allowed to reach. */
const ALLOWED = new Set([
// mlsvc — the canonical training surface (task #40 ResourceMeter gates POST jobs).
'train/jobs',
'train/experiments',
// Model serving — the org's deployed kserve InferenceServices.
'ml/models',
// Real Kubeflow control-plane probe (which operators/CRDs are actually served).
// Read-only; 503 + body flows through so the UI can report a degraded plane.
'train/health',
// fine-tuning broker (custom-data runs, HF search) — sibling surface.
// fine-tuning broker (custom-data runs, HF search) — the ONE training door.
'finetune/jobs',
'finetune/job',
'finetune/cancel',
@@ -68,7 +63,7 @@ async function forward(req: NextRequest, path: string[]): Promise<NextResponse>
return NextResponse.json({ status: 'error', msg: 'Not found' }, { status: 404 })
}
// CSRF: `POST /train/jobs` mutates (and bills) from the auto-sent cookie — refuse a
// CSRF: `POST /finetune/jobs` mutates (and bills) from the auto-sent cookie — refuse a
// cross-origin one before any work (safe reads pass).
const csrf = csrfRefusal(req, 'casibase')
if (csrf) return csrf
@@ -82,7 +77,7 @@ async function forward(req: NextRequest, path: string[]): Promise<NextResponse>
}
// Mint a short-lived, user-bound Bearer (the SAME per-user cache the `/v1`
// proxy uses). cloud-api's `/v1/train/*` 403s a cookie-only call ("no validated
// proxy uses). cloud-api's `/v1/*` 403s a cookie-only call ("no validated
// principal"); a Bearer is the one credential it accepts. Fail CLOSED with 502 if
// the token can't be minted — never fall through to an unauthenticated forward.
let bearer: string
+2 -2
View File
@@ -10,8 +10,8 @@
* billing UI tab URLs (`/billing/reports`, `/billing/invoices`, …) — they differ at
* the FIRST path segment, so the tab slugs fall through to the SPA.
*
* Verbs: GET (reads: balance/usage/invoices/subscriptions/payment-methods, and the
* per-invoice PDF), POST (writes: top-up, spend-alerts, save-a-method, cancel/
* Verbs: GET (reads: balance/usage/invoices/subscriptions/methods, and the
* per-invoice PDF), POST (writes: top-up, alerts, save-a-method, cancel/
* reactivate a subscription), PATCH (edit a budget/spend-alert), DELETE (detach a
* saved payment method, remove a budget). Each is scoped to the caller's OWN org
* server-side; a mutating verb is CSRF-guarded (`forwardBilling`).
Vendored
+5
View File
@@ -0,0 +1,5 @@
// Side-effect CSS imports (`import './globals.css'`, `import '@hanzogui/core/reset.css'`).
// The bundler owns them; TypeScript only needs to know the specifier resolves.
// TS7 (tsgo) errors on an unresolvable side-effect import (TS2882) where tsc stayed
// silent, so the declaration lives here — one place, every stylesheet.
declare module '*.css'
+3 -2
View File
@@ -2,8 +2,9 @@
The console talks to the unified Hanzo Cloud backend (`hanzoai/cloud`).
Base URL: `${NEXT_PUBLIC_CLOUD_URL}/v1`. All requests send cookie
credentials; responses are the envelope `{ status, msg, data, data2 }` (`data2`
is the total row count on list endpoints).
credentials; responses are the envelope `{ status, msg, data, total }` (`total`
is the row count on list endpoints; the legacy `data2` count is still accepted
as a fallback until every emitter finishes the rename).
Client modules live in `src/lib/api/`.
+24 -8
View File
@@ -27,12 +27,24 @@ export type SessionClaims = {
email?: string
displayName?: string
isAdmin?: boolean
/** The IAM user's property bag — where `hanzo.preferences` rides as a SNAPSHOT. */
properties?: Record<string, string>
/** When the token was minted (`iat`, seconds). Defaults to now; set it in the past
* to reproduce the production case where the snapshot predates a later write. */
issuedAt?: number
}
const b64 = (o: object): string => Buffer.from(JSON.stringify(o)).toString('base64')
/**
* base64URL — what a JWT segment actually is. Plain base64 was close enough while the
* payloads were tiny, but `+` and `/` appear as soon as one grows (a `properties` bag
* is enough), and a strict decoder rejects the token outright: the SDK reports signed
* out and the app sits on its loader forever.
*/
const b64 = (o: object): string =>
Buffer.from(JSON.stringify(o)).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
/** The default identity render specs run as — a hanzo-org admin. */
export const DEFAULT_CLAIMS: Required<SessionClaims> = {
export const DEFAULT_CLAIMS: Required<Omit<SessionClaims, 'properties' | 'issuedAt'>> = {
owner: 'hanzo',
name: 'z',
email: 'z@hanzo.ai',
@@ -40,20 +52,24 @@ export const DEFAULT_CLAIMS: Required<SessionClaims> = {
isAdmin: true,
}
/** An unsigned JWT whose payload carries the claims + a far-future `exp`. */
export function forgeToken(claims: Required<SessionClaims>): string {
const payload = { ...claims, sub: `${claims.owner}/${claims.name}`, exp: Math.floor(Date.now() / 1000) + 3600 }
/** An unsigned JWT whose payload carries the claims, an `iat` and a far-future `exp`. */
export function forgeToken(claims: SessionClaims): string {
const iat = claims.issuedAt ?? Math.floor(Date.now() / 1000)
const payload = { ...claims, sub: `${claims.owner}/${claims.name}`, iat, exp: iat + 86_400 }
return `${b64({ alg: 'none' })}.${b64(payload)}.x`
}
/** Seed tokens + gate keys and register the IAM endpoint mocks. */
export async function primeSession(page: Page, overrides: Partial<SessionClaims> = {}): Promise<void> {
const claims: Required<SessionClaims> = { ...DEFAULT_CLAIMS, ...overrides }
const claims: SessionClaims = { ...DEFAULT_CLAIMS, ...overrides }
await page.addInitScript(
({ org, token }: { org: string; token: string }) => {
try {
sessionStorage.setItem('hanzo_iam_access_token', token)
sessionStorage.setItem('hanzo_iam_expires_at', String(Date.now() + 3600_000))
// localStorage, not sessionStorage: the `@hanzo/iam` token store is shared
// across tabs (that IS the session), so seeding a per-tab area would leave
// the SDK reading an empty store and every primed spec signed out.
localStorage.setItem('hanzo_iam_access_token', token)
localStorage.setItem('hanzo_iam_expires_at', String(Date.now() + 3600_000))
localStorage.setItem('hanzo.console.org', org)
localStorage.setItem('hanzo.console.org.selected', '1')
localStorage.setItem(`hz_onboarding_done:${org}`, '1')
+25 -10
View File
@@ -1,10 +1,17 @@
/**
* The ONE account control, at the foot of the rail.
* The ONE account control, at the foot of the rail — and the ONE org switch.
*
* There used to be three: an org switcher at the top of the sidebar, an account
* popover at the bottom, and a third menu in the mobile drawer — with four ways
* to sign out between them. This spec pins the replacement: one control, both
* switchers, at the bottom, in a shell that ships no Tailwind.
* There used to be three account-ish menus: an org switcher at the top of the
* sidebar, an account popover at the bottom, and a third in the mobile drawer,
* with four ways to sign out between them. They became one control that answered
* BOTH "who am I" and "where am I".
*
* They have now been split again, but by QUESTION rather than by accident: the
* account control at the foot answers who you are (identity, team, personal
* settings, balance, the way out), and `ContextSwitcher` at the TOP-LEFT answers
* where you are (organization + project, together, beside the tenant's mark).
* So the cross-tenant reach is asserted against the context switcher below, and
* the account menu is asserted to no longer offer a tenant at all.
*
* Everything is asserted on computed style and geometry. The failure this guards
* against is a menu that is present in the DOM and unreadable — a library that
@@ -40,6 +47,9 @@ const accountTrigger = (page: Page) => page.getByTestId('nav-user').first()
/** The trigger inside the phone's account sheet — the last mount in the document. */
const drawerTrigger = (page: Page) => page.getByTestId('nav-user').last()
/** The org + project control at the top-left — the only thing that switches tenant. */
const contextTrigger = (page: Page) => page.getByTestId('switcher-context').first()
async function mountConsole(page: Page, seen: Scoped[]) {
// The standalone console reaches the cross-tenant list through its own gated
// `/admin/iam` proxy; the go:embed build reaches cloud's `/v1/iam` directly.
@@ -182,18 +192,23 @@ test.describe('account control', () => {
expect(typedInCaps).toEqual([])
})
test('the org switcher reaches a tenant the caller is not a member of', async ({ page }) => {
test('the context switcher reaches a tenant the caller is not a member of', async ({ page }) => {
const seen: Scoped[] = []
await mountConsole(page, seen)
await accountTrigger(page).click()
await page.locator('[role=menu]').waitFor()
// Tenancy is the TOP-LEFT control's job now, not the account menu's.
await contextTrigger(page).click()
// Acme is nobody's membership — it exists only in the cross-tenant list an
// admin may search. A memberships-only switcher could not offer it at all.
await page.getByLabel('Find an organization').fill('acme')
const acme = page.getByRole('option', { name: 'Acme Industrial' })
// `radiogroup`/`radio`, not `listbox`/`option`: @hanzo/gui's `role` union is
// React Native's a11y set, which carries `option` but NOT `listbox`.
const orgList = page.getByRole('radiogroup', { name: 'Organizations' })
const acme = orgList.getByRole('radio', { name: 'Acme Industrial' })
await acme.waitFor()
await expect(page.getByRole('option')).toHaveCount(1)
// Scoped to the ORG group — the same popover also lists projects, and a bare
// getByRole('radio') would silently count those too.
await expect(orgList.getByRole('radio')).toHaveCount(1)
await page.screenshot({ path: 'e2e-shots/account-menu-find-org.png', animations: 'disabled' })
+170
View File
@@ -0,0 +1,170 @@
/**
* e2e: the assistant's ONE entry point, and the All-products directory you can act in.
*
* Three claims, each measured in a real browser rather than inferred from source:
*
* 1. The assistant opens from a FLOATING bottom-right control, not from the header —
* asserted on GEOMETRY (the control's box is in the bottom-right quadrant of the
* viewport) and on the header carrying no assistant control at all.
* 2. Clicking an app in the All-products directory NAVIGATES to that app. This is the
* regression that matters: the rows rendered, hovered, and did nothing, so the
* directory looked interactive and was not. Asserted on where the browser LANDS.
* 3. A pin made in the directory survives a reload EVEN WHEN the identity token
* carries an older preferences snapshot — the exact production condition (the
* token is minted at sign-in; a pin made after it is not in it).
*
* Local dev server + mocked network; `primeSession` supplies the IAM-PKCE identity.
*
* Run: BASE_URL=http://localhost:4000 npx playwright test assistant-fab-and-apps
*/
import { test, expect, type Route, type Page } from '@playwright/test'
import { requireFixtureServer } from './_fixture'
import { primeSession } from './_session'
import { mkdirSync } from 'node:fs'
import { join } from 'node:path'
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4000'
requireFixtureServer()
const SHOTS = join(process.cwd(), 'e2e-shots')
const API_RE = /\/(v1|cloud|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations)(\/|$|\?)/
async function mock(route: Route) {
const req = route.request()
if (req.resourceType() === 'document') return route.continue()
const url = new URL(req.url())
const sameOrigin = url.origin === new URL(BASE_URL).origin
if (sameOrigin && !API_RE.test(url.pathname)) return route.continue()
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ status: 'ok', msg: '', data: [], data2: 0 }),
})
}
/** Sign in and land on `path`, waiting for the signed-in shell to have mounted. */
async function boot(page: Page, path = '/', claims?: Parameters<typeof primeSession>[1]) {
await page.route('**/*', mock)
await primeSession(page, claims)
await page.goto(`${BASE_URL}${path}`, { waitUntil: 'domcontentloaded' })
await expect(page.getByRole('button', { name: 'Ask Hanzo' })).toBeVisible({ timeout: 60_000 })
}
test.beforeAll(() => mkdirSync(SHOTS, { recursive: true }))
test('the assistant opens from the bottom-right, and the header carries no AI control', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const page = await ctx.newPage()
await boot(page)
const fab = page.getByRole('button', { name: 'Ask Hanzo' })
const box = await fab.boundingBox()
expect(box).not.toBeNull()
// Bottom-right quadrant: the whole point of the relocation.
expect(box!.x).toBeGreaterThan(1440 / 2)
expect(box!.y).toBeGreaterThan(900 / 2)
// A comfortable target, not a hairline.
expect(box!.width).toBeGreaterThanOrEqual(44)
expect(box!.height).toBeGreaterThanOrEqual(44)
// The topbar itself holds no assistant control any more — it used to carry two
// (a brand-H "Chat with Hanzo" and a "Talk to Hanzo" mic) beside the search box.
const inTopbar = await page.evaluate(() =>
Array.from(document.querySelectorAll('.hz-topbar [aria-label]')).map((n) => n.getAttribute('aria-label') ?? ''),
)
expect(inTopbar).not.toHaveLength(0) // the topbar was found at all
expect(inTopbar.filter((l) => /Hanzo/i.test(l))).toHaveLength(0)
await page.screenshot({ path: join(SHOTS, 'assistant-fab-desktop.png') })
// It opens the SAME assistant surface.
await fab.click()
await expect(page.getByText('Assistant').first()).toBeVisible({ timeout: 15_000 })
await page.screenshot({ path: join(SHOTS, 'assistant-open-desktop.png') })
await ctx.close()
})
test('the assistant control is reachable on a phone and never scrolls the body sideways', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 390, height: 844 } })
const page = await ctx.newPage()
await boot(page)
const fab = page.getByRole('button', { name: 'Ask Hanzo' })
const box = await fab.boundingBox()
expect(box).not.toBeNull()
expect(box!.x + box!.width).toBeLessThanOrEqual(390)
expect(box!.y).toBeGreaterThan(844 / 2)
const [scrollW, clientW] = await page.evaluate(() => [
document.documentElement.scrollWidth,
document.documentElement.clientWidth,
])
expect(scrollW).toBe(clientW)
await page.screenshot({ path: join(SHOTS, 'assistant-fab-mobile.png') })
await ctx.close()
})
test('clicking an app in All products opens that app', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const page = await ctx.newPage()
await boot(page)
await page.getByRole('button', { name: 'All products' }).first().click()
const row = page.getByRole('button', { name: 'Open Agents' })
await expect(row).toBeVisible({ timeout: 15_000 })
await page.screenshot({ path: join(SHOTS, 'all-products-desktop.png') })
await row.click()
// Where the browser LANDS is the claim — not that a handler fired.
await expect(page).toHaveURL(/\/agents$/, { timeout: 15_000 })
await ctx.close()
})
test('a pin made in All products survives a reload under a STALE token snapshot', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const page = await ctx.newPage()
// The production condition: the identity token was minted an hour ago and carries a
// preferences SNAPSHOT from then. Treating that snapshot as authoritative is what
// silently threw away every pin made since — the pin reads as pinned, and is gone
// after a reload.
const snapshot = { pins: [{ id: 'models', group: '' }], pinGroups: [] }
await boot(page, '/', {
properties: { 'hanzo.preferences': JSON.stringify(snapshot) },
issuedAt: Math.floor(Date.now() / 1000) - 3600,
})
const openDirectory = async () => {
await page.getByRole('button', { name: 'All products' }).first().click()
// "…to sidebar" / "…from sidebar" are the directory's own labels — the home page's
// Apps map carries a plain "Pin Agents", so the short form is ambiguous.
await expect(page.getByRole('button', { name: /Agents (to|from) sidebar/ })).toBeVisible({ timeout: 15_000 })
}
// The snapshot the token carries is what the sidebar starts from.
await openDirectory()
await page.getByRole('button', { name: 'Pin Agents to sidebar' }).click()
await expect(page.getByRole('button', { name: 'Remove Agents from sidebar' })).toBeVisible()
// Only a write the SERVER acknowledged earns the stamp that out-ranks the snapshot.
await expect
.poll(() => page.evaluate(() => localStorage.getItem('hanzo.console2.prefs.z.writtenAt')), { timeout: 10_000 })
.not.toBeNull()
await page.reload({ waitUntil: 'domcontentloaded' })
await expect(page.getByRole('button', { name: 'Ask Hanzo' })).toBeVisible({ timeout: 60_000 })
// Still pinned — the hour-old snapshot did not win. Asserted on what the user sees…
await openDirectory()
await expect(page.getByRole('button', { name: 'Remove Agents from sidebar' })).toBeVisible({ timeout: 15_000 })
// …and on what was actually kept (models from the snapshot, agents from the write).
const pins = await page.evaluate(() => {
const raw = JSON.parse(localStorage.getItem('hanzo.console2.prefs.z') ?? '{}')
return (raw.pins ?? []).map((p: { id: string }) => p.id)
})
expect(pins).toContain('agents')
expect(pins).toContain('models')
await ctx.close()
})
+10 -9
View File
@@ -1,12 +1,12 @@
/**
* e2e: two-tenant BILLING ISOLATION through the `/billing/*` proxy.
* e2e: two-tenant BILLING ISOLATION through the `/v1/billing/*` proxy.
*
* The proxy (app/billing/v1/[...path]/route.ts) resolves the billing subject from the
* The proxy (app/v1/billing/[...path]/route.ts) resolves the billing subject from the
* session server-side and pins the full subject-key set (user/userId/customerId) +
* the X-Org-Id header, so a tenant can only ever read its OWN commerce ledger. This
* spec proves that end-to-end against the LIVE proxy: two accounts in DIFFERENT orgs
* each fetch `/billing/subscriptions` (and `/payment-methods`), and we assert the
* two result sets are disjoint — neither tenant can see the other's rows.
* each fetch `/v1/billing/subscriptions` (and `/v1/billing/methods`), and we assert
* the two result sets are disjoint — neither tenant can see the other's rows.
*
* This is the regression guard for the IDOR RED found (the proxy previously pinned
* only `?user=` while commerce filters subscriptions on `?userId=`, so subscriptions
@@ -36,11 +36,12 @@ async function signIn(page: Page, email: string, password: string) {
await page.waitForLoadState('domcontentloaded')
}
/** Fetch a billing path through the same-origin DATA proxy (`/billing/v1/*`), as the
* signed-in browser. (`/billing/<slug>` without `v1/` is a UI tab, served by the SPA.) */
/** Fetch a billing path through the same-origin DATA proxy (`/v1/billing/*`), as the
* signed-in browser. (`/billing/<slug>` is a UI tab, served by the SPA — it differs at
* the FIRST path segment, so the two never collide.) */
async function billing(page: Page, path: string): Promise<{ status: number; ids: string[] }> {
return page.evaluate(async (p) => {
const res = await fetch(`/billing/v1/${p}`, { credentials: 'include', headers: { Accept: 'application/json' } })
const res = await fetch(`/v1/billing/${p}`, { credentials: 'include', headers: { Accept: 'application/json' } })
let ids: string[] = []
try {
const body = await res.json()
@@ -73,9 +74,9 @@ test.describe('billing is isolated per tenant through the proxy', () => {
await signIn(pageB, B.email, B.password)
// `invoices` is included because its row ids drive the per-invoice PDF URL
// (`/billing/v1/invoices/:id/pdf`) — proving the invoice list is tenant-isolated
// (`/v1/billing/invoices/:id/pdf`) — proving the invoice list is tenant-isolated
// proves a user can only ever build a PDF URL for their OWN org's invoices.
for (const path of ['subscriptions', 'payment-methods', 'invoices']) {
for (const path of ['subscriptions', 'methods', 'invoices']) {
const a = await billing(pageA, path)
const b = await billing(pageB, path)
+2 -2
View File
@@ -131,8 +131,8 @@ test.describe('Money/usage/o11y surface is fail-closed for anonymous (unauthenti
'/v1/billing/balance',
'/v1/billing/invoices',
'/v1/billing/usage',
'/v1/billing/payment-methods',
'/v1/billing/spend-alerts',
'/v1/billing/methods',
'/v1/billing/alerts',
'/v1/usage/summary',
'/v1/get-cloud-usages',
'/v1/o11y/observations',
+1 -1
View File
@@ -40,7 +40,7 @@ const CANONICAL_IDS: string[] = JSON.parse(readFileSync(join(process.cwd(), 'e2e
* are NOT registry ids — they must resolve via SLUG_ALIASES to a real module (never
* a 404 blank). Auditing them here proves the alias map end-to-end against the real app.
*/
const ALIAS_SLUGS = ['traces', 'deploy', 'plans-pricing', 'wallets', 'model-catalog', 'fine-tuning', 'web-search', 'mlpipelines', 'kubeflow']
const ALIAS_SLUGS = ['traces', 'deploy', 'plans-pricing', 'wallets', 'model-catalog', 'fine-tuning', 'web-search']
const IDS: string[] = [...CANONICAL_IDS, ...ALIAS_SLUGS]
/** A global-admin (sees every surface) or a tenant customer (Dave/maxpower shape). */
+4 -4
View File
@@ -3,7 +3,7 @@
*
* Runs against a LOCAL server (BASE_URL=http://localhost:4000) with the whole network
* mocked (same pattern as blank-audit): `/auth/session` → a global admin so the shell
* mounts, `/v1/billing/spend-alerts` → real-shaped budget rows (org default + project
* mounts, `/v1/billing/alerts` → real-shaped budget rows (org default + project
* warn + service over + unlimited/rate-limit-only), everything else → an empty-ok
* envelope.
*
@@ -39,7 +39,7 @@ const ACCOUNT = {
signupApplication: 'hanzo-cloud',
}
/** Real-shaped `/v1/billing/spend-alerts` rows — one per verdict/scope (threshold = cents). */
/** Real-shaped `/v1/billing/alerts` rows — one per verdict/scope (threshold = cents). */
const BUDGETS = [
{ id: 'b1', title: 'Org monthly cap', threshold: 500000, currency: 'usd', project: '', service: '', enforce: true, softPct: 80, rateLimitRpm: 0, periodSpentCents: 312000, over: false, warn: false },
{ id: 'b2', title: 'Inference budget', threshold: 200000, currency: 'usd', project: 'acme-prod', service: 'inference', enforce: false, softPct: 75, rateLimitRpm: 600, periodSpentCents: 186000, over: false, warn: true },
@@ -61,8 +61,8 @@ async function mock(route: Route) {
if (path.startsWith('/auth/')) {
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) })
}
// The page under test — the real spend-alerts contract.
if (path === '/v1/billing/spend-alerts') {
// The page under test — the real alerts contract.
if (path === '/v1/billing/alerts') {
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(BUDGETS) })
}
+32 -20
View File
@@ -1,11 +1,12 @@
/**
* e2e: brand-forward chrome + voice — mocked-network render proof.
*
* The Chrome wave: the big floating chat CIRCLE was removed; the assistant now opens
* from the TOPBAR (a small brand-H "Chat with Hanzo" + a "Talk to Hanzo" mic), the
* top-left SidebarBrand renders the org's own logo (white-label), and the Developers
* dock is drag-resizable with a live "Create key". This spec proves all of it in a
* browser.
* The Chrome wave: the big floating chat CIRCLE was removed; the assistant opens from
* ONE control — a floating bottom-right cluster (a brand-H "Ask Hanzo" + a "Talk to
* Hanzo" mic, `AssistantFab`), NOT the topbar, which carries navigation and account
* chrome only. The top-left SidebarBrand renders the org's own logo (white-label), and
* the Developers dock is drag-resizable with a live "Create key". This spec proves all
* of it in a browser.
*
* Same harness as workbench.spec (the closest sibling): a LOCAL server with the
* network mocked. `primeSession` seeds the IAM-PKCE identity AND the first-run gates
@@ -13,7 +14,7 @@
* real-shaped ledger rows for the dock's Overview, `/v1/models` → a small catalog for
* the assistant's model list; everything else → an empty-ok envelope.
*
* Voice gotcha: headless chromium ships NO webkitSpeechRecognition, so the topbar mic
* Voice gotcha: headless chromium ships NO webkitSpeechRecognition, so the mic
* (rendered only when `voiceSupported()`) would be absent for an environment reason,
* not a code one. A tiny, inert Web Speech stub is injected BEFORE load
* (`installVoiceStub`) so `voiceSupported()` is deterministically true and the mic
@@ -107,20 +108,20 @@ function installVoiceStub(page: Page) {
})
}
/** Prime + navigate; the topbar brand-H is on EVERY viewport, so it is the mount signal. */
/** Prime + navigate; the floating brand-H is on EVERY viewport, so it is the mount signal. */
async function openHome(page: Page, waitForMount = true) {
await installVoiceStub(page)
await page.route('**/*', mock)
await primeSession(page)
await page.goto(`${BASE_URL}/`, { waitUntil: 'domcontentloaded' })
if (waitForMount) {
await expect(page.locator('[aria-label="Chat with Hanzo"]').first()).toBeVisible({ timeout: 20_000 })
await expect(page.locator('[aria-label="Ask Hanzo"]').first()).toBeVisible({ timeout: 20_000 })
}
}
test.beforeAll(() => mkdirSync(SHOTS, { recursive: true }))
test('the floating circle is gone; the topbar carries chat + voice, the sidebar brand + docked assistant + Developers dock work', async ({ browser }) => {
test('one floating control carries chat + voice; the topbar carries neither; sidebar brand + docked assistant + Developers dock work', async ({ browser }) => {
// laptop (≥ lg 1024): the persistent sidebar, the Developers dock, and the docked
// assistant column are all present (they are desktop-only concerns).
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } })
@@ -130,10 +131,18 @@ test('the floating circle is gone; the topbar carries chat + voice, the sidebar
// 1. The OLD floating circle is GONE — the bubble that covered page content.
await expect(page.locator('[aria-label="Open AI assistant"]')).toHaveCount(0)
// 2. Topbar: the small brand-H "Chat with Hanzo" AND the "Talk to Hanzo" mic, both
// visible (the mic renders because the Web Speech stub makes voiceSupported() true).
await expect(page.locator('[aria-label="Chat with Hanzo"]').first()).toBeVisible()
await expect(page.locator('[aria-label="Talk to Hanzo"]').first()).toBeVisible()
// 2. ONE floating control, bottom-right: the brand-H "Ask Hanzo" AND the "Talk to
// Hanzo" mic (the mic renders because the Web Speech stub makes voiceSupported()
// true) — and the topbar carries no assistant control at all. The two used to live
// up there beside the search box, which put the assistant in a third place.
// Scoped to the control itself: the assistant's own composer carries a mic with
// the same label, mounted-but-hidden until the panel opens, so a bare
// `[aria-label="Talk to Hanzo"]` matches that one first and reads "hidden".
const fab = page.getByTestId('assistant-fab')
await expect(fab.locator('[aria-label="Ask Hanzo"]')).toBeVisible()
await expect(fab.locator('[aria-label="Talk to Hanzo"]')).toBeVisible()
await expect(page.locator('.hz-topbar [aria-label="Ask Hanzo"]')).toHaveCount(0)
await expect(page.locator('.hz-topbar [aria-label="Talk to Hanzo"]')).toHaveCount(0)
// 3. The top-left SidebarBrand renders the org logo / BrandMark (an <img> or <svg>).
const brand = page.locator('[aria-label*="right-click for brand menu"]').first()
@@ -147,15 +156,18 @@ test('the floating circle is gone; the topbar carries chat + voice, the sidebar
await expect(page.locator('[title="Drag to resize"]').first()).toBeVisible()
await expect(page.locator('text=Create key').first()).toBeVisible({ timeout: 15_000 })
// 5. Clicking "Chat with Hanzo" opens the DOCKED assistant surface — the "Assistant"
// header + its Undock control appear (uniquely the docked panel at lg+).
await page.locator('[aria-label="Chat with Hanzo"]').first().click()
// 5. Clicking "Ask Hanzo" opens the DOCKED assistant surface — the "Assistant"
// header + its Undock control appear (uniquely the docked panel at lg+). The
// floating control then steps aside: at lg+ the docked column IS the assistant,
// so keeping a button to open it on top of itself would be a second way in.
await fab.locator('[aria-label="Ask Hanzo"]').click()
await expect(page.locator('[aria-label^="Undock"]').first()).toBeVisible({ timeout: 15_000 })
await expect(page.getByText('Assistant', { exact: true }).filter({ visible: true }).first()).toBeVisible()
await expect(page.locator('[aria-label="Ask Hanzo"]')).toHaveCount(0)
// 6. The mic is wired: "Talk to Hanzo" → startVoice → the conversation opens the
// recognition (voiceSignal effect → voice.start() → the stub records the call).
await page.locator('[aria-label="Talk to Hanzo"]').first().click()
// 6. The mic is wired: "Talk to Hanzo" (now the open conversation's own) → the
// recognition opens (voice.start() → the stub records the call).
await page.locator('[aria-label="Talk to Hanzo"]').filter({ visible: true }).first().click()
await expect
.poll(() => page.evaluate(() => (window as unknown as { __voiceStarted?: number }).__voiceStarted ?? 0), { timeout: 15_000 })
.toBeGreaterThan(0)
@@ -179,7 +191,7 @@ test('renders across breakpoints with no horizontal body scroll on a phone; scre
// (real render, or an honest blank shell if the sandbox can't paint the SPA).
await openHome(page, false)
await page
.locator('[aria-label="Chat with Hanzo"]')
.locator('[aria-label="Ask Hanzo"]')
.first()
.waitFor({ state: 'visible', timeout: 20_000 })
.catch(() => {})
+10 -10
View File
@@ -14,7 +14,7 @@
*
* Run:
* HANZO_PASSWORD=xxx pnpm e2e
* HANZO_PASSWORD=xxx HANZO_API_KEY=hk-xxx pnpm e2e
* HANZO_PASSWORD=xxx HANZO_API_KEY=sk-xxx pnpm e2e
*/
import { test, expect, type Page } from '@playwright/test'
@@ -151,15 +151,15 @@ test.describe('Hanzo Cloud Console e2e', () => {
if (needsCreate) {
await createBtn.click()
// One-time reveal card with the hk- key
await expect(page.locator('text=/hk-/')).toBeVisible({ timeout: 25_000 })
// One-time reveal card with the sk- key
await expect(page.locator('text=/sk-/')).toBeVisible({ timeout: 25_000 })
await expect(page.locator('text=/shown only once/i')).toBeVisible()
await expect(page.locator('button:has-text("Copy")')).toBeVisible()
console.log('✓ API key created (hk- one-time reveal shown)')
console.log('✓ API key created (sk- one-time reveal shown)')
} else {
// Key already exists
await expect(hasKey).toBeVisible({ timeout: 10_000 })
await expect(page.locator('text=/hk-…|hk-[A-Za-z0-9]{3,}/i')).toBeVisible({ timeout: 5_000 })
await expect(page.locator('text=/sk-…|sk-[A-Za-z0-9]{3,}/i')).toBeVisible({ timeout: 5_000 })
console.log('✓ API key already exists (prefix shown)')
}
})
@@ -183,19 +183,19 @@ test.describe('Hanzo Cloud Console e2e', () => {
} else if (await createBtn.isVisible({ timeout: 1_000 }).catch(() => false)) {
await createBtn.click()
}
await expect(page.locator('text=/hk-/')).toBeVisible({ timeout: 25_000 })
await expect(page.locator('text=/sk-/')).toBeVisible({ timeout: 25_000 })
// Grab the FULL key from the one-time reveal — never the masked display
// (the account card shows `hk-2f18…` with an ellipsis, which is not a
// usable credential). Match only a full hk- token (no `…`/`...`).
const fullKey = /hk-[A-Za-z0-9._-]{16,}/
// (the account card shows `sk-2f18…` with an ellipsis, which is not a
// usable credential). Match only a full sk- token (no `…`/`...`).
const fullKey = /sk-[A-Za-z0-9._-]{16,}/
const keyEl = page.locator('[style*="monospace"]').filter({ hasText: fullKey }).first()
apiKey = (((await keyEl.textContent().catch(() => '')) ?? '').match(fullKey) ?? [''])[0]
if (!apiKey) {
const m = ((await page.textContent('body')) ?? '').match(fullKey)
apiKey = m ? m[0] : ''
}
expect(apiKey, 'Could not extract hk- key from page').toMatch(/^hk-/)
expect(apiKey, 'Could not extract sk- key from page').toMatch(/^sk-/)
console.log(`✓ Extracted key prefix: ${apiKey.slice(0, 11)}`)
}
+64
View File
@@ -0,0 +1,64 @@
/**
* The OAuth return raises EXACTLY ONE toast.
*
* A unit test cannot see this bug. It is a render loop: the toast provider built
* its context value fresh on every render and used it as the value, so every
* useToast() consumer got a new identity whenever a toast was added — and the
* integrations effect both DEPENDS on the toast api and RAISES a toast. Raising
* one re-rendered the provider, which handed the effect a new api, which raised
* another. Live this stacked ~15 identical "Connected slack" cards down the
* viewport. Stripping the query params could not stop it: router.replace is
* asynchronous, so the params are still readable on the renders in between.
*
* So the assertion is a COUNT after the loop has had time to run, on the real
* rendered DOM — the only place the defect exists.
*/
import { test, expect } from '@playwright/test'
import { primeSession } from './_session'
const PROVIDERS = [
{
id: 'slack',
name: 'Slack',
description: 'Post messages and receive events in your Slack workspace.',
category: 'Communication',
available: true,
connected: true,
connection: { account: 'The Foundation', connectedAt: '2026-08-05T00:16:49Z' },
},
]
test.describe('integrations OAuth return', () => {
test.beforeEach(async ({ page }) => {
// Everything else answers empty so the module mounts standalone.
await page.route('**/v1/**', async (route) => {
const url = route.request().url()
if (url.includes('/v1/integrations')) {
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(PROVIDERS) })
}
return route.fulfill({ status: 200, contentType: 'application/json', body: '{"data":[]}' })
})
await primeSession(page)
})
test('a connected= return raises exactly one toast', async ({ page }) => {
await page.goto('/integrations?connected=slack&account=The+Foundation')
const toasts = page.getByText('Connected slack')
await expect(toasts.first()).toBeVisible({ timeout: 15_000 })
// Give the loop every chance to run: the effect re-fires on each provider
// re-render, and the pre-fix build had stacked well past a dozen by now.
await page.waitForTimeout(3_000)
expect(await toasts.count()).toBe(1)
await page.screenshot({ path: 'e2e-shots/integrations-one-toast.png', fullPage: false })
})
test('the callback params are stripped so a reload cannot replay it', async ({ page }) => {
await page.goto('/integrations?connected=slack&account=The+Foundation')
await expect(page.getByText('Connected slack').first()).toBeVisible({ timeout: 15_000 })
await expect.poll(() => new URL(page.url()).search, { timeout: 10_000 }).toBe('')
})
})
+1 -1
View File
@@ -134,7 +134,7 @@ test.describe('LIVE v8.4.15 — (a) business board + (c) billing dimension', ()
test('(c) billing Reports renders the cost-dimension surface', async ({ page }) => {
await signIn(page, CONSOLE)
// v8.4.16: the data proxy moved to /billing/v1/*, so /billing/reports now falls
// The data proxy lives at /v1/billing/*, so /billing/reports now falls
// through to the SPA (was shadowed by the /billing/[...path] proxy → raw JSON).
// A hard deep-link must render the Reports UI, not a proxy "not found".
await page.goto(`${CONSOLE}/billing/reports`, { waitUntil: 'domcontentloaded' })
+118
View File
@@ -0,0 +1,118 @@
/**
* Onboarding — Continue never moves, and Skip is always reachable.
*
* The complaint this pins: the Continue button landed at a different height on
* every step, so a user clicking through had to re-aim each time. StepActions was
* the LAST CHILD of a flex column, so its y was whatever the step's content
* happened to add up to. It is now a SLOT on StepShell above a content area with
* a reserved height — one placement, decided in one place.
*
* This is a GEOMETRY assertion on purpose. The JSX move is invisible to a unit
* test (both shapes render the same button with the same label); only the painted
* box says whether the thing the user complained about is fixed.
*/
import { test, expect, type Page } from '@playwright/test'
import { primeSession } from './_session'
/** Every step whose footer must line up, in flow order. */
const STEPS = ['Secure your account', 'Data & consent', 'Your workspace', '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)
}
})
-1
View File
@@ -18,7 +18,6 @@
"agents",
"inference",
"finetuning",
"ml-pipelines",
"embeddings",
"evals",
"gpus",
+3 -3
View File
@@ -128,11 +128,11 @@ async function mock(route: Route) {
async function openPolicy(page: Page, marker = 'Enabled models') {
await page.addInitScript((org) => {
try {
// A valid @hanzo/iam session: a non-expired access token in sessionStorage so the
// A valid @hanzo/iam session: a non-expired access token in localStorage so the
// SDK's getValidAccessToken() returns it (userinfo is network-mocked to CLAIMS).
// Without a future `expires_at` the SDK treats the token as expired → anonymous.
sessionStorage.setItem('hanzo_iam_access_token', 'mock-access-token')
sessionStorage.setItem('hanzo_iam_expires_at', String(Date.now() + 3600000))
localStorage.setItem('hanzo_iam_access_token', 'mock-access-token')
localStorage.setItem('hanzo_iam_expires_at', String(Date.now() + 3600000))
localStorage.setItem('hanzo.console.org', org)
// Scope shows the org PICKER until an org is explicitly entered — the scope
+1 -1
View File
@@ -43,7 +43,7 @@ async function mock(route: Route) {
const req = route.request()
if (req.resourceType() === 'document') return route.continue()
const url = new URL(req.url())
if (/\/v1\/admin\/block-storage(\/|$|\?)/.test(url.pathname)) {
if (/\/v1\/admin\/volumes(\/|$|\?)/.test(url.pathname)) {
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(SNAPSHOT) })
}
const sameOrigin = url.origin === new URL(BASE_URL).origin
+50 -10
View File
@@ -1,4 +1,4 @@
import { readdirSync, readFileSync } from 'node:fs'
import { existsSync, readdirSync, readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
@@ -7,12 +7,16 @@ import { resolveBuildId, readGitSha } from './src/config/build-id.mjs'
/**
* Hanzo Cloud Console — Next.js config.
*
* Hanzo GUI is consumed at runtime (no optimizing compiler): the published
* `@hanzogui/next-plugin` has a broken npm dependency (`hanzogui-loader@7.3.0`
* is unpublished; the available fork renames its exports), so we transpile the
* Gui ESM packages with Next's built-in `transpilePackages` and let
* `GuiProvider` inject CSS at runtime. Gui is designed to work this way — the
* compiler is an optimization, not a requirement.
* Hanzo GUI is consumed at runtime (no optimizing compiler): we transpile the Gui
* ESM packages with Next's built-in `transpilePackages` and let `GuiProvider`
* inject CSS at runtime. Gui is designed to work this way — the compiler is an
* optimization, not a requirement.
*
* (The original reason to avoid `@hanzogui/next-plugin` no longer holds: the loader
* it depends on was unpublished at 7.3.0, but 8.x renamed it to `@hanzogui/loader`
* and both now ship. Adopting the compiler is therefore a live option — as an
* optimization to measure, not a correctness fix, so it is deliberately not bundled
* into the 8.x convergence.)
*
* `react-native` is aliased to `react-native-web` for the browser.
*
@@ -46,6 +50,9 @@ function guiPackages() {
return ['@hanzo/gui', '@hanzo/iam-js-sdk', '@hanzo/dash', '@hanzo/data', '@hanzo/canvas', '@hanzo/finance-ui', '@hanzo/usage', '@hanzo/ui', 'react-native-web', ...scoped]
}
/** A `@hanzogui/<pkg>/<subpath>/index.{js,cjs}` metro-compat shim (see `webpack()`). */
const GUI_SUBPATH_SHIM = /\/@hanzogui\/([^/]+)\/([^/]+)\/index\.c?js$/
/**
* Same-origin `/v1/*` — ZERO client-visible prefix (the CTO contract: "no prefix
* before /v1/ in any API call"). The browser ALWAYS calls its OWN origin at a clean
@@ -97,10 +104,10 @@ const AI_V1_HEADS = ['models', 'chat', 'embeddings', 'rerank', 'audio', 'images'
// (`providers/toggle`, `providers/primary`) both match the `/:path*` rewrite below,
// which is method-agnostic (Next matches on the URL), so POST is covered without a
// second entry. Keep this in sync with `admin-aggregate.ts` ADMIN_AGGREGATE_HEADS.
const ADMIN_V1_HEADS = ['overview', 'usage', 'orgs', 'audit', 'products', 'finance', 'compute', 'o11y', 'providers', 'customers', 'revenue', 'analytics', 'enablement', 'grants', 'referrals', 'affiliates', 'authors', 'treasury', 'services', 'promos', 'spend-caps', 'block-storage']
const ADMIN_V1_HEADS = ['overview', 'usage', 'orgs', 'audit', 'products', 'finance', 'compute', 'o11y', 'providers', 'customers', 'revenue', 'analytics', 'enablement', 'grants', 'referrals', 'affiliates', 'authors', 'treasury', 'services', 'promos', 'caps', 'volumes']
/**
* DEV-ONLY: proxy the client's direct-cloud `/v1/{iam,o11y}/*` calls (get-account,
* annotation-queues/users) to a real cloud backend so `npm run dev` renders the
* reviews/users) to a real cloud backend so `npm run dev` renders the
* authenticated shell locally. Enabled ONLY when `DEV_CLOUD_ORIGIN` is set (never in
* the built image), so production is unchanged — there the console host's edge routes
* `/v1` to the console, whose `/v1` catch-all forwards to cloud-api. The request cookie
@@ -206,7 +213,40 @@ const nextConfig = {
experimental: {
esmExternals: true,
},
webpack(config) {
webpack(config, { webpack }) {
// `@hanzogui/*` 8.x ships legacy metro-compat subpath DIRECTORIES (`config/v5/`,
// `themes/v5/`, `shorthands/v5/`, …) beside the `exports` map that already names
// the real entry. Each holds a CommonJS `index.js` — inside a `"type": "module"`
// package. Whatever resolves the directory therefore parses that file as ESM: the
// `export *` chain goes opaque ("'defaultConfig' is not exported from
// '@hanzogui/config/v5'") and its bare `require('../dist/cjs/v5.cjs')` survives
// into the server chunk, where it MODULE_NOT_FOUNDs at prerender (the require is
// relative to `.next/server/chunks/`, not to the package).
//
// So redirect any such shim to the ESM build sitting beside it. Pattern-based, on
// the RESOLVED file, so it holds however the request got there — and costs nothing
// the day the shims stop shipping.
config.plugins.push(
new webpack.NormalModuleReplacementPlugin(GUI_SUBPATH_SHIM, (data) => {
const resource = data.createData?.resource
if (!resource) return
const shim = GUI_SUBPATH_SHIM.exec(resource)
if (!shim) return
const esm = `${resource.slice(0, shim.index)}/@hanzogui/${shim[1]}/dist/esm/${shim[2]}.mjs`
if (!existsSync(esm)) return
// The module's CONTEXT must move with it, or its own relative imports
// (`./v5-base.mjs`) keep resolving against the shim directory.
data.createData.resource = esm
data.createData.userRequest = esm
data.createData.context = dirname(esm)
data.context = dirname(esm)
}),
)
// @hanzo/ui is consumed from SOURCE via a workspace link. Keep the symlinked path
// so its own imports (@hanzo/gui, @hanzogui/*) walk up into the CONSOLE's
// node_modules — one Tamagui instance, as the tsconfig `paths` already pin for
// types. Resolving the realpath would load a second copy and break theme context.
config.resolve.symlinks = false
config.resolve.alias = {
...config.resolve.alias,
'react-native$': 'react-native-web',
+12019
View File
File diff suppressed because it is too large Load Diff
+22 -21
View File
@@ -1,11 +1,11 @@
{
"name": "@hanzo/console",
"version": "8.5.33",
"version": "8.5.35",
"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.",
"description": "Hanzo Cloud Console \u2014 unified admin console for Hanzo Cloud and all cloud products.",
"scripts": {
"dev": "next dev -p 4000",
"build": "next build",
@@ -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.0",
"@hanzo/canvas": "^0.1.0",
"@hanzo/dash": "0.3.0",
"@hanzo/data": "^1.2.0",
"@hanzo/event": "^0.3.4",
"@hanzo/finance-ui": "0.1.1",
"@hanzo/gui": "7.3.0",
"@hanzo/iam": "^0.21.2",
"@hanzo/logo": "^1.0.13",
"@hanzo/ui": "^8.0.20",
"@hanzo/brand": "^1.4.5",
"@hanzo/canvas": "^0.2.1",
"@hanzo/dash": "^0.3.0",
"@hanzo/data": "^1.2.2",
"@hanzo/design": "^0.4.6",
"@hanzo/event": "^0.3.8",
"@hanzo/finance-ui": "~0.1.1",
"@hanzo/gui": "^8.0.0",
"@hanzo/iam": "^0.21.6",
"@hanzo/logo": "^1.0.14",
"@hanzo/ui": "^8.0.38",
"@hanzo/usage": "^0.1.6",
"@hanzogui/config": "7.3.0",
"@hanzogui/core": "7.3.0",
"@hanzogui/lucide-icons-2": "7.3.0",
"@hanzogui/next-theme": "7.3.0",
"@hanzogui/shell": "^7.6.3",
"@hanzogui/config": "^8.0.0",
"@hanzogui/core": "^8.0.0",
"@hanzogui/lucide-icons-2": "^8.0.0",
"@hanzogui/next-theme": "^8.0.0",
"@hanzogui/telemetry": "^8.0.0",
"@hanzogui/shell": "^8.1.1",
"@lexical/html": "0.46.0",
"@lexical/link": "0.46.0",
"@lexical/list": "0.46.0",
@@ -53,6 +54,7 @@
"qrcode.react": "4.2.0",
"react": "19.2.7",
"react-dom": "19.2.7",
"react-native-svg": "15.15.5",
"react-native-web": "0.21.2",
"superjson": "2.2.2"
},
@@ -61,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"
+1303 -5434
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -195,8 +195,8 @@ try {
stdio: 'inherit',
// CONSOLE_EMBED gates the server-side build transforms; NEXT_PUBLIC_CONSOLE_EMBED
// is inlined into the CLIENT bundle so runtime code (lib/embed.ts → IS_EMBED) can
// skip the BFF-only session probes (/auth/refresh|session, /billing welcome) that
// don't exist in this static, server-less deployment.
// skip the BFF-only session probes (/auth/refresh|session) that don't exist in
// this static, server-less deployment.
env: { ...process.env, CONSOLE_EMBED: '1', NEXT_PUBLIC_CONSOLE_EMBED: '1' },
})
+48
View File
@@ -0,0 +1,48 @@
/**
* Asks the RENDERER which props @hanzo/gui 8 actually honors.
*
* gui accepts any prop and drops the ones it does not know, so a gui-7 `tag="a"`
* type-checks, builds, and ships a <div>: the link is inert and nothing anywhere says
* so. A green build cannot answer this; only the rendered markup can. Every rule in
* `src/lib/gui8-props.ts` was verified here before it was written down.
*
* gui injects its stylesheet as a leading <style>, so read the host element from the
* marked child — never from the first tag in the string.
*
* node scripts/gui-prop-probe.mjs (slow: it resolves ~184 unbundled ESM packages)
*/
import React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { defaultConfig } from '@hanzogui/config/v5'
import { XStack, Text, createGui, GuiProvider } from '@hanzo/gui'
const config = createGui(defaultConfig)
const render = (el) =>
renderToStaticMarkup(React.createElement(GuiProvider, { config, defaultTheme: 'dark' }, el))
/** The element carrying our marker id — not gui's injected <style>. */
const marked = (markup) => markup.match(/<([a-z]+)[^>]*\bid="probe"[^>]*>/)?.[0] ?? ''
const hostOf = (markup) => marked(markup).match(/^<([a-z]+)/)?.[1] ?? '(not found)'
const probe = (label, Comp, props) => {
const markup = render(React.createElement(Comp, { id: 'probe', ...props }, 'x'))
console.log(`${label.padEnd(30)} -> ${marked(markup) || '(not found)'}`)
return { host: hostOf(markup), markup }
}
console.log('--- host element: tag (gui 7) vs render (gui 8) ---')
const withTag = probe('tag="a"', XStack, { tag: 'a', href: 'https://hanzo.ai' })
const withRender = probe('render="a"', XStack, { render: 'a', href: 'https://hanzo.ai' })
console.log('\n--- style props that silently drop or mis-unit ---')
probe('lineHeight={1.1} (prop)', Text, { lineHeight: 1.1 })
probe('style lineHeight: 1.1 (ratio)', Text, { style: { lineHeight: 1.1 } })
probe("style lineHeight: '1.1' (string)", Text, { style: { lineHeight: '1.1' } })
probe('letterSpacing="-0.02em"', Text, { letterSpacing: '-0.02em' })
probe('animation="quick"', XStack, { animation: 'quick' })
probe('$sm={{...}}', XStack, { $sm: { bg: '$red10' } })
probe('$gtSm={{...}}', XStack, { $gtSm: { bg: '$red10' } })
console.log()
console.log(withTag.host === 'a' ? 'tag WORKS' : 'tag IS SILENTLY DROPPED')
console.log(withRender.host === 'a' ? 'render WORKS' : 'render IS SILENTLY DROPPED')
+20 -56
View File
@@ -1,29 +1,22 @@
'use client'
/**
* The account control — who you are, which organization you are acting in, what
* you have left to spend, and the way out. ONE control, at the foot of the rail.
* The account control — WHO you are: identity, your team, your personal
* settings, what you have left to spend, and the way out. ONE control, at the
* foot of the rail.
*
* It deliberately does NOT switch tenant. Org and project are one question —
* WHERE you are — and they are answered together by `ContextSwitcher` at the
* top-left, beside the tenant's own mark. Handing this menu an `orgState` too
* would put the org in two corners again, which is the exact confusion the
* condensed switcher removes. The cross-tenant reach, the admin-gated org list
* and the single `org-scope.switchOrg` money seam all moved there intact; there
* is still exactly one org switch in the app.
*
* It is `@hanzo/iam`'s `UserMenu`, the same component hanzo.chat mounts, so the
* identity, the switcher and the behaviour (click-away, Escape, close-before-
* navigate, never a raw uuid) are shared rather than rebuilt. This file is the
* ADAPTER — everything the console knows that the SDK does not:
*
* - ORG REACH. `useOrganizations()` reads the caller's memberships off the token
* and cannot express what an admin console does: enter ANY tenant. So the
* switcher is handed `findOrgs`, backed by the console's existing lazy,
* server-paged cross-tenant list (`IamAdminApi.organizations` through the
* gated `/admin/iam` proxy) — the same source the full-page org picker uses.
* A regular user never fires it: they see their own org, synthesized from the
* session, exactly as before. Nothing is fabricated, and nobody's reach widens.
*
* - MONEY. Switching goes through `org-scope.switchOrg` — the console's existing
* switch, passed by reference, not reimplemented. It persists the scope and
* reloads so every module refetches under the new `X-Org-Id`. That one seam is
* where tenant scoping and its billing attribution already live; this file adds
* no second switch, no header of its own, and no billing call, so the rule about
* which ledger a masquerading admin's spend lands on is exactly where it was.
* `org-state.test.ts` pins the identity so a second switch cannot creep in.
* identity and the behaviour (click-away, Escape, close-before-navigate, never a
* raw uuid) are shared rather than rebuilt. This file is the ADAPTER —
* everything the console knows that the SDK does not:
*
* - THEME. The console themes through `@hanzogui/next-theme` (which drives the
* Gui tree). That is adapted into the menu's shape rather than mounting IAM's
@@ -32,45 +25,19 @@
* - BRAND. The strip at the foot wears THIS host's brand. Passing nothing would
* paint a Hanzo mark on a Lux or Zoo console.
*/
import { useCallback, useMemo } from 'react'
import { UserMenu, type OrgState, type UserTheme } from '@hanzo/iam/react'
import { useMemo } from 'react'
import { UserMenu, type UserTheme } from '@hanzo/iam/react'
import { useThemeSetting } from '@hanzogui/next-theme'
import { config } from '~/config'
import { adminOrgState, scopedOrgRow } from '~/lib/account/org-state'
import { useSession } from '~/lib/auth/session'
import { useIsSuperAdmin } from '~/lib/auth/admin'
import { IamAdminApi, type Organization } from '~/lib/api'
import { ORG_PAGE_SIZE, orgQuery } from '~/lib/org-list'
import { currentOrg, leaveOrg, switchOrg } from '~/lib/org-scope'
import { useCloudBalance, spendableCents } from '~/lib/billing/live-balance'
export function AccountMenu() {
const { account, signOut } = useSession()
// The cross-tenant list is admin-gated at the proxy; a regular user would 403 it,
// so they are never asked to. Their own org is the honest answer.
const isSuperAdmin = useIsSuperAdmin()
const { balance } = useCloudBalance()
const { current, resolvedTheme, set } = useThemeSetting()
const scoped = currentOrg()
const findOrgs = useCallback(
async (query: string): Promise<Organization[]> => {
if (!isSuperAdmin) return scopedOrgRow(scoped)
const res = await IamAdminApi.organizations(orgQuery(0, query, ORG_PAGE_SIZE))
return res.rows ?? []
},
[isSuperAdmin, scoped],
)
// The console's own switch, by reference: persist the scope, reload, refetch
// under the new X-Org-Id. Pinned in `org-state.test.ts`.
const orgState: OrgState = useMemo(
() => adminOrgState({ scoped, findOrgs, switchOrg }),
[scoped, findOrgs],
)
// `system` is a real choice, and the console's provider already understands it.
const theme: UserTheme = useMemo(
() => ({
@@ -98,7 +65,6 @@ export function AccountMenu() {
isAuthenticated
isLoading={false}
onSignOut={() => void signOut()}
orgState={orgState}
theme={theme}
settingsUrl="/profile"
usageUrl="/billing"
@@ -106,12 +72,10 @@ export function AccountMenu() {
// Only shown when the backend actually reported a balance — never a fabricated $0.
balance={cents === null ? undefined : { amountUsd: cents / 100, topUpUrl: config.payUrl }}
items={[
{
label: 'All organizations',
// De-scope back to the full-page picker, where an org is entered — and
// where a new one is created. The dropdown never grew its own form.
onSelect: () => leaveOrg(),
},
// Your people, beside your own settings — the other half of "who am I".
// Choosing a DIFFERENT tenant is a different question and lives in the
// top-left context switcher, so this menu never re-scopes the console.
{ label: 'Team', href: '/team' },
{ label: 'Documentation', href: config.docsUrl, external: true, separatorBefore: true },
]}
brand={{ name: config.brandName }}
+53 -9
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,12 +15,15 @@
* 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 { 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'
@@ -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
@@ -236,6 +279,7 @@ export function AddProductPanel() {
color={colorOf(entry.id)}
pinned={isPinned(entry.id)}
inUse={inUse?.has(entry.id) ?? false}
onOpen={() => openEntry(entry)}
onToggle={() => toggle(entry.id)}
/>
))}
+55 -15
View File
@@ -1,27 +1,67 @@
'use client'
/**
* Bridges the console session + App-Router navigation into the shared analytics
* client (`@hanzo/event`). Rendered once, inside both `SessionProvider` and
* `AnalyticsProvider` (see `Provider.tsx`), it renders nothing.
* The console's telemetry surface — the ONE Hanzo telemetry provider, plus the one
* thing it deliberately leaves to the app.
*
* - `usePageview` emits a pageview on every path change (the provider fires the
* FIRST pageview itself, so this only covers subsequent client navigations).
* - `identify` binds the person to the STABLE `owner/name` actor id — the same id
* the API client already uses (`setCurrentActor`), never the email — once the
* session resolves. The org tenant is stamped server-side from the session, so
* we send the user id only. Anonymous placeholder sessions are skipped.
* `TelemetrySurface` mounts `@hanzogui/telemetry`, which owns the whole plane for
* this subtree: pageviews (including SPA route changes), `window.onerror` +
* `unhandledrejection`, React render errors (its internal boundary REPORTS and
* re-throws, so the app's own error UI still decides what the user sees), lazily
* imported interaction capture, and DNT/GPC consent. It replaces the hand-rolled
* `<AnalyticsProvider>` + bridge + boundary combo; it mounts @hanzo/event's
* `AnalyticsProvider` internally with its own client, so every existing
* `useAnalytics()` call site keeps working against that ONE client and one stream.
* `product="console"` is all the configuration there is — @hanzo/event's DSN
* registry resolves the hanzo-console Sentry project from it, so the error plane
* needs no `dsn` prop and no env var.
*
* It reads `usePathname()` ITSELF rather than taking a `path` prop from `Provider`:
* `Provider` memoizes its tree on `children`, so a path read up there would be
* baked into the cached element and go stale on the first client navigation.
*
* `AnalyticsBridge` is the one thing TelemetryProvider does NOT do — `identify`.
* It binds the person to the STABLE `owner/name` actor id (the same id the API
* client uses via `setCurrentActor`), never the email, once the session resolves.
* The org tenant is stamped server-side from the session, so we send the user id
* only, and anonymous placeholder sessions are skipped. It renders nothing and
* emits NO pageview — the provider owns those, and a second emitter would
* double-count every route.
*/
import { useEffect, useRef } from 'react'
import { useEffect, useRef, type ReactNode } from 'react'
import { usePathname } from 'next/navigation'
import { useAnalytics, usePageview } from '@hanzo/event/react'
import { TelemetryProvider, useTelemetry } from '@hanzogui/telemetry'
import { iamAccessToken } from '~/lib/auth/iam'
import { useSession } from '~/lib/auth/session'
import { type Account } from '~/lib/api/types'
/** The user attributes worth carrying alongside the id, from the IAM claims the
* session already decoded. A key is OMITTED rather than sent undefined, so an
* absent claim never overwrites a trait a prior identify established. */
export function identityTraits(account: Account): Record<string, unknown> {
const traits: Record<string, unknown> = {}
if (account.email) traits.email = account.email
const name = account.displayName ?? account.name
if (name) traits.name = name
return traits
}
export function TelemetrySurface({ children }: { children: ReactNode }) {
const path = usePathname()
// @hanzo/iam (PKCE) is the console's ONE credential and it is a BEARER — the same
// token `lib/api/client.ts` puts on every call — so telemetry authenticates the
// same way rather than relying on a cookie the ingest host would never receive.
return (
<TelemetryProvider product="console" path={path} getToken={iamAccessToken}>
{children}
</TelemetryProvider>
)
}
export function AnalyticsBridge() {
const analytics = useAnalytics()
const telemetry = useTelemetry()
const { account } = useSession()
usePageview(usePathname())
const identified = useRef('')
useEffect(() => {
@@ -29,8 +69,8 @@ export function AnalyticsBridge() {
const personId = `${account.owner}/${account.name}`
if (identified.current === personId) return
identified.current = personId
analytics.identify(personId)
}, [account, analytics])
telemetry.identify(personId)
}, [account, telemetry])
return null
}
+24 -6
View File
@@ -22,12 +22,13 @@ import { useIam } from '@hanzo/iam/react'
import { Button, Text, YStack } from '@hanzo/gui'
import { Loader } from '~/components/ui/Loader'
import { takeReturnTo } from '~/lib/auth/iam'
import { takeReturnTo, startReauth } from '~/lib/auth/iam'
import { classifyCallback, type CallbackVerdict } from '~/lib/auth/callback-error'
export function AuthCallback() {
const router = useRouter()
const { handleCallback } = useIam()
const [error, setError] = useState<string | null>(null)
const [verdict, setVerdict] = useState<CallbackVerdict | null>(null)
// The OAuth `code` is SINGLE-USE and the SDK removes the PKCE verifier BEFORE the
// token fetch, so the exchange must fire EXACTLY ONCE. Without this guard, React
// StrictMode's double-invoke (or any `handleCallback` identity change re-running the
@@ -47,20 +48,37 @@ export function AuthCallback() {
window.location.assign(takeReturnTo())
})
.catch(() => {
if (!cancelled) setError('Sign-in failed.')
if (cancelled) return
// The SDK is the AUTHORITY on whether a callback is a sign-in — it validates
// `state` before honouring an error branch, so an attacker-supplied
// /callback?error=… cannot mint a session. It reports every refusal the same
// way, though, so this screen used to say "Sign-in failed." to someone who had
// merely cancelled a consent screen. Read the code for WORDING only; the
// decision was already made above.
setVerdict(
classifyCallback(typeof window === 'undefined' ? '' : window.location.search) ?? {
kind: 'failed',
message: 'Sign-in failed.',
},
)
})
return () => {
cancelled = true
}
}, [handleCallback])
if (error) {
if (verdict) {
// A refusal is not always a fault. Only a genuine failure is worded as one, and
// the two benign outcomes lead with the action that actually resolves them.
const retry = verdict.kind === 'failed' ? 'Back to sign in' : 'Sign in'
return (
<YStack flex={1} minH="100vh" items="center" justify="center" gap="$3">
<Text color="$color12" fontWeight="600">
{error}
{verdict.message}
</Text>
<Button onPress={() => router.replace('/signin')}>Back to sign in</Button>
<Button onPress={() => (verdict.kind === 'failed' ? router.replace('/signin') : startReauth())}>
{retry}
</Button>
</YStack>
)
}
+195
View File
@@ -0,0 +1,195 @@
'use client'
/**
* Context switcher — WHERE you are: the organization and the project, in ONE
* control, at the TOP-LEFT where the tenant's mark already sits.
*
* The console used to answer "who and where am I" from three different corners:
* the org at the top of the rail, the account (which also switched org) at its
* foot, and the project chip in the top-right beside the network. Org and
* project are one question — which tenant, and which slice of it — so they are
* one control, and it sits with the org mark that already anchors the top-left.
*
* The ACCOUNT keeps the other question ("who am I": identity, team, personal
* settings, the way out) at the foot of the rail. The NETWORK stays its own
* control in the top-right, because it is a global MODE rather than a place —
* and its tier dot is a destructive-environment guard, not decoration.
*
* There is still exactly ONE org switch. `switchOrg` is passed by reference from
* `~/lib/org-scope` (the seam that persists the scope and reloads so every
* module refetches under the new `X-Org-Id`, which is where tenant scoping and
* its billing attribution already live). This control does not mint a second
* one, add a header of its own, or make a billing call — `org-state.test.ts`
* pins that identity. Cross-tenant reach is the SAME admin-gated, server-paged
* list the full-page picker uses; a regular user never fires it and sees only
* their own org.
*/
import { useCallback, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { Button, Popover, Text, XStack, YStack } from '@hanzo/gui'
import { ChevronsUpDown, FolderGit2, Plus } from '@hanzogui/lucide-icons-2'
import { useScope } from '~/lib/scope-context'
import { useOrgIdentity } from '~/components/ui/BrandLogo'
import { useIsSuperAdmin } from '~/lib/auth/admin'
import { IamAdminApi, type Organization } from '~/lib/api'
import { ORG_PAGE_SIZE, orgQuery } from '~/lib/org-list'
import { currentOrg, leaveOrg, switchOrg } from '~/lib/org-scope'
import { contextLabel, scopedOrgRow, titleCase } from '~/lib/account/org-state'
import { FieldText } from '~/components/ui/Field'
import { MenuRow } from '~/components/ui/MenuRow'
import { paper } from '~/components/ui/paper'
export function ContextSwitcher() {
const router = useRouter()
const org = useOrgIdentity()
const scoped = currentOrg()
const isSuperAdmin = useIsSuperAdmin()
const { scope, projects, loadingProjects, selectProject } = useScope()
const [open, setOpen] = useState(false)
const [orgs, setOrgs] = useState<Organization[] | null>(null)
const [query, setQuery] = useState('')
// IAM's display name when it has one; otherwise the slug, titled the same way
// `scopedOrgRow` titles it — one rule, so the trigger and the list agree.
const orgLabel = org.displayName || titleCase(org.name || scoped)
// The cross-tenant list is admin-gated at the proxy; a regular user would 403
// it, so they are never asked to — their own org is the honest answer. An admin
// searches the SERVER (the list is paged and far longer than one page), which is
// the only way to reach a tenant nobody is a member of.
const loadOrgs = useCallback(
async (q: string) => {
if (!isSuperAdmin) return setOrgs(scopedOrgRow(scoped) as Organization[])
const res = await IamAdminApi.organizations(orgQuery(0, q, ORG_PAGE_SIZE))
setOrgs(res.rows ?? [])
},
[isSuperAdmin, scoped],
)
const onOpenChange = useCallback(
(next: boolean) => {
setOpen(next)
if (next && orgs === null) void loadOrgs('')
},
[orgs, loadOrgs],
)
const search = useCallback(
(q: string) => {
setQuery(q)
void loadOrgs(q)
},
[loadOrgs],
)
const pick = useCallback(
(fn: () => void) => () => {
setOpen(false)
fn()
},
[],
)
const orgRows = useMemo(() => orgs ?? [], [orgs])
return (
<Popover open={open} onOpenChange={onOpenChange} placement="bottom-start">
<Popover.Trigger asChild>
<Button
size="$3"
chromeless
justify="flex-start"
px="$2"
data-testid="switcher-context"
iconAfter={<ChevronsUpDown size={13} opacity={0.6} />}
aria-label={`Organization and project — ${contextLabel(orgLabel, scope.project)}`}
>
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1} flex={1}>
{contextLabel(orgLabel, scope.project)}
</Text>
</Button>
</Popover.Trigger>
<Popover.Content {...paper} p="$2" width={280}>
<YStack gap="$0.5">
<Text px="$2" py="$1" fontSize="$1" color="$color10" fontWeight="500">
Organization
</Text>
{/* Admins only: the cross-tenant list is server-paged and longer than
one page, so reaching a tenant nobody is a member of means SEARCHING
it, not scrolling. A regular user has one org and no field. */}
{isSuperAdmin ? (
<YStack px="$1" pb="$1">
<FieldText
value={query}
onChange={search}
placeholder="Find an organization"
ariaLabel="Find an organization"
/>
</YStack>
) : null}
<YStack role="radiogroup" aria-label="Organizations" gap="$0.5">
{orgRows.map((o) => (
<MenuRow
key={o.name}
label={o.displayName || o.name}
active={scoped === o.name}
onPress={pick(() => {
if (o.name !== scoped) switchOrg(o.name)
})}
/>
))}
</YStack>
{orgRows.length === 0 ? (
<Text px="$2" py="$1.5" fontSize="$2" color="$color10">
{orgs === null ? 'Loading…' : 'No organization matches that.'}
</Text>
) : null}
<MenuRow label="All organizations" icon={<Plus size={14} />} onPress={pick(leaveOrg)} />
<XStack height={1} bg="$borderColor" my="$1" />
<Text px="$2" py="$1" fontSize="$1" color="$color10" fontWeight="500">
Project
</Text>
<YStack role="radiogroup" aria-label="Projects" gap="$0.5">
{/* Org-level scope — no X-Project-Id sent. */}
<MenuRow
label="All projects"
sub="Org-level"
active={!scope.project}
onPress={pick(() => selectProject(undefined))}
/>
{projects.map((p) => (
<MenuRow
key={p.name}
label={p.displayName || p.name}
active={scope.project === p.name}
onPress={pick(() => selectProject(p.name))}
/>
))}
</YStack>
{projects.length === 0 && !loadingProjects ? (
<Text px="$2" py="$1.5" fontSize="$2" color="$color10">
No projects yet.
</Text>
) : null}
<MenuRow
label="New project"
icon={<FolderGit2 size={14} />}
onPress={pick(() => router.push('/projects'))}
/>
</YStack>
</Popover.Content>
</Popover>
)
}
+88 -13
View File
@@ -17,6 +17,14 @@
* `<lg` the assistant is ALWAYS the floating bubble/sheet regardless of the dock
* choice; `docked` only reserves the right column at `lg+`.
*
* 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
* here; this is purely the container. "History" deep-links to the full `/chat` page.
@@ -26,12 +34,13 @@
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 { 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
@@ -204,6 +213,74 @@ 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 it is suppressed exactly where the assistant is already on
* screen: while the sheet is open, on the pages that ARE a composer (`/chat`,
* `/playground`), and — at `lg+` only — while the assistant is docked as a column.
* The `lg+` half of that rule is a CSS media prop rather than a JS branch, so SSR and
* first paint agree.
*/
function AssistantFab({
docked,
onOpen,
onVoice,
}: {
docked: boolean
onOpen: () => void
onVoice: () => void
}) {
const [voiceOk] = useState(() => voiceSupported())
return (
<XStack
testID="assistant-fab"
position="fixed"
r={20}
b={20}
$lg={{ b: 64, display: docked ? 'none' : 'flex' }}
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
@@ -292,16 +369,14 @@ export function Chat({ children }: { children: ReactNode }) {
<Ctx.Provider value={{ isOpen, open, close, toggle, docked, 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) and on the
pages that ARE a composer; at lg+ also hidden while docked (the permanent
column is the surface). `open`/`toggle`/`ask` still drive the assistant
programmatically (e.g. the Code hub's "Ask AI"). */}
{isOpen || onChatSurface ? null : (
<AssistantFab docked={docked} 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);
+69 -3
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'
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>
+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>
)
}
+20 -114
View File
@@ -1,32 +1,35 @@
'use client'
/**
* Scope switcher — the project + network pickers that scope every module.
* Scope switcher — the NETWORK picker, and only that.
*
* Two chips next to the org switcher: the active PROJECT (or "All projects" for
* org-level scope) and the active NETWORK. The network picker offers the stock
* tiers (Mainnet/Testnet/Devnet — the live Hanzo networks), a Local option for a
* self-hosted cloud binary, and any custom networks the user adds (their own
* networkID / EVM chainID / RPC / API). Selecting a network writes through
* `useScope`, which updates the module-level scope the API client reads — so it
* re-scopes every module (via `X-Environment`) AND retargets chain/RPC/API at once.
* A "New project" affordance routes to the Projects module; we never fabricate one.
* The network is a global MODE, not a place, so it stays its own control in the
* top-right while org and project condense into the top-left `ContextSwitcher`.
* Its tier dot is a destructive-environment guard (mainnet live green, testnet
* caution amber), which is why it keeps a distinct, always-visible chip rather
* than folding into a menu you have to open to read.
*
* The picker offers the stock tiers (Mainnet/Testnet/Devnet — the live Hanzo
* networks), a Local option for a self-hosted cloud binary, and any custom
* networks the user adds (their own networkID / EVM chainID / RPC / API).
* Selecting a network writes through `useScope`, which updates the module-level
* scope the API client reads — so it re-scopes every module (via
* `X-Environment`) AND retargets chain/RPC/API at once.
*/
import { useMemo, useState, type ReactNode } from 'react'
import { useRouter } from 'next/navigation'
import { useMemo, useState } from 'react'
import { Button, Popover, Text, XStack, YStack } from '@hanzo/gui'
import { Check, ChevronsUpDown, FolderGit2, Layers, Plus, Trash } from '@hanzogui/lucide-icons-2'
import { Check, ChevronsUpDown, Layers, Plus, Trash } from '@hanzogui/lucide-icons-2'
import { useScope } from '~/lib/scope-context'
import { STOCK_ENVIRONMENTS } from '~/lib/scope'
import { isStockNetwork, parseCustomNetwork, type Network } from '~/lib/network'
import { FieldText } from '~/components/ui/Field'
import { MenuRow, type DotColor } from '~/components/ui/MenuRow'
import { paper } from '~/components/ui/paper'
/** A small dot keyed to the network tier. Monochrome by default; only the genuine
* states carry a hue — mainnet is live (green), testnet is a caution (amber). Every
* other tier is a neutral off the design ladder (Tamagui $colorN). */
type DotColor = '$green10' | '$yellow10' | '$color10' | '$color9' | '$color8'
const NET_DOT: Record<string, DotColor> = {
mainnet: '$green10',
testnet: '$yellow10',
@@ -41,59 +44,6 @@ const titleCase = (s: string) => (s ? s[0].toUpperCase() + s.slice(1) : s)
const idSub = (n: Network): string =>
n.networkID === n.evmChainID ? `Chain ${n.evmChainID}` : `Net ${n.networkID} · Chain ${n.evmChainID}`
function ProjectPicker() {
const router = useRouter()
const { scope, projects, loadingProjects, selectProject } = useScope()
const label = scope.project ? scope.project : 'All projects'
return (
<Popover placement="bottom-end">
<Popover.Trigger asChild>
<Button size="$2" chromeless icon={<FolderGit2 size={14} />} iconAfter={<ChevronsUpDown size={13} />}>
{label}
</Button>
</Popover.Trigger>
<Popover.Content {...paper} p="$2" width={260}>
<YStack gap="$0.5">
<Text px="$2" py="$1" fontSize="$1" color="$color10" fontWeight="500">
Project
</Text>
{/* Org-level scope — no X-Project-Id sent. */}
<Row
label="All projects"
sub="Org-level"
active={!scope.project}
onPress={() => selectProject(undefined)}
/>
{projects.map((p) => (
<Row
key={p.name}
label={p.displayName || p.name}
active={scope.project === p.name}
onPress={() => selectProject(p.name)}
/>
))}
{projects.length === 0 && !loadingProjects ? (
<Text px="$2" py="$1.5" fontSize="$2" color="$color10">
No projects yet.
</Text>
) : null}
<XStack height={1} bg="$borderColor" my="$1" />
<Row
label="New project"
icon={<Plus size={14} />}
onPress={() => router.push('/projects')}
/>
</YStack>
</Popover.Content>
</Popover>
)
}
const EMPTY_FORM = { label: '', networkID: '', evmChainID: '', rpcEndpoint: '', apiEndpoint: '' }
function AddNetworkForm({
@@ -200,7 +150,7 @@ function NetworkPicker() {
Project environments
</Text>
{extraEnvs.map((env) => (
<Row
<MenuRow
key={env}
label={titleCase(env)}
sub="Custom"
@@ -216,7 +166,7 @@ function NetworkPicker() {
{adding ? (
<AddNetworkForm taken={takenCustomIds} onAdd={add} onCancel={() => setAdding(false)} />
) : (
<Row label="Add custom network" icon={<Plus size={14} />} onPress={() => setAdding(true)} />
<MenuRow label="Add custom network" icon={<Plus size={14} />} onPress={() => setAdding(true)} />
)}
</YStack>
</Popover.Content>
@@ -263,54 +213,10 @@ function NetworkRow({
)
}
/** One selectable row in a picker popover. */
function Row({
label,
sub,
dot,
icon,
active,
onPress,
}: {
label: string
sub?: string
dot?: DotColor
icon?: ReactNode
active?: boolean
onPress: () => void
}) {
return (
<XStack
onPress={onPress}
cursor="pointer"
items="center"
gap="$2"
px="$2"
py="$2"
rounded="$3"
hoverStyle={{ bg: '$color4' }}
>
{dot ? <YStack width={8} height={8} rounded="$10" bg={dot} /> : icon}
<YStack flex={1}>
<Text fontSize="$2" color="$color12" numberOfLines={1}>
{label}
</Text>
{sub ? (
<Text fontSize="$1" color="$color10">
{sub}
</Text>
) : null}
</YStack>
{active ? <Check size={14} /> : null}
</XStack>
)
}
/** Project + network pickers as a unit (topbar). */
/** The network picker (topbar). Org + project live in `ContextSwitcher`, top-left. */
export function ScopeSwitcher() {
return (
<XStack items="center" gap="$1">
<ProjectPicker />
<XStack items="center" gap="$1" data-testid="switcher-network">
<NetworkPicker />
</XStack>
)
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import { identityTraits } from './Analytics'
import { type Account } from '~/lib/api/types'
const account = (over: Partial<Account> = {}): Account => ({
owner: 'hanzo',
name: 'z',
userId: 'sub-1',
...over,
})
describe('identityTraits', () => {
it('carries the email and the human name off the IAM claims', () => {
expect(
identityTraits(account({ email: 'z@hanzo.ai', displayName: 'Z Hanzo' })),
).toEqual({ email: 'z@hanzo.ai', name: 'Z Hanzo' })
})
it('falls back to the login handle when no display name was claimed', () => {
expect(identityTraits(account({ email: 'z@hanzo.ai' }))).toEqual({
email: 'z@hanzo.ai',
name: 'z',
})
})
// An absent claim must be ABSENT, not `undefined`: a trait sent as undefined
// is a trait written, and it would blank a value an earlier identify had set.
it('omits a key it has no claim for rather than sending undefined', () => {
const traits = identityTraits(account({ displayName: 'Z Hanzo' }))
expect(traits).toEqual({ name: 'Z Hanzo' })
expect('email' in traits).toBe(false)
})
// The tenant is stamped server-side from the validated bearer. A tenant the
// client can name is a tenant the client can get wrong.
it('never sends the org', () => {
const traits = identityTraits(
account({ email: 'z@hanzo.ai', organization: 'hanzo', owner: 'hanzo' }),
)
expect(traits).not.toHaveProperty('org')
expect(traits).not.toHaveProperty('organization')
expect(traits).not.toHaveProperty('owner')
})
})
+7 -3
View File
@@ -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}
+23 -3
View File
@@ -14,26 +14,46 @@ import { PrimaryButton } from '~/components/ui/PrimaryButton'
import { ONBOARDING_STEPS, type StepId, type StepStatus } from '~/lib/onboarding/steps'
/** 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}>
@@ -121,7 +121,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 +259,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>
)
}
@@ -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>
)
}
+21 -17
View File
@@ -7,8 +7,8 @@
* browser — the console never sees a PAN. REAL: `BillingApi.paymentConfig` mounts the
* element (`useSquareCard`), `createPaymentMethod({token})` vaults the card (the
* commerce handler grants/extends the trial credit as a side-effect, $1 verify-then-
* void, no charge), `welcome()` claims the fixed starter grant (idempotent), and
* `balance()` shows the granted balance. Skippable — credits can be added later.
* void, no charge), and `balance()` shows the granted balance. Skippable — credits
* can be added later.
*/
import { useEffect, useRef, useState } from 'react'
import { Card, Spinner, Text, XStack, YStack } from '@hanzo/gui'
@@ -70,9 +70,9 @@ export function CreditsStep({ next, skip, back, isFirst }: StepProps) {
try {
const token = await card.tokenize()
await BillingApi.createPaymentMethod({ type: 'card', token })
// Claim the fixed starter grant too (idempotent server-side); the card-added
// handler also extends the trial — both are safe to run.
await BillingApi.welcome().catch(() => undefined)
// The trial credit is granted SERVER-SIDE as a side-effect of vaulting the card.
// The browser never mints its own credit — the only credit mint is the
// mint-gated POST /v1/billing/credit, which a tenant session cannot call.
const bal = await BillingApi.balance().catch(() => balance)
if (!mounted.current) return
setBalance(bal)
@@ -91,7 +91,21 @@ export function CreditsStep({ next, skip, back, isFirst }: StepProps) {
const split = balanceSplitLabel(balance)
return (
<StepShell title="Free trial credits" subtitle="Add a card to unlock free trial credits. No charge now — it just keeps your account ready when the trial ends.">
<StepShell
title="Free trial credits"
subtitle="Add a card to unlock free trial credits. No charge now — it just keeps your account ready when the trial ends."
actions={
<StepActions
onBack={isFirst ? undefined : back}
onSkip={phase === 'ready' && !isUnlocked ? skip : undefined}
skipLabel="Skip for now"
onContinue={next}
continueLabel="Continue"
continueDisabled={phase === 'ready' && !isUnlocked}
busy={adding}
/>
}
>
{phase === 'loading' ? (
<Card p="$5" items="center" borderWidth={1} borderColor="$borderColor">
<Spinner size="large" color="$color11" />
@@ -121,7 +135,7 @@ export function CreditsStep({ next, skip, back, isFirst }: StepProps) {
<XStack gap="$2" items="center">
<CreditCard size={18} color="var(--color10)" />
<Text fontSize="$4" fontWeight="700" color="$color12">
Payments aren't set up on this deployment
Payments aren't set up for this organization
</Text>
</XStack>
<Text fontSize="$3" color="$color11">
@@ -195,16 +209,6 @@ export function CreditsStep({ next, skip, back, isFirst }: StepProps) {
</XStack>
</Card>
)}
<StepActions
onBack={isFirst ? undefined : back}
onSkip={phase === 'ready' && !isUnlocked ? skip : undefined}
skipLabel="Skip for now"
onContinue={next}
continueLabel="Continue"
continueDisabled={phase === 'ready' && !isUnlocked}
busy={adding}
/>
</StepShell>
)
}
+15 -11
View File
@@ -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>
)
}
+15 -9
View File
@@ -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()
@@ -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 workspace"
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">
@@ -95,13 +108,6 @@ export function TeamStep({ next, back, isFirst }: StepProps) {
Invite teammates and switch workspaces anytime from the top bar.
</Text>
</XStack>
<StepActions
onBack={isFirst ? undefined : back}
onContinue={() => void commit()}
continueLabel="Continue"
busy={busy}
/>
</StepShell>
)
}
@@ -4,7 +4,7 @@
* Annotation Queues list of review queues (HIP-0106), native on @hanzo/gui.
*
* An annotation queue is a named work queue of traces/observations to review and
* score against a set of score configs. Reads the REAL `/v1/o11y/annotation-queues`
* score against a set of rubrics. Reads the REAL `/v1/o11y/reviews`
* surface; when the runtime is not initialized (503) or unrouted (404) it shows an
* honest RuntimeNotice never fabricated queues. Read-only list here; items are
* worked in the annotation flow.
+4 -4
View File
@@ -1,12 +1,12 @@
'use client'
/**
* API Keys create, copy, rotate, and revoke the per-user `hk-` Cloud API key.
* API Keys create, copy, rotate, and revoke the per-user `sk-` Cloud API key.
*
* The key is minted server-side (`/keys` route IAM, app-on-behalf as the
* confidential console client); the browser only sends its session cookie and
* never holds a long-lived secret beyond the one-time reveal at creation. This
* is the real credential the user presents as `Authorization: Bearer hk-…` to
* is the real credential the user presents as `Authorization: Bearer sk-…` to
* the SDKs, CLI, and the api.hanzo.ai gateway.
*
* `ApiKeysView` is the bare surface so Settings can embed it as a tab;
@@ -190,7 +190,7 @@ export function ApiKeysView() {
Cloud API key
</Text>
<Text fontSize="$2" color="$color10" style={{ fontFamily: 'monospace' }}>
{status?.keyPrefix ? `${status.keyPrefix}` : 'hk-…'}
{status?.keyPrefix ? `${status.keyPrefix}` : 'sk-…'}
</Text>
{fmtKeyDate(status?.createdAt) ? (
<Text fontSize="$1" color="$color10">
@@ -233,7 +233,7 @@ export function ApiKeysView() {
</XStack>
<Text fontSize="$3" color="$color11">
One key for your account, scoped to your organization. Use it as{' '}
<Text fontSize="$2" style={{ fontFamily: 'monospace' }}>Authorization: Bearer hk-</Text> with the SDKs,
<Text fontSize="$2" style={{ fontFamily: 'monospace' }}>Authorization: Bearer sk-</Text> with the SDKs,
CLI, and the api.hanzo.ai gateway.
</Text>
<Button
+1 -1
View File
@@ -13,7 +13,7 @@
* Reports cost breakdown by service (model/provider), filterable + charts.
* Accounts which account pays and in what order: attach an account to the
* org or to one project, and reorder the chain commerce resolves.
* Budgets create/list spend budgets (real `/v1/billing/spend-alerts`).
* Budgets create/list spend budgets (real `/v1/billing/alerts`).
* Invoices invoice history + download (`/v1/billing/invoices`).
* Subscriptions the org's plans/status/renewal (reuses `SubscriptionsModule`).
* Payment saved, masked payment methods (reuses `PaymentMethodsModule`).
+1 -1
View File
@@ -528,7 +528,7 @@ export function CapTableModule({ params }: { params: Record<string, string> }) {
) : null}
{active === 'classes' ? (
classes.phase === 'error' ? <BackendStateCard state={classes.error} onRetry={refreshClasses} hint="endpoint · GET /v1/captable/share-classes" />
classes.phase === 'error' ? <BackendStateCard state={classes.error} onRetry={refreshClasses} hint="endpoint · GET /v1/captable/classes" />
: classes.phase === 'ready' && classes.data.length === 0 ? (
<EmptyState icon={Layers} title="No share classes yet" description="Create a share class (Common, Preferred) — certificates and rounds reference it." primary={{ label: 'New share class', onPress: () => setDialog({ kind: 'class' }) }} />
) : <DataTable<ShareClass> columns={classCols} rows={classList} loading={classes.phase === 'loading'} rowKey={(c) => c.id} empty="No share classes yet." />
+4 -1
View File
@@ -167,7 +167,10 @@ function LinkOut({ href, label }: { href: string; label?: string }) {
<XStack
items="center"
gap="$1"
tag="a"
// `render`, not `tag`: gui 8 renamed the host-element prop, and gui drops a prop
// it does not know without erroring — `tag="a"` type-checked, built, and shipped
// a <div>, so this chip was an inert link that nothing reported.
render="a"
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{...({ href, target: '_blank', rel: 'noopener noreferrer' } as any)}
>
+4 -2
View File
@@ -124,10 +124,12 @@ export function ContactModule() {
function ChannelCard({ channel }: { channel: Channel }) {
const { icon: Icon, title, body, href, cta } = channel
// The Card IS the flex item — and the link, via tag="a" (Stacks pass href/target
// The Card IS the flex item — and the link, via render="a" (Stacks pass href/target
// through on web). Keeps layout on one node; no non-flex Anchor wrapper.
// `render`, not gui 7's `tag`: gui drops an unknown prop silently, so `tag` rendered
// a <div> and every contact channel — mailto included — was a dead card.
const link = href
? { tag: 'a' as const, href, target: href.startsWith('mailto:') ? undefined : '_blank', rel: 'noopener noreferrer', cursor: 'pointer' as const, textDecorationLine: 'none' as const }
? { render: 'a' as const, href, target: href.startsWith('mailto:') ? undefined : '_blank', rel: 'noopener noreferrer', cursor: 'pointer' as const, textDecorationLine: 'none' as const }
: {}
return (
<Card
+4 -5
View File
@@ -5,7 +5,7 @@
* feature). Wired to the REAL cloud `/v1/evals/*` facade, which proxies the
* console's public dataset API:
* - POST /v1/evals/datasets create a dataset
* - POST /v1/evals/dataset-items add an item (input + expected output)
* - POST /v1/evals/datasets/:name/items add an item (input + expected output)
*
* The gateway does not mount a dataset LIST route yet, so the list area attempts
* the forward-compatible GET and renders an honest "not available here yet" card
@@ -122,8 +122,7 @@ export function DatasetsModule(_props: { params: Record<string, string> }) {
setAdding(true)
setAddMsg(null)
try {
await EvalsApi.createDatasetItem({
datasetName: itemDataset.trim(),
await EvalsApi.createDatasetItem(itemDataset.trim(), {
input: parseMaybeJson(input),
expectedOutput: parseMaybeJson(expected),
})
@@ -265,7 +264,7 @@ export function DatasetItemsModule(_props: { params: Record<string, string> }) {
const load = useCallback(() => {
setList({ phase: 'loading' })
// Native dataset-items require a datasetName; fetch the org's datasets, then
// A dataset item only exists inside a set; fetch the org's datasets, then
// their items in parallel, and flatten — real rows, never fabricated.
EvalsApi.listDatasets()
.then((datasets) =>
@@ -307,7 +306,7 @@ export function DatasetItemsModule(_props: { params: Record<string, string> }) {
}
/>
{list.phase === 'error' ? (
<BackendStateCard state={list.error} onRetry={load} hint="endpoint · GET /v1/evals/dataset-items" />
<BackendStateCard state={list.error} onRetry={load} hint="endpoint · GET /v1/evals/datasets/:name/items" />
) : (
<DataTable
columns={itemColumns}
-252
View File
@@ -1,252 +0,0 @@
'use client'
/**
* ML Pipelines (Kubeflow) the read-only orchestration + control-plane view over
* the live cloud mlsvc bridge (hanzoai/cloud `clients/ml`), which fronts the
* Kubeflow-family CRDs as REST through the console's OWN `/training` proxy.
*
* This is the Kubeflow *orchestration* lens (distinct from Fine-tuning, which is
* the "train a model on my data" wizard): a real control-plane health strip
* (`GET /v1/train/health` which Kubeflow operators/CRDs are actually served),
* then the two orchestration objects the bridge exposes
* - Pipelines = Katib Experiments (`GET /v1/train/experiments`)
* - Runs = trainer TrainJobs (`GET /v1/train/jobs`)
* both fetched via the shared `TrainApi` (one client, no duplication).
*
* Honest by construction: EVERY row is a real backend object. A load failure
* renders a truthful `BackendStateCard` (503 not-initialized · 404 not-routed ·
* 401/403 session/access) instead of placeholder rows; a reachable-but-empty
* plane renders an honest empty state. Nothing is fabricated the console has
* no Kubeflow Notebook/Pipeline-CRD surface, so none is invented here.
*/
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Button, Text, XStack, YStack } from '@hanzo/gui'
import { Blocks, Play, RefreshCw, Workflow } from '@hanzogui/lucide-icons-2'
import { TrainApi, type TrainExperiment, type TrainJob } from '~/lib/api'
import { KubeflowApi, KUBEFLOW_OPERATORS, type KubeflowControlPlane } from '~/lib/api/kubeflow'
import { PageHeader } from '~/components/ui/PageHeader'
import { DataTable, type Column } from '~/components/ui/DataTable'
import { StatusTag } from '~/components/ui/StatusTag'
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
type Async<T> = { phase: 'loading' } | { phase: 'error'; error: BackendState } | { phase: 'ready'; data: T }
const TABS = [
{ id: 'pipelines', label: 'Pipelines', icon: Workflow },
{ id: 'runs', label: 'Runs', icon: Play },
] as const
type TabId = (typeof TABS)[number]['id']
const DASH = '—'
const fmtDate = (v?: string) => {
if (!v) return DASH
const d = new Date(v)
return Number.isNaN(d.getTime()) ? v : d.toLocaleString()
}
const fmtNum = (n?: number, digits = 3) => (typeof n === 'number' && Number.isFinite(n) ? n.toFixed(digits) : DASH)
// ── Control-plane strip ───────────────────────────────────────────────────────
// A compact, honest one-liner: whether the Kubeflow control plane is reporting,
// and which operators (Trainer/Katib) are live. The lists below carry the
// primary state, so this stays understated — never a big error card.
function ControlPlaneStrip({ cp }: { cp: Async<KubeflowControlPlane> }) {
if (cp.phase !== 'ready') return null
const { healthy, k8s, error } = cp.data
const reporting = k8s
const dot = healthy ? '$green10' : reporting ? '$yellow10' : '$color8'
const label = healthy
? 'Kubeflow control plane · connected'
: reporting
? 'Kubeflow control plane · degraded'
: 'Kubeflow control plane · not reporting'
return (
<XStack
items="center"
gap="$3"
px="$3"
py="$2"
rounded="$4"
bg="$color2"
borderWidth={1}
borderColor="$borderColor"
flexWrap="wrap"
>
<XStack items="center" gap="$2">
<YStack width={8} height={8} rounded="$10" bg={dot} />
<Text fontSize="$2" color="$color11">
{label}
</Text>
</XStack>
{reporting ? (
<XStack items="center" gap="$2" flexWrap="wrap">
{KUBEFLOW_OPERATORS.map((op) => {
const up = cp.data.crds[op.crd] === true
return (
<XStack key={op.crd} items="center" gap="$1.5">
<YStack width={6} height={6} rounded="$10" bg={up ? '$green10' : '$color8'} />
<Text fontSize="$1" color="$color10">
{op.label}
</Text>
<Text fontSize="$1" color="$color9" style={{ fontFamily: 'monospace' }}>
{op.crd}
</Text>
</XStack>
)
})}
</XStack>
) : error ? (
<Text fontSize="$1" color="$color10" numberOfLines={1}>
{error}
</Text>
) : null}
</XStack>
)
}
// ── Column sets ───────────────────────────────────────────────────────────────
const pipelineColumns: Column<TrainExperiment>[] = [
{
key: 'name',
header: 'Pipeline',
render: (e) => (
<YStack minW={0}>
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>
{e.name || e.id}
</Text>
<Text fontSize="$1" color="$color10" numberOfLines={1} style={{ fontFamily: 'monospace' }}>
{e.id}
</Text>
</YStack>
),
},
{ key: 'job', header: 'Job', width: 160, render: (e) => <Text fontSize="$3" color="$color11" numberOfLines={1}>{e.jobId || DASH}</Text> },
{ key: 'trials', header: 'Trials', width: 90, render: (e) => <Text fontSize="$3" color="$color11">{e.metrics.length || DASH}</Text> },
{ key: 'val', header: 'val_loss', width: 100, render: (e) => <Text fontSize="$3" color="$color11">{fmtNum(e.finalValLoss)}</Text> },
{ key: 'status', header: 'Status', width: 110, render: (e) => <StatusTag status={e.status ?? 'unknown'} /> },
{ key: 'created', header: 'Created', width: 170, render: (e) => <Text fontSize="$3" color="$color11" numberOfLines={1}>{fmtDate(e.createdAt)}</Text> },
]
const runColumns: Column<TrainJob>[] = [
{
key: 'name',
header: 'Run',
render: (j) => (
<YStack minW={0}>
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>
{j.name || j.id}
</Text>
<Text fontSize="$1" color="$color10" numberOfLines={1} style={{ fontFamily: 'monospace' }}>
{j.id}
</Text>
</YStack>
),
},
{ key: 'base', header: 'Base model', width: 180, render: (j) => <Text fontSize="$3" color="$color11" numberOfLines={1}>{j.baseModel || DASH}</Text> },
{ key: 'type', header: 'Type', width: 110, render: (j) => <Text fontSize="$3" color="$color11" numberOfLines={1}>{j.type || DASH}</Text> },
{ key: 'gpu', header: 'GPU', width: 120, render: (j) => <Text fontSize="$3" color="$color11" numberOfLines={1}>{j.gpu ? `${j.gpu}${j.gpuCount && j.gpuCount > 1 ? ` ×${j.gpuCount}` : ''}` : DASH}</Text> },
{ key: 'status', header: 'Status', width: 110, render: (j) => <StatusTag status={j.status} /> },
{ key: 'created', header: 'Created', width: 170, render: (j) => <Text fontSize="$3" color="$color11" numberOfLines={1}>{fmtDate(j.createdAt)}</Text> },
]
// ── Module ────────────────────────────────────────────────────────────────────
export function KubeflowModule(_props: { params: Record<string, string> }) {
const [cp, setCp] = useState<Async<KubeflowControlPlane>>({ phase: 'loading' })
const [pipelines, setPipelines] = useState<Async<TrainExperiment[]>>({ phase: 'loading' })
const [runs, setRuns] = useState<Async<TrainJob[]>>({ phase: 'loading' })
const [tab, setTab] = useState<TabId>('pipelines')
const load = useCallback(() => {
setCp({ phase: 'loading' })
setPipelines({ phase: 'loading' })
setRuns({ phase: 'loading' })
// controlPlane() never throws — the .catch is a belt-and-suspenders no-op.
KubeflowApi.controlPlane()
.then((data) => setCp({ phase: 'ready', data }))
.catch((e) => setCp({ phase: 'ready', data: { healthy: false, k8s: false, crds: {}, error: e instanceof Error ? e.message : String(e) } }))
TrainApi.experiments()
.then((data) => setPipelines({ phase: 'ready', data }))
.catch((e) => setPipelines({ phase: 'error', error: classifyBackend(e) }))
TrainApi.listJobs()
.then((data) => setRuns({ phase: 'ready', data }))
.catch((e) => setRuns({ phase: 'error', error: classifyBackend(e) }))
}, [])
useEffect(() => load(), [load])
const count = useCallback(
(t: TabId): number | null => {
const src = t === 'pipelines' ? pipelines : runs
return src.phase === 'ready' ? src.data.length : null
},
[pipelines, runs],
)
const active = tab === 'pipelines' ? pipelines : runs
const hint = useMemo(
() =>
tab === 'pipelines'
? 'Pipelines are Katib Experiments from the Kubeflow bridge (GET /v1/train/experiments).'
: 'Runs are trainer TrainJobs from the Kubeflow bridge (GET /v1/train/jobs).',
[tab],
)
return (
<>
<PageHeader
title="ML Pipelines"
subtitle="Orchestrated training and evaluation pipelines on Kubeflow — Katib experiments and trainer runs."
actions={
<Button icon={<RefreshCw size={16} />} onPress={load}>
Refresh
</Button>
}
/>
<ControlPlaneStrip cp={cp} />
<XStack gap="$1" flexWrap="wrap">
{TABS.map((t) => {
const n = count(t.id)
const Icon = t.icon
return (
<Button
key={t.id}
size="$2"
bg={t.id === tab ? '$color5' : 'transparent'}
borderWidth={1}
borderColor="$borderColor"
icon={<Icon size={14} />}
onPress={() => setTab(t.id)}
>
{n === null ? t.label : `${t.label} · ${n}`}
</Button>
)
})}
</XStack>
{active.phase === 'error' ? (
<BackendStateCard state={active.error} onRetry={load} hint={hint} />
) : tab === 'pipelines' ? (
<DataTable
columns={pipelineColumns}
rows={pipelines.phase === 'ready' ? pipelines.data : []}
loading={pipelines.phase === 'loading'}
rowKey={(e) => e.id}
empty="No pipelines yet. Katib experiments you orchestrate appear here."
/>
) : (
<DataTable
columns={runColumns}
rows={runs.phase === 'ready' ? runs.data : []}
loading={runs.phase === 'loading'}
rowKey={(j) => j.id}
empty="No runs yet. Trainer TrainJobs appear here as they are submitted."
/>
)}
</>
)
}
@@ -6,9 +6,9 @@
*
* FULL CRUD over the unified cloud binary via the same-origin user-bearer `/v1`
* proxy, org resolved from the Bearer owner:
* - GET /v1/load-balancers list
* - POST /v1/load-balancers create (name + type + region)
* - DELETE /v1/load-balancers/:id delete
* - GET /v1/balancers list
* - POST /v1/balancers create (name + type + region)
* - DELETE /v1/balancers/:id delete
*
* When the backend doesn't serve the surface the list load fails and the honest
* not-configured / unavailable card renders instead of an empty grid; create/delete
@@ -66,7 +66,7 @@ export function LoadBalancerModule(_props: { params: Record<string, string> }) {
const load = useCallback(async () => {
setLoading(true)
try {
const r = await restGet<unknown>(cloudProxyV1Url('load-balancers'))
const r = await restGet<unknown>(cloudProxyV1Url('balancers'))
setRows(lbsOf(r))
setLoadError(null)
} catch (e) {
@@ -89,7 +89,7 @@ export function LoadBalancerModule(_props: { params: Record<string, string> }) {
setCreating(true)
setActionMsg(null)
try {
await restPost(cloudProxyV1Url('load-balancers'), { name: name.trim(), type, region })
await restPost(cloudProxyV1Url('balancers'), { name: name.trim(), type, region })
setActionMsg({ tone: 'ok', text: `Created load balancer "${name.trim()}".` })
setName('')
await load()
@@ -104,7 +104,7 @@ export function LoadBalancerModule(_props: { params: Record<string, string> }) {
if (typeof window !== 'undefined' && !window.confirm(`Delete load balancer "${lb.name || lb.id}"? This cannot be undone.`)) return
setActionMsg(null)
try {
await restDelete(cloudProxyV1Url(`load-balancers/${enc(lb.id)}`))
await restDelete(cloudProxyV1Url(`balancers/${enc(lb.id)}`))
setActionMsg({ tone: 'ok', text: `Deleted load balancer "${lb.name || lb.id}".` })
await load()
} catch (e) {
+71 -2
View File
@@ -40,11 +40,13 @@ import {
groupByFamily,
filterFamilies,
familyOf,
suggestedModels,
totalModels,
displayLabel,
DEFAULT_MODEL,
type FamilyGroup,
} from '~/lib/api/families'
import { useRecentModels } from '~/lib/models/recent'
import { ProviderLogo } from '~/components/ui/ProviderLogo'
import { PageHeader } from '~/components/ui/PageHeader'
import { Filters } from '~/components/ui/Filters'
@@ -391,6 +393,50 @@ function CatalogSkeleton() {
)
}
/** One tappable model chip — the family mark + the model's display name. */
function ModelChip({ m, onOpen }: { m: CatalogEntry; onOpen: () => void }) {
return (
<Button size="$2" icon={<ProviderLogo provider={familyOf(m).logo} size={15} />} onPress={onOpen}>
{displayLabel(m)}
</Button>
)
}
/** Recent + Suggested the reach-first chips above the full family list. Recents
* are the user's own trail; suggestions are one live rung per pinned family. An
* empty section renders nothing never a fabricated chip. */
function ShortcutStrip({
recents,
suggested,
onOpen,
}: {
recents: CatalogEntry[]
suggested: CatalogEntry[]
onOpen: (m: CatalogEntry) => void
}) {
if (!recents.length && !suggested.length) return null
return (
<XStack items="center" gap="$2" flexWrap="wrap">
{recents.length ? (
<Text fontSize="$1" color="$color10" fontWeight="500">
Recent
</Text>
) : null}
{recents.map((m) => (
<ModelChip key={modelId(m)} m={m} onOpen={() => onOpen(m)} />
))}
{suggested.length ? (
<Text fontSize="$1" color="$color10" fontWeight="500" ml={recents.length ? '$2' : undefined}>
Suggested
</Text>
) : null}
{suggested.map((m) => (
<ModelChip key={modelId(m)} m={m} onOpen={() => onOpen(m)} />
))}
</XStack>
)
}
type LoadState =
| { phase: 'loading' }
| { phase: 'error'; err: ApiError }
@@ -402,7 +448,7 @@ export function ModelCatalogModule(_props: { params: Record<string, string> }) {
const [plans, setPlans] = useState<Plan[]>([])
const [selected, setSelected] = useState<CatalogEntry | null>(null)
// The user's own view of this catalog, persisted with the rest of their
// preferences — 85+ models across a dozen families is a list you narrow once and
// preferences — the catalog across a dozen families is a list you narrow once and
// want to find narrowed the same way, on any device. It is never hidden state:
// the bar renders the query and offers Reset the moment anything is active.
const list = useList('models')
@@ -425,6 +471,23 @@ export function ModelCatalogModule(_props: { params: Record<string, string> }) {
const groups = useMemo(() => groupByFamily(models), [models])
const visible = useMemo(() => filterFamilies(groups, query), [groups, query])
// Opening a model records the use, so the Recent chips are the user's real
// trail — the same trail chat writes when a turn is sent.
const { recent, record } = useRecentModels()
const openModel = useCallback(
(m: CatalogEntry) => {
record(modelId(m))
setSelected(m)
},
[record],
)
const shortcuts = useMemo(() => {
const byId = new Map(models.map((x) => [modelId(x).toLowerCase(), x]))
// A recent id whose model left the catalog silently drops — honest absence.
const recents = recent.map((id) => byId.get(id.toLowerCase())).filter((x): x is CatalogEntry => x !== undefined)
return { recents, suggested: suggestedModels(groups, recents.map((x) => modelId(x))) }
}, [models, groups, recent])
const stats = useMemo(() => {
const shown = totalModels(visible)
const live = visible.reduce((n, g) => n + g.available, 0)
@@ -467,6 +530,12 @@ export function ModelCatalogModule(_props: { params: Record<string, string> }) {
{/* Search across all families — the ONE list bar, not a fourth search box. */}
<Filters list={list} placeholder="Search models across every family…" />
{/* Reach first: the user's recent models, then one suggestion per house/
flagship family hidden while searching (the query owns the page). */}
{state.phase === 'ready' && !query ? (
<ShortcutStrip recents={shortcuts.recents} suggested={shortcuts.suggested} onOpen={openModel} />
) : null}
{state.phase === 'loading' ? (
<CatalogSkeleton />
) : visible.length === 0 ? (
@@ -480,7 +549,7 @@ export function ModelCatalogModule(_props: { params: Record<string, string> }) {
<YStack gap="$2.5">
{visible.map((g, i) => (
<FadeIn key={g.id} index={i} step={40}>
<FamilySection group={g} onOpen={setSelected} />
<FamilySection group={g} onOpen={openModel} />
</FadeIn>
))}
</YStack>
@@ -20,7 +20,7 @@
* on this deployment (`available`) or already connected are listed, so every card
* has a live Connect/Disconnect action no dead-end.
*/
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { Button, Card, Spinner, Text, XStack, YStack } from '@hanzo/gui'
import { Cable, Plug, RefreshCw, GitBranch } from '@hanzogui/lucide-icons-2'
@@ -151,21 +151,30 @@ export function OrgIntegrationsModule(_props: { params: Record<string, string> }
// Handle the OAuth callback return. The provider callback 302s the browser to
// /integrations?connected=<id>&account=<label> (success) or ?error=<id>&reason=<msg>.
// Read the primitives (stable by value) so this runs once per return; the guard +
// the router.replace that strips the params keep it from re-toasting on refetch.
//
// The latch is what makes this ONE-SHOT, and it is not belt-and-braces. Stripping
// the params with router.replace cannot do it alone: the replace is asynchronous, so
// every render between raising the toast and the URL actually changing still reads
// `connected` and would toast again. This effect also calls load(), whose state
// updates cause exactly those renders. A latch says "this return was handled" once
// and stays true regardless of how many times the effect is re-entered, or why.
const connectedId = search.get('connected')
const erroredId = search.get('error')
const account = search.get('account')
const reason = search.get('reason')
const handledReturn = useRef(false)
useEffect(() => {
if (!connectedId && !erroredId) return
if (handledReturn.current) return
handledReturn.current = true
if (connectedId) {
toast.success(`Connected ${connectedId}`, account ? `Account: ${account}` : undefined)
load()
} else if (erroredId) {
toast.error(`Could not connect ${erroredId}`, reason || 'The connection could not be completed.')
}
// Strip the callback params so a refresh/refetch doesn't re-toast.
// The latch covers this mount; stripping the params covers the NEXT one, so a
// refresh or a back-navigation does not replay a connection that already happened.
router.replace('/integrations')
}, [connectedId, erroredId, account, reason, toast, router, load])
@@ -8,8 +8,8 @@
* - ADD: the card is entered ONLY in Square's cross-origin iframe (the shared
* `useSquareCard` capability, same as Add-credits) and tokenized IN THE BROWSER.
* We POST only the opaque single-use nonce (`token`) the RAW PAN never touches
* our code, servers, or logs (PCI SAQ-A). `POST /v1/billing/payment-methods`.
* - REMOVE: a per-row detach with a confirm `DELETE /v1/billing/payment-methods/:id`.
* our code, servers, or logs (PCI SAQ-A). `POST /v1/billing/methods`.
* - REMOVE: a per-row detach with a confirm `DELETE /v1/billing/methods/:id`.
* Commerce authorizes the delete against the caller's server-pinned subject.
*
* Read-only + masked by construction on display: commerce returns ONLY a
@@ -262,15 +262,15 @@ export function PaymentMethodsModule(_props: { params: Record<string, string> })
</Text>
</Card>
) : cfg.phase === 'error' ? (
<BackendStateCard state={cfg.error} hint="endpoint · GET /v1/billing/payment-config" />
<BackendStateCard state={cfg.error} hint="endpoint · GET /v1/billing/settings" />
) : !configured ? (
<Card p="$4" gap="$2" borderWidth={1} borderColor="$borderColor" maxW={560}>
<Text fontSize="$4" fontWeight="700" color="$color12">
Adding a card isnt available on this deployment yet
No card processor is configured for this organization
</Text>
<Text fontSize="$3" color="$color11">
No card processor is configured for this organization. You can manage payment methods in the
billing portal, or contact support to enable in-console cards.
Adding a card in-console needs a payment processor connected to your organization. You can
manage payment methods in the billing portal, or contact support to enable in-console cards.
</Text>
<XStack>
<Button size="$3" iconAfter={<ExternalLink size={14} />} onPress={openBillingPortal}>
@@ -348,7 +348,7 @@ export function PaymentMethodsModule(_props: { params: Record<string, string> })
) : null}
{methods.phase === 'error' ? (
<BackendStateCard state={methods.error} onRetry={load} hint="endpoint · GET /v1/billing/payment-methods" />
<BackendStateCard state={methods.error} onRetry={load} hint="endpoint · GET /v1/billing/methods" />
) : (
<DataTable
columns={columns}
+114 -14
View File
@@ -6,18 +6,18 @@
* Reads the REAL account from the session (`get-account`): identity, email, org,
* and role. Identity mutations (name, password, 2FA) are owned by Hanzo IAM this
* deep-links there rather than re-implementing them (never a plaintext password
* here). The API Keys tab embeds the shared per-user `hk-` credential surface.
* here). The API Keys tab embeds the shared per-user `sk-` credential surface.
* Reached from the footer wallet's user row; also carries Sign out.
*/
import { SubNav } from '~/components/ui/SubNav'
import { productSubpageSlug } from '~/lib/products/match'
import { useState } from 'react'
import { useRef, useState } from 'react'
import { Avatar, Button, Card, Input, Spinner, Text, XStack, YStack } from '@hanzo/gui'
import { Check, Copy, ExternalLink, KeyRound, LogOut, ShieldCheck } from '@hanzogui/lucide-icons-2'
import { Camera, Check, Copy, ExternalLink, KeyRound, LogOut, ShieldCheck } from '@hanzogui/lucide-icons-2'
import { config } from '~/config'
import { useSession } from '~/lib/auth/session'
import { ApiError } from '~/lib/api'
import { AccountApi, ApiError } from '~/lib/api'
import { MfaApi, type MfaSetup } from '~/lib/api/mfa'
import { PageHeader } from '~/components/ui/PageHeader'
import { FieldRow } from '~/components/ui/Field'
@@ -53,24 +53,124 @@ function ManageInIam({ label = 'Edit in IAM' }: { label?: string }) {
)
}
/**
* The profile photo, and the control that changes it.
*
* The photo used to be read-only here the card rendered `avatar` and offered
* "Edit in IAM", which links to an IAM that has no way to set one either (its
* only writers are federation and SCIM). So a password signup had a monogram and
* no way out of it.
*
* DOWNSCALED IN THE BROWSER before upload. A phone original is several MB and
* would be served to every viewer of every page that shows this face; 512px is
* larger than any surface renders it. The server still caps the body this is
* the courtesy, not the guard. A source the canvas cannot decode is sent
* verbatim rather than dropped, and the server's format check is the authority.
*/
const PHOTO_EDGE = 512
async function downscale(file: File): Promise<Blob> {
const bitmap = await createImageBitmap(file)
try {
const scale = Math.min(1, PHOTO_EDGE / Math.max(bitmap.width, bitmap.height))
if (scale === 1) return file
const canvas = document.createElement('canvas')
canvas.width = Math.round(bitmap.width * scale)
canvas.height = Math.round(bitmap.height * scale)
const ctx = canvas.getContext('2d')
if (!ctx) return file
ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height)
// PNG keeps transparency and is on the server's allow-list. A canvas that
// refuses to encode yields null — send the original rather than nothing.
const out = await new Promise<Blob | null>((r) => canvas.toBlob(r, 'image/png'))
return out ?? file
} finally {
bitmap.close()
}
}
function PhotoCard() {
const { account, reload } = useSession()
const name = account?.displayName || account?.name || 'Account'
const [photo, setPhoto] = useState<string | undefined>(
typeof account?.avatar === 'string' ? account.avatar : undefined,
)
const [busy, setBusy] = useState(false)
const [err, setErr] = useState<string | null>(null)
const picker = useRef<HTMLInputElement>(null)
const choose = async (file: File | undefined) => {
if (!file) return
setBusy(true)
setErr(null)
try {
let body: File = file
try {
const small = await downscale(file)
body = small === file ? file : new File([small], 'photo.png', { type: 'image/png' })
} catch {
/* undecodable here — let the server judge the original */
}
const url = await AccountApi.setAvatar(body)
// Show the new photo immediately; the URL is content-addressed, so this can
// never be a stale cache of the old one.
setPhoto(url)
// And re-read the session so every other surface in this tab agrees.
void reload()
} catch (e) {
setErr(e instanceof ApiError ? e.message : 'Could not update your photo.')
} finally {
setBusy(false)
if (picker.current) picker.current.value = ''
}
}
return (
<YStack gap="$2">
<XStack gap="$3" items="center">
<Avatar circular size={56}>
{photo ? <Avatar.Image accessibilityLabel={name} src={photo} /> : null}
<Avatar.Fallback bg="$color5" items="center" justify="center">
<Text fontSize="$5" fontWeight="800" color="$color12">{initials(name)}</Text>
</Avatar.Fallback>
</Avatar>
<YStack flex={1}>
<Text fontSize="$6" fontWeight="800" color="$color12">{name}</Text>
<Text fontSize="$3" color="$color11">{account?.email || '—'}</Text>
</YStack>
<Button
size="$2"
disabled={busy}
icon={busy ? <Spinner size="small" /> : <Camera size={15} />}
onPress={() => picker.current?.click()}
>
{photo ? 'Change photo' : 'Add photo'}
</Button>
</XStack>
{/* The file input is the real control; the Button is its label. Kept in the
DOM (not display:none) so assistive tech can still reach it. */}
<input
ref={picker}
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
aria-label="Profile photo"
style={{ position: 'absolute', width: 1, height: 1, opacity: 0, pointerEvents: 'none' }}
onChange={(e) => void choose(e.target.files?.[0])}
/>
{err ? <Text fontSize="$2" color="$red10">{err}</Text> : null}
</YStack>
)
}
function AccountTab() {
const { account, signOut } = useSession()
const name = account?.displayName || account?.name || 'Account'
const avatar = typeof account?.avatar === 'string' ? account.avatar : undefined
return (
<YStack gap="$5">
<Card p="$4" gap="$4" borderWidth={1} borderColor="$borderColor" maxWidth={720}>
<XStack gap="$3" items="center">
<Avatar circular size={56}>
{avatar ? <Avatar.Image accessibilityLabel={name} src={avatar} /> : null}
<Avatar.Fallback bg="$color5" items="center" justify="center">
<Text fontSize="$5" fontWeight="800" color="$color12">{initials(name)}</Text>
</Avatar.Fallback>
</Avatar>
<YStack flex={1}>
<Text fontSize="$6" fontWeight="800" color="$color12">{name}</Text>
<Text fontSize="$3" color="$color11">{account?.email || '—'}</Text>
<PhotoCard />
</YStack>
<ManageInIam />
</XStack>
@@ -4,7 +4,7 @@
* Score Configs list of score definitions (HIP-0106), native on @hanzo/gui.
*
* A score config defines a score's data type and its valid range (numeric) or
* categories (categorical/boolean). Reads the REAL `/v1/o11y/score-configs`
* categories (categorical/boolean). Reads the REAL `/v1/evals/rubrics`
* surface; when the runtime is not initialized (503) or unrouted (404) it shows
* an honest RuntimeNotice never fabricated configs. Read-only here; configs are
* authored where scores are recorded.
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -8,7 +8,7 @@
* - POST /v1/webhooks create the row incl. `secret` (reveal-once)
* - PATCH /v1/webhooks/:id enable/disable + edit
* - DELETE /v1/webhooks/:id remove
* - POST /v1/webhooks/:id/rotate-secret { secret } (reveal-once)
* - POST /v1/webhooks/:id/secret { secret } (reveal-once)
* - POST /v1/webhooks/:id/test { delivered, httpStatus, durationMs, error? }
* - GET /v1/webhooks/:id/deliveries recent attempts (newest-first)
*
@@ -436,7 +436,7 @@ export function WebhooksModule({ params }: { params: Record<string, string> }) {
setBusyId(w.id)
setActionMsg(null)
try {
const res = await restPost<{ secret?: string }>(cloudProxyV1Url(`webhooks/${enc(w.id)}/rotate-secret`))
const res = await restPost<{ secret?: string }>(cloudProxyV1Url(`webhooks/${enc(w.id)}/secret`))
const secret = str(res?.secret)
if (secret) setRevealed({ title: 'New signing secret', url: w.url, secret })
setActionMsg({ tone: 'ok', text: 'Signing secret rotated.' })
@@ -10,7 +10,7 @@
* 2. CAPS oversight + override of ANY org's usage caps: pick a target org, list its
* spend caps (real threshold / period-spend meter / hard-cap vs alert / rate limit /
* when it resets), and create / edit / delete a cap on that org's behalf. Backed by
* `GET/POST/PATCH/DELETE /v1/admin/spend-caps?org=<slug>`.
* `GET/POST/PATCH/DELETE /v1/admin/caps?org=<slug>`.
*
* All reads/writes terminate at the GLOBAL-ADMIN-GATED `app/admin/aggregate` proxy
* (`getAdminGate`, fail-closed 403, then a minted user bearer + same-origin CSRF), and
@@ -41,7 +41,7 @@ import {
import { useIsSuperAdmin } from '~/lib/auth/admin'
import { ApiError } from '~/lib/api'
import { AdminPromosApi, type PlatformPromo } from '~/lib/api/admin-promos'
import { AdminSpendCapsApi, type AdminSpendCap } from '~/lib/api/admin-spend-caps'
import { AdminCapsApi, type AdminCap } from '~/lib/api/admin-caps'
import { fmtInt, fmtUsd } from '~/lib/api/functions'
import { PageHeader } from '~/components/ui/PageHeader'
import { EmptyState } from '~/components/ui/EmptyState'
@@ -341,7 +341,7 @@ function CapFields({ form, setForm, disabled }: { form: BudgetForm; setForm: (f:
)
}
function CapCard({ org, cap, onChanged }: { org: string; cap: AdminSpendCap; onChanged: () => void }) {
function CapCard({ org, cap, onChanged }: { org: string; cap: AdminCap; onChanged: () => void }) {
const [editing, setEditing] = useState(false)
const [form, setForm] = useState<BudgetForm>(() => formForAlert(cap))
const [saving, setSaving] = useState(false)
@@ -367,7 +367,7 @@ function CapCard({ org, cap, onChanged }: { org: string; cap: AdminSpendCap; onC
setSaving(true)
setError(null)
try {
await AdminSpendCapsApi.update(org, cap.id, {
await AdminCapsApi.update(org, cap.id, {
title: v.title,
thresholdCents: v.thresholdCents,
project: v.project,
@@ -391,7 +391,7 @@ function CapCard({ org, cap, onChanged }: { org: string; cap: AdminSpendCap; onC
setRemoving(true)
setError(null)
try {
await AdminSpendCapsApi.remove(org, cap.id)
await AdminCapsApi.remove(org, cap.id)
onChanged()
} catch (e) {
const a = asApiError(e)
@@ -499,7 +499,7 @@ function AddCapForm({ org, onDone, onCancel }: { org: string; onDone: () => void
setSaving(true)
setError(null)
try {
await AdminSpendCapsApi.create(org, {
await AdminCapsApi.create(org, {
title: v.title,
thresholdCents: v.thresholdCents,
project: v.project,
@@ -542,7 +542,7 @@ function AddCapForm({ org, onDone, onCancel }: { org: string; onDone: () => void
function CapsTab() {
const [orgInput, setOrgInput] = useState('')
const [org, setOrg] = useState('')
const [state, setState] = useState<Async<AdminSpendCap[]> | null>(null)
const [state, setState] = useState<Async<AdminCap[]> | null>(null)
const [adding, setAdding] = useState(false)
const load = useCallback((slug: string) => {
@@ -551,7 +551,7 @@ function CapsTab() {
setOrg(s)
setAdding(false)
setState({ phase: 'loading' })
AdminSpendCapsApi.list(s)
AdminCapsApi.list(s)
.then((data) => setState({ phase: 'ready', data }))
.catch((e) => setState({ phase: 'error', err: asApiError(e) }))
}, [])
@@ -154,7 +154,7 @@ export function InfraModule({ params }: { params: Record<string, string> }) {
if (tab === 'clusters') return <ClustersTab data={data} loading={loading} />
if (tab === 'nodes') return <NodesTab data={data} loading={loading} reload={() => void load(true)} toast={toast} />
if (tab === 'volumes') return <VolumesTab data={data} loading={loading} reload={() => void load(true)} toast={toast} />
if (tab === 'load-balancers') return <LoadBalancersTab data={data} loading={loading} />
if (tab === 'balancers') return <LoadBalancersTab data={data} loading={loading} />
if (tab === 'audit') return <AuditTab data={data} loading={loading} />
return <OverviewTab data={data} loading={loading} />
})()
@@ -448,7 +448,7 @@ const TONE_COLOR = { green: '$green11', yellow: '$yellow11', red: '$red11', neut
// fillOf joins the block-storage read onto an inventory volume. The two backends
// answer different questions about the SAME object — /v1/admin/infra knows whether a
// volume is referenced (and therefore safe to delete), /v1/admin/block-storage knows
// volume is referenced (and therefore safe to delete), /v1/admin/volumes knows
// how full it is — so the board reads both and shows one row. Two boards for one
// noun is what this replaces; two READS for one row is fine, and each degrades on
// its own (no fill data → an honest em-dash, never a fabricated 0%).
@@ -2,7 +2,7 @@
/**
* Budgets & limits view and set per-scope SPEND CAPS and RATE LIMITS over the REAL
* commerce spend-alerts API (`GET/POST/PATCH/DELETE /v1/billing/spend-alerts`, the
* commerce alerts API (`GET/POST/PATCH/DELETE /v1/billing/alerts`, the
* user-group endpoints billing.hanzo.ai itself uses), scoped to the caller's OWN
* subject by the `/billing` proxy (server-pinned a caller only ever sees/edits their
* own budgets).
@@ -586,7 +586,7 @@ export function BillingBudgets(_props: { params: Record<string, string> }) {
/>
{state.phase === 'error' ? (
<BackendStateCard state={state.error} onRetry={load} hint="endpoint · GET /v1/billing/spend-alerts" />
<BackendStateCard state={state.error} onRetry={load} hint="endpoint · GET /v1/billing/alerts" />
) : state.phase === 'loading' ? (
<LoadingCards />
) : (
@@ -130,15 +130,15 @@ export function BillingCredits(_props: { params: Record<string, string> }) {
</Text>
</Card>
) : cfg.phase === 'error' ? (
<BackendStateCard state={cfg.error} hint="endpoint · GET /v1/billing/payment-config" />
<BackendStateCard state={cfg.error} hint="endpoint · GET /v1/billing/settings" />
) : !configured ? (
<Card p="$4" gap="$2" borderWidth={1} borderColor="$borderColor">
<Text fontSize="$4" fontWeight="700" color="$color12">
Card top-up isnt available on this deployment yet
No card processor is configured for this organization
</Text>
<Text fontSize="$3" color="$color11">
No card processor is configured for this organization. You can top up with crypto below,
or contact support to enable card payments.
Card top-up needs a payment processor connected to your organization. You can top up with
crypto below, or contact support to enable card payments.
</Text>
</Card>
) : submit.state === 'done' ? (
@@ -25,6 +25,7 @@ import { EVENTS } from '@hanzo/event'
import { AiApi, PlaygroundApi, type ChatMessage } from '~/lib/api'
import { DEFAULT_MODEL } from '~/lib/api/families'
import { useRecentModels } from '~/lib/models/recent'
import { hanzoAssistantSystemPrompt, ASSISTANT_DOCS_STORE } from '~/lib/assistant'
import { useVoice } from '~/lib/voice'
import { useIsSuperAdmin } from '~/lib/auth/admin'
@@ -187,6 +188,7 @@ export function ChatConversation({
voiceSignal?: number
}) {
const analytics = useAnalytics()
const { record } = useRecentModels()
const [model, setModel] = useState('')
const [messages, setMessages] = useState<(ChatMessage & { time?: string })[]>([])
const [input, setInput] = useState('')
@@ -201,9 +203,9 @@ export function ChatConversation({
const showAdmin = useIsSuperAdmin()
const system = useMemo(() => hanzoAssistantSystemPrompt({ showAdmin }), [showAdmin])
// Default to the trial-safe Zen default once the catalog loads: prefer the
// Default to the trial-safe house default once the catalog loads: prefer the
// NON-PREMIUM `DEFAULT_MODEL` (the same default the Models page pills and the
// Playground pick), then any Zen model, then the first — so a $5-trial user's
// Playground pick), then any Enso model, then the first — so a $5-trial user's
// first message never 402s on a premium default.
useEffect(() => {
let live = true
@@ -214,7 +216,7 @@ export function ChatConversation({
(m) =>
m ||
ids.find((x) => x.toLowerCase() === DEFAULT_MODEL) ||
ids.find((x) => /zen/i.test(x)) ||
ids.find((x) => /enso/i.test(x)) ||
ids[0],
)
})
@@ -319,6 +321,8 @@ export function ChatConversation({
// First turn of a conversation starts a chat; every turn is a message sent.
if (history.length === 0) analytics.capture(EVENTS.CHAT_STARTED)
analytics.capture(EVENTS.CHAT_MESSAGE_SENT)
// A sent turn is a real use — it feeds the Models page's Recent chips.
if (model) record(model)
setMessages((m) => [...m, { role: 'user', content: q, time: turnTime() }])
setInput('')
const reply = await streamReply(q, history)
@@ -404,7 +408,7 @@ export function ChatConversation({
) : (
<PageHeader
title="Chat"
subtitle={`Talk to ${model || 'Zen'} and other models — real completions through the gateway.`}
subtitle={`Talk to ${model || 'Enso'} and other models — real completions through the gateway.`}
actions={
<XStack gap="$2">
<Button size="$2" icon={<History size={15} />} onPress={onShowHistory}>
@@ -435,7 +439,7 @@ export function ChatConversation({
</Text>
<Text fontSize="$3" color="$color11" text="center" lineHeight={22}>
Ask about {config.brandName} models, GPUs, data, deploys, billing or anything
else. Answers come from the live {model || 'Zen'} gateway, billed to your
else. Answers come from the live {model || 'Enso'} gateway, billed to your
organization.
</Text>
</YStack>
@@ -512,7 +516,7 @@ export function ChatConversation({
ref={inputRef as never}
value={input}
onChangeText={setInput}
placeholder={`Message ${model || 'the assistant'}`}
placeholder="Ask anything. Do anything."
numberOfLines={compact ? 2 : 3}
disabled={sending}
borderWidth={0}
@@ -1,91 +0,0 @@
'use client'
/**
* CommerceResource the ONE list surface every Commerce store page renders (Products,
* Orders, Customers, Inventory, Promotions). It owns the fetch honest loading / empty
* / error lifecycle (identical to the Subscriptions/Functions modules) so each page is
* just a title + a `load()` + its columns; there is one way to render a store list.
*
* `load` is held in a ref so a page can pass an inline `() => CommerceApi.products()`
* without re-triggering the mount effect the list fetches once and on explicit
* Refresh. Every row is real (from the `/commerce` user-bearer proxy, org-scoped
* server-side); an empty store shows the honest empty copy, never a placeholder row.
*/
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
import { Button, XStack } from '@hanzo/gui'
import { RefreshCw } from '@hanzogui/lucide-icons-2'
import { PageHeader } from '~/components/ui/PageHeader'
import { DataTable, type Column } from '~/components/ui/DataTable'
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
import type { CommerceList } from '~/lib/api/commerce'
type Async<T> = { phase: 'loading' } | { phase: 'error'; error: BackendState } | { phase: 'ready'; data: T }
export function CommerceResource<T>({
title,
subtitle,
load,
columns,
rowKey,
empty,
hint,
actions,
}: {
title: string
subtitle: string
/** Real per-org fetch (via the `/commerce` proxy). Identity may change per render. */
load: () => Promise<CommerceList<T>>
columns: Column<T>[]
rowKey: (row: T) => string
empty: string
/** BackendStateCard hint — the real endpoint, e.g. `GET /v1/product`. */
hint: string
/** Optional extra header actions (given the reload fn), left of Refresh. */
actions?: (reload: () => void) => ReactNode
}) {
const [state, setState] = useState<Async<T[]>>({ phase: 'loading' })
const loadRef = useRef(load)
loadRef.current = load
const reload = useCallback(() => {
setState({ phase: 'loading' })
loadRef
.current()
.then((r) => setState({ phase: 'ready', data: r.rows }))
.catch((e) => setState({ phase: 'error', error: classifyBackend(e) }))
}, [])
useEffect(() => {
reload()
}, [reload])
return (
<>
<PageHeader
title={title}
subtitle={subtitle}
actions={
<XStack gap="$2">
{actions?.(reload)}
<Button size="$2" icon={<RefreshCw size={15} />} onPress={reload}>
Refresh
</Button>
</XStack>
}
/>
{state.phase === 'error' ? (
<BackendStateCard state={state.error} onRetry={reload} hint={hint} />
) : (
<DataTable
columns={columns}
rows={state.phase === 'ready' ? state.data : []}
loading={state.phase === 'loading'}
rowKey={rowKey}
empty={empty}
/>
)}
</>
)
}
+1 -1
View File
@@ -35,7 +35,7 @@ import { FieldRow, FieldText } from '~/components/ui/Field'
import { StatusTag } from '~/components/ui/StatusTag'
import { PageHeader } from '~/components/ui/PageHeader'
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
import { CommerceResource } from './CommerceResource'
import { CommerceResource } from '@hanzo/ui/product'
import { discountValue, humanizeStatus } from './logic'
const Cell = ({ children }: { children: ReactNode }) => (
+2 -2
View File
@@ -66,8 +66,8 @@ function EndpointDetailBody({ endpoint, records, hasLedger }: { endpoint: Endpoi
const call =
endpoint.kind === 'deployed' && endpoint.url
? `curl ${endpoint.url}/v1/models/${endpoint.name}:predict \\\n -H "Authorization: Bearer hk-..." \\\n -d '{"instances": [ ... ]}'`
: `curl https://api.${config.iamOrgName === 'hanzo' ? 'hanzo.ai' : `${config.iamOrgName}.cloud`}/v1/chat/completions \\\n -H "Authorization: Bearer hk-..." \\\n -d '{"model": "${endpoint.name}", "messages": [{"role":"user","content":"Hello"}]}'`
? `curl ${endpoint.url}/v1/models/${endpoint.name}:predict \\\n -H "Authorization: Bearer sk-..." \\\n -d '{"instances": [ ... ]}'`
: `curl https://api.${config.iamOrgName === 'hanzo' ? 'hanzo.ai' : `${config.iamOrgName}.cloud`}/v1/chat/completions \\\n -H "Authorization: Bearer sk-..." \\\n -d '{"model": "${endpoint.name}", "messages": [{"role":"user","content":"Hello"}]}'`
return (
<ScrollView>
@@ -87,7 +87,7 @@ export function LaunchDrawer({
const [phase, setPhase] = useState<'idle' | 'launching'>('idle')
const [error, setError] = useState<{ msg: string; needsPay: boolean } | null>(null)
// Card-on-file gate — GPUs ONLY. `null` = still checking; a CPU machine is never
// card-gated (treated as satisfied). FAIL CLOSED: if the payment-methods read errors
// card-gated (treated as satisfied). FAIL CLOSED: if the saved-methods read errors
// we treat it as "no card" so a GPU launch is BLOCKED (never a silent credit fallback).
const [hasCard, setHasCard] = useState<boolean | null>(kind === 'gpu' ? null : true)
// The org's spendable CREDIT balance (cents) — funds a CPU/non-GPU launch. `null`
+5 -5
View File
@@ -75,7 +75,7 @@ export const OVERVIEW_SPECS: Record<string, OverviewSpec> = {
health: { kind: 'platform-app', service: 'gateway' },
facts: [
{ label: 'Base URL', value: 'api.hanzo.ai/v1' },
{ label: 'Auth', value: 'IAM bearer / hk- key' },
{ label: 'Auth', value: 'IAM bearer / sk- key' },
{ label: 'Surface', value: 'OpenAI-compatible /v1' },
],
actions: [
@@ -237,7 +237,7 @@ export const OVERVIEW_SPECS: Record<string, OverviewSpec> = {
facts: [
{ label: 'Base URL', value: 'api.hanzo.ai/v1' },
{ label: 'Spec', value: 'OpenAPI 3' },
{ label: 'Auth', value: 'Bearer (IAM / hk-)' },
{ label: 'Auth', value: 'Bearer (IAM / sk-)' },
],
actions: [
{ label: 'Create API key', to: '/api-keys', icon: 'key' },
@@ -250,8 +250,8 @@ export const OVERVIEW_SPECS: Record<string, OverviewSpec> = {
},
{
heading: 'Authentication',
body: 'Every request carries an Authorization: Bearer header — an IAM user token or an hk- Cloud API key minted on the API Keys page.',
code: 'Authorization: Bearer hk-...',
body: 'Every request carries an Authorization: Bearer header — an IAM user token or an sk- Cloud API key minted on the API Keys page.',
code: 'Authorization: Bearer sk-...',
},
],
},
@@ -344,7 +344,7 @@ export const OVERVIEW_SPECS: Record<string, OverviewSpec> = {
facts: [
{ label: 'Output', value: 'Markdown · text · JSON' },
{ label: 'Rendering', value: 'JS-aware' },
{ label: 'Auth', value: 'Bearer (IAM / hk-)' },
{ label: 'Auth', value: 'Bearer (IAM / sk-)' },
],
actions: [
{ label: 'Create API key', to: '/api-keys', icon: 'key' },
@@ -413,7 +413,7 @@ const fmt = (n: number, dp = 0): string => new Intl.NumberFormat('en', { notatio
/**
* Metrics tab REAL per-service RED metrics from the cloud o11y surface
* (`GET /v1/o11y/metrics?product=<slug>`): requests, error rate, and p95 latency
* (`GET /v1/o11y/product/metrics?product=<slug>`): requests, error rate, and p95 latency
* over a selectable window, each with its live time-series sparkline. Honest states:
* loading; o11y-not-connected (503/404/401/403); and "connected · no telemetry yet"
* when o11y answered but this service emits none. Per-service CPU/memory are not
@@ -6,11 +6,11 @@
* sent every field present is a real field the OpenAI-compatible gateway
* accepts, and an omitted field is genuinely omitted (gateway default), never a
* fabricated value. The cURL targets the public gateway with a bearer placeholder
* (`$HANZO_API_KEY`), so a developer can copy, paste a real `hk-` key, and run it.
* (`$HANZO_API_KEY`), so a developer can copy, paste a real `sk-` key, and run it.
*/
import type { RunParams } from './params'
/** The public gateway endpoint a copied cURL targets (an `hk-` key works here). */
/** The public gateway endpoint a copied cURL targets (an `sk-` key works here). */
export const GATEWAY_URL = 'https://api.hanzo.ai/v1/chat/completions'
/** A message as shown in the preview (text or multimodal content). */
+2 -2
View File
@@ -185,7 +185,7 @@ export function o11yServiceFor(entry: CatalogEntry): string | null {
/**
* AI products that call the unified gateway their real, product-specific config is
* the gateway endpoint + credential (an IAM bearer or an `hk-` key). Used for the
* the gateway endpoint + credential (an IAM bearer or an `sk-` key). Used for the
* Settings config of the ones that don't carry a bespoke overview spec.
*/
const GATEWAY_AI_PRODUCTS = new Set<string>([
@@ -237,7 +237,7 @@ export function settingsConfigFor(entry: CatalogEntry): SettingsConfig {
return {
facts: [
{ label: 'Endpoint', value: 'api.hanzo.ai/v1' },
{ label: 'Auth', value: 'Bearer — IAM token or hk- key' },
{ label: 'Auth', value: 'Bearer — IAM token or sk- key' },
{ label: 'Compatibility', value: 'OpenAI-compatible' },
],
links: [apiKeys, openSelf],
@@ -0,0 +1,372 @@
'use client'
/**
* Issue detail the Linear-style pane (create OR view/edit) for one work item. It
* owns the write surface (title/description/status/priority/assignee/labels), the
* one-click status control, and the AGENT + GIT linkage that makes an issue
* actionable: hand an issue to the coding agent (assignee + `agent` label the
* cloud coding seam picks it up and opens a linked PR), see the issuebranchPR
* chain (`linkedPRs`), and jump to the native git.hanzo.ai repo or the upstream
* GitHub source. Honest states throughout no fabricated activity, real writes over
* `/v1/tracker`.
*/
import { useState } from 'react'
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
import { Bot, ExternalLink, GitBranch, Github, Trash2, GitPullRequest, Layers } from '@hanzogui/lucide-icons-2'
import {
TrackerApi,
STATUSES,
PRIORITIES,
type Issue,
type Project,
type Status,
type Priority,
type NewIssue,
} from '~/lib/api/tracker'
import { FieldRow, FieldText, FieldTextArea, FieldSelect } from '~/components/ui/Field'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import { asApiError } from '~/components/ui/States'
import { useToast } from '~/components/ui/Toast'
import {
STATUS_LABEL,
PRIORITY_LABEL,
KIND_LABEL,
SOURCE_LABEL,
parseLabels,
githubUrl,
gitHanzoUrl,
linkedPRs,
} from './logic'
import { StatusIcon, KindIcon, SourceBadge, Identifier, PriorityIcon } from './atoms'
/** The default coding-agent ref an issue is handed to (assignee). */
const AGENT_REF = 'hanzo'
function openTab(url: string | null) {
if (url && typeof window !== 'undefined') window.open(url, '_blank', 'noopener')
}
/** A labeled external-link button (GitHub / native git / PR). */
function LinkButton({ icon, label, url }: { icon: React.ReactElement; label: string; url: string | null }) {
if (!url) return null
return (
<Button size="$2" icon={icon} iconAfter={<ExternalLink size={12} opacity={0.5} />} onPress={() => openTab(url)}>
{label}
</Button>
)
}
/** A read-only key/value meta row in the detail sidebar. */
function Meta({ label, children }: { label: string; children: React.ReactNode }) {
return (
<XStack items="center" justify="space-between" gap="$3">
<Text fontSize="$2" color="$color10">
{label}
</Text>
{children}
</XStack>
)
}
/** A compact linked-PR / child-issue row inside the detail. */
function LinkRow({ issue, onOpen }: { issue: Issue; onOpen?: (i: Issue) => void }) {
return (
<XStack
items="center"
gap="$2"
px="$2"
py="$1.5"
rounded="$3"
hoverStyle={{ bg: '$color3' }}
cursor={onOpen ? 'pointer' : 'default'}
onPress={() => onOpen?.(issue)}
>
{issue.kind === 'pr' ? <KindIcon kind="pr" size={14} /> : <StatusIcon status={issue.status} size={14} />}
<Identifier id={issue.identifier} />
<Text flex={1} fontSize="$2" color="$color12" numberOfLines={1}>
{issue.title}
</Text>
<SourceBadge source={issue.source} />
</XStack>
)
}
export function IssueDetail({
projectKey,
issue,
allIssues,
onClose,
onSaved,
onDeleted,
onOpenIssue,
}: {
projectKey: string
/** null = create; an issue = view/edit. */
issue: Issue | null
/** the full working set, for deriving linked PRs / epic children. */
allIssues: Issue[]
onClose: () => void
onSaved: () => void
onDeleted: () => void
onOpenIssue?: (i: Issue) => void
}) {
const toast = useToast()
const [title, setTitle] = useState(issue?.title ?? '')
const [description, setDescription] = useState(issue?.description ?? '')
const [status, setStatus] = useState<Status>(issue?.status ?? 'backlog')
const [priority, setPriority] = useState<Priority>(issue?.priority ?? 'none')
const [assignee, setAssignee] = useState(issue?.assignee ?? '')
const [labels, setLabels] = useState((issue?.labels ?? []).join(', '))
const [busy, setBusy] = useState(false)
const body = (): NewIssue => ({
title: title.trim(),
description: description.trim() || undefined,
status,
priority,
assignee: assignee.trim() || undefined,
labels: parseLabels(labels),
})
const save = async () => {
if (!title.trim()) {
toast.error('Title required', 'Give the issue a title.')
return
}
setBusy(true)
try {
if (issue) {
await TrackerApi.updateIssue(projectKey, issue.number, body())
toast.success('Issue updated', issue.identifier)
} else {
const created = await TrackerApi.createIssue(projectKey, body())
toast.success('Issue created', created.identifier)
}
onSaved()
} catch (e) {
toast.error(issue ? 'Could not update issue' : 'Could not create issue', asApiError(e).message)
} finally {
setBusy(false)
}
}
// One-click status (edit only) — PATCH just the status; the row moves group/column.
const quickStatus = async (s: Status) => {
setStatus(s)
if (!issue) return
try {
await TrackerApi.updateIssue(projectKey, issue.number, { status: s })
toast.success('Status updated', `${issue.identifier}${STATUS_LABEL[s]}`)
onSaved()
} catch (e) {
toast.error('Could not update status', asApiError(e).message)
}
}
// Hand the issue to the coding agent: assignee + the `agent` label. The cloud coding
// seam (clients/coding) picks it up, works on a native git.hanzo.ai branch, and opens
// a linked PR row (Kind:"pr", Source:"agent", same Repo) — surfaced below once it lands.
const handToAgent = async () => {
if (!issue) return
setBusy(true)
try {
await TrackerApi.assignAgent(projectKey, issue.number, AGENT_REF, issue.labels)
setAssignee(AGENT_REF)
toast.success('Handed to agent', `${issue.identifier} → @${AGENT_REF}. A linked PR opens when the run completes.`)
onSaved()
} catch (e) {
toast.error('Could not assign agent', asApiError(e).message)
} finally {
setBusy(false)
}
}
const remove = async () => {
if (!issue) return
if (typeof window !== 'undefined' && !window.confirm(`Delete ${issue.identifier}? This cannot be undone.`)) return
try {
await TrackerApi.deleteIssue(projectKey, issue.number)
toast.success('Issue deleted', issue.identifier)
onDeleted()
} catch (e) {
toast.error('Could not delete issue', asApiError(e).message)
}
}
const prs = issue ? linkedPRs(issue, allIssues) : []
const children =
issue && issue.kind === 'epic'
? allIssues.filter((i) => i.id !== issue.id && (i.extRef === issue.identifier || i.extRef === issue.id))
: []
const gh = issue ? githubUrl(issue) : null
const git = issue ? gitHanzoUrl(issue.repo) : null
const isAgentPr = issue?.kind === 'pr' && issue.source === 'agent'
const handedToAgent = (issue?.assignee ?? '').trim().toLowerCase() === AGENT_REF || issue?.labels.includes('agent')
return (
<YStack gap="$3">
{issue ? (
<>
{/* Identity header */}
<XStack items="center" gap="$2" flexWrap="wrap">
<Identifier id={issue.identifier} />
<XStack items="center" gap="$1.5" px="$1.5" py="$0.5" rounded="$3" bg="$color3">
<KindIcon kind={issue.kind} size={13} />
<Text fontSize="$1" color="$color11" fontWeight="600">
{KIND_LABEL[issue.kind]}
</Text>
</XStack>
<SourceBadge source={issue.source} />
</XStack>
{/* One-click status */}
<YStack gap="$1.5">
<Text fontSize="$2" color="$color10">
Status
</Text>
<XStack gap="$1.5" flexWrap="wrap">
{STATUSES.map((s) => (
<Button
key={s}
size="$2"
theme={status === s ? 'light' : undefined}
chromeless={status !== s}
icon={<StatusIcon status={s} size={13} />}
onPress={() => void quickStatus(s)}
>
{STATUS_LABEL[s]}
</Button>
))}
</XStack>
</YStack>
</>
) : null}
<FieldRow label="Title">
<FieldText value={title} onChange={setTitle} placeholder="Fix the login redirect" disabled={busy} />
</FieldRow>
<FieldRow label="Description">
<FieldTextArea value={description} onChange={setDescription} rows={5} disabled={busy} />
</FieldRow>
<XStack gap="$2" flexWrap="wrap">
<YStack flex={1} minW={160}>
<FieldRow label="Status">
<FieldSelect value={status} options={[...STATUSES]} onChange={(v) => setStatus(v as Status)} disabled={busy} />
</FieldRow>
</YStack>
<YStack flex={1} minW={160}>
<FieldRow label="Priority">
<FieldSelect value={priority} options={[...PRIORITIES]} onChange={(v) => setPriority(v as Priority)} disabled={busy} />
</FieldRow>
</YStack>
</XStack>
<FieldRow label="Assignee">
<FieldText value={assignee} onChange={setAssignee} placeholder="ada@hanzo.ai" disabled={busy} />
</FieldRow>
<FieldRow label="Labels">
<FieldText value={labels} onChange={setLabels} placeholder="bug, auth, p1" disabled={busy} />
</FieldRow>
{/* Save / cancel / delete */}
<XStack gap="$2" justify="space-between" flexWrap="wrap">
<XStack gap="$2">
<PrimaryButton disabled={busy} onPress={() => void save()}>
{busy ? 'Saving…' : issue ? 'Save changes' : 'Create issue'}
</PrimaryButton>
<Button chromeless disabled={busy} onPress={onClose}>
Cancel
</Button>
</XStack>
{issue ? (
<Button theme="red" chromeless icon={<Trash2 size={15} />} disabled={busy} onPress={() => void remove()} aria-label="Delete issue" />
) : null}
</XStack>
{/* ── Agent + git linkage (edit only) ─────────────────────────────────── */}
{issue ? (
<Card p="$3" gap="$3" bg="$color1" borderWidth={1} borderColor="$borderColor">
<XStack items="center" gap="$2">
<Bot size={15} />
<Text fontSize="$3" fontWeight="700" color="$color12">
Agent &amp; git
</Text>
</XStack>
{issue.repo ? (
<Meta label="Repo">
<Text fontSize="$2" color="$color12" className="hz-mono">
{issue.repo}
</Text>
</Meta>
) : null}
{isAgentPr && issue.extRef ? (
<Meta label="Branch">
<XStack items="center" gap="$1.5">
<GitBranch size={13} />
<Text fontSize="$2" color="$color12" className="hz-mono" numberOfLines={1}>
{issue.extRef}
</Text>
</XStack>
</Meta>
) : null}
<XStack gap="$2" flexWrap="wrap">
<LinkButton icon={<GitBranch size={14} />} label="Open in git.hanzo.ai" url={git} />
<LinkButton icon={<Github size={14} />} label="View on GitHub" url={gh} />
</XStack>
{/* Hand to agent — only for non-PR work items. */}
{issue.kind !== 'pr' ? (
handedToAgent ? (
<XStack items="center" gap="$2" px="$2.5" py="$2" rounded="$3" bg="$color2">
<Bot size={14} />
<Text fontSize="$2" color="$color11" flex={1}>
Handed to the coding agent. A linked PR appears here when the run opens one.
</Text>
</XStack>
) : (
<Button icon={<Bot size={15} />} disabled={busy} onPress={() => void handToAgent()}>
Hand to coding agent
</Button>
)
) : null}
{/* Linked PRs */}
{prs.length > 0 ? (
<YStack gap="$1">
<XStack items="center" gap="$1.5">
<GitPullRequest size={13} />
<Text fontSize="$2" color="$color10" fontWeight="600">
Linked pull requests · {prs.length}
</Text>
</XStack>
{prs.map((pr) => (
<LinkRow key={pr.id} issue={pr} onOpen={onOpenIssue} />
))}
</YStack>
) : null}
{/* Epic children */}
{issue.kind === 'epic' ? (
<YStack gap="$1">
<XStack items="center" gap="$1.5">
<Layers size={13} />
<Text fontSize="$2" color="$color10" fontWeight="600">
Child issues · {children.length}
</Text>
</XStack>
{children.length === 0 ? (
<Text fontSize="$1" color="$color9" px="$2">
No children yet. Set a child issue&apos;s ExtRef to {issue.identifier} to link it here.
</Text>
) : (
children.map((c) => <LinkRow key={c.id} issue={c} onOpen={onOpenIssue} />)
)}
</YStack>
) : null}
</Card>
) : null}
</YStack>
)
}
@@ -0,0 +1,218 @@
'use client'
/**
* Issue list + board renderers the two Linear-grade layouts over the SAME grouped
* data. `GroupedList` is the sectioned list (the proof view: rows grouped by the
* chosen axis); `Board` is the horizontal status-column kanban. Both render the dense
* `IssueRow`/`IssueCard` and support a `selectedId` highlight so the module's
* keyboard cursor (j/k//, Enter) is visible. Pure presentation over props all
* grouping/sorting is decided in `logic.ts`.
*/
import { Card, ScrollView, Text, XStack, YStack } from '@hanzo/gui'
import type { Issue, Project } from '~/lib/api/tracker'
import { type IssueGroup, relTime, teamName } from './logic'
import { StatusIcon, PriorityIcon, Identifier, KindIcon, SourceBadge, LabelChips, Avatar } from './atoms'
export type RowProps = {
issue: Issue
projects: Project[]
selected?: boolean
showTeam?: boolean
onOpen: (i: Issue) => void
}
/** One dense list row — Linear's single-line issue row. */
export function IssueRow({ issue, projects, selected, showTeam, onOpen }: RowProps) {
return (
<XStack
items="center"
gap="$2.5"
px="$3"
height={40}
bg={selected ? '$color4' : 'transparent'}
borderLeftWidth={2}
borderColor={selected ? '$color8' : 'transparent'}
hoverStyle={{ bg: '$color3' }}
cursor="pointer"
onPress={() => onOpen(issue)}
data-issue={issue.id}
>
<StatusIcon status={issue.status} />
<PriorityIcon priority={issue.priority} size={14} />
<YStack width={72}>
<Identifier id={issue.identifier} />
</YStack>
{issue.kind !== 'issue' ? <KindIcon kind={issue.kind} /> : null}
<Text flex={1} fontSize="$3" color="$color12" numberOfLines={1}>
{issue.title || 'Untitled'}
</Text>
{showTeam ? (
<Text display="none" $md={{ display: 'flex' }} fontSize="$1" color="$color9" className="hz-mono">
{teamName(issue.projectKey, projects)}
</Text>
) : null}
<XStack display="none" $md={{ display: 'flex' }} items="center" gap="$2">
<SourceBadge source={issue.source} />
<LabelChips labels={issue.labels} max={2} />
</XStack>
<Text display="none" $lg={{ display: 'flex' }} fontSize="$1" color="$color9" width={44} style={{ textAlign: 'right' }}>
{relTime(issue.updatedAt)}
</Text>
<Avatar name={issue.assignee} />
</XStack>
)
}
/** A board card — the same issue, stacked for a column. */
export function IssueCard({ issue, projects, selected, showTeam, onOpen }: RowProps) {
return (
<Card
p="$2.5"
gap="$2"
bg="$color1"
borderWidth={1}
borderColor={selected ? '$color8' : '$borderColor'}
hoverStyle={{ borderColor: '$color8' }}
cursor="pointer"
onPress={() => onOpen(issue)}
data-issue={issue.id}
>
<XStack items="center" justify="space-between" gap="$2">
<XStack items="center" gap="$1.5" flex={1} minW={0}>
<Identifier id={issue.identifier} />
{issue.kind !== 'issue' ? <KindIcon kind={issue.kind} size={12} /> : null}
</XStack>
<PriorityIcon priority={issue.priority} size={13} />
</XStack>
<Text fontSize="$3" color="$color12" numberOfLines={3}>
{issue.title || 'Untitled'}
</Text>
<XStack items="center" justify="space-between" gap="$2">
<XStack items="center" gap="$1.5" flex={1} minW={0} flexWrap="wrap">
<SourceBadge source={issue.source} />
<LabelChips labels={issue.labels} max={2} />
{showTeam ? (
<Text fontSize="$1" color="$color9" className="hz-mono">
{teamName(issue.projectKey, projects)}
</Text>
) : null}
</XStack>
<Avatar name={issue.assignee} size={18} />
</XStack>
</Card>
)
}
/** A section header — the group label, its glyph, and a count. */
function GroupHeader({ group, glyph }: { group: IssueGroup; glyph?: React.ReactNode }) {
return (
<XStack items="center" gap="$2" px="$3" py="$1.5" bg="$color2" borderBottomWidth={1} borderColor="$borderColor">
{glyph}
<Text fontSize="$2" fontWeight="700" color="$color12">
{group.label || 'Issues'}
</Text>
<Text fontSize="$2" color="$color9">
{group.issues.length}
</Text>
</XStack>
)
}
/** The sectioned list every group with its rows. Empty groups are hidden unless
* `keepEmpty` (a status board keeps its empty columns; a dynamic bucket does not). */
export function GroupedList({
groups,
projects,
selectedId,
showTeam,
keepEmpty,
glyphFor,
onOpen,
}: {
groups: IssueGroup[]
projects: Project[]
selectedId?: string
showTeam?: boolean
keepEmpty?: boolean
glyphFor?: (g: IssueGroup) => React.ReactNode
onOpen: (i: Issue) => void
}) {
const visible = keepEmpty ? groups : groups.filter((g) => g.issues.length > 0)
return (
<YStack borderWidth={1} borderColor="$borderColor" rounded="$4" overflow="hidden">
{visible.map((g, gi) => (
<YStack key={g.key} borderTopWidth={gi === 0 ? 0 : 1} borderColor="$borderColor">
<GroupHeader group={g} glyph={glyphFor?.(g)} />
{g.issues.length === 0 ? (
<Text fontSize="$2" color="$color9" px="$3" py="$2">
No issues
</Text>
) : (
g.issues.map((i) => (
<YStack key={i.id} borderTopWidth={1} borderColor="$color2">
<IssueRow issue={i} projects={projects} selected={i.id === selectedId} showTeam={showTeam} onOpen={onOpen} />
</YStack>
))
)}
</YStack>
))}
</YStack>
)
}
/** The horizontal board — status columns of cards (always the five columns). */
export function Board({
columns,
projects,
selectedId,
showTeam,
glyphFor,
onOpen,
}: {
columns: IssueGroup[]
projects: Project[]
selectedId?: string
showTeam?: boolean
glyphFor?: (g: IssueGroup) => React.ReactNode
onOpen: (i: Issue) => void
}) {
return (
<ScrollView horizontal showsHorizontalScrollIndicator>
<XStack gap="$3" py="$1" items="flex-start">
{columns.map((col) => (
<YStack key={col.key} width={300} minW={300} gap="$2">
<XStack items="center" gap="$2" px="$1">
{glyphFor?.(col)}
<Text flex={1} fontSize="$2" fontWeight="700" color="$color12">
{col.label}
</Text>
<Text fontSize="$2" color="$color9">
{col.issues.length}
</Text>
</XStack>
<YStack gap="$2">
{col.issues.length === 0 ? (
<YStack borderWidth={1} borderColor="$borderColor" borderStyle="dashed" rounded="$4" py="$4" items="center">
<Text fontSize="$2" color="$color9">
No issues
</Text>
</YStack>
) : (
col.issues.map((i) => (
<IssueCard
key={i.id}
issue={i}
projects={projects}
selected={i.id === selectedId}
showTeam={showTeam}
onOpen={onOpen}
/>
))
)}
</YStack>
</YStack>
))}
</XStack>
</ScrollView>
)
}
+280
View File
@@ -0,0 +1,280 @@
'use client'
/**
* Tracker toolbar the Linear-grade control strip above every issue view: ListBoard
* toggle, a Group-by menu, a Filter menu (status / priority / kind / source / assignee
* / label), active-filter chips with one-click clear, and a `/`-focusable search box.
* Every control is a pure value in/out (state lives in the module), so the toolbar is
* a dumb, reusable renderer. @hanzo/gui Popover menus, shorthands only.
*/
import { forwardRef } from 'react'
import { Button, Input, Popover, ScrollView, Text, XStack, YStack } from '@hanzo/gui'
import {
ListChecks,
LayoutGrid,
ChevronDown,
Filter as FilterIcon,
Search,
X,
} from '@hanzogui/lucide-icons-2'
import { STATUSES, PRIORITIES, KINDS, SOURCES } from '~/lib/api/tracker'
import {
type GroupBy,
type IssueFilters,
GROUP_BY,
GROUP_BY_LABEL,
STATUS_LABEL,
PRIORITY_LABEL,
KIND_LABEL,
SOURCE_LABEL,
countFilters,
} from './logic'
import { StatusIcon, PriorityIcon } from './atoms'
type Opt = { value: string; label: string; glyph?: React.ReactElement }
/** A Popover single-select menu a labeled trigger, a scrollable option list, an
* optional clear. Selecting closes via the controlled `Popover` (uncontrolled here
* is fine Radix closes on outer press). */
function Menu({
trigger,
options,
value,
onChange,
clearLabel,
}: {
trigger: React.ReactNode
options: Opt[]
value?: string
onChange: (v: string) => void
clearLabel?: string
}) {
return (
<Popover placement="bottom-start">
<Popover.Trigger asChild>{trigger}</Popover.Trigger>
<Popover.Content bordered elevate p="$1.5" width={220} bg="$color2" borderColor="$borderColor">
<ScrollView maxH={320}>
<YStack gap="$0.5">
{clearLabel ? (
<Button
size="$2"
chromeless
justify="flex-start"
onPress={() => onChange('')}
bg={!value ? '$color4' : 'transparent'}
>
<Text fontSize="$2" color="$color11">
{clearLabel}
</Text>
</Button>
) : null}
{options.map((o) => (
<Button
key={o.value}
size="$2"
chromeless
justify="flex-start"
icon={o.glyph ?? undefined}
onPress={() => onChange(o.value)}
bg={value === o.value ? '$color4' : 'transparent'}
>
<Text fontSize="$2" color="$color12">
{o.label}
</Text>
</Button>
))}
</YStack>
</ScrollView>
</Popover.Content>
</Popover>
)
}
/** A quiet filter chip: "Status: Todo ✕". */
function Chip({ label, onClear }: { label: string; onClear: () => void }) {
return (
<XStack items="center" gap="$1.5" px="$2" py="$1" rounded="$3" bg="$color3" borderWidth={1} borderColor="$borderColor">
<Text fontSize="$1" color="$color11" fontWeight="500">
{label}
</Text>
<Text onPress={onClear} cursor="pointer" color="$color10" hoverStyle={{ color: '$color12' }} aria-label={`Clear ${label}`}>
<X size={12} />
</Text>
</XStack>
)
}
export type ToolbarProps = {
view: 'list' | 'board'
onView: (v: 'list' | 'board') => void
groupBy: GroupBy
onGroupBy: (g: GroupBy) => void
filters: IssueFilters
onFilters: (f: IssueFilters) => void
query: string
onQuery: (q: string) => void
/** dynamic option lists derived from the current issue set. */
assignees: string[]
labels: string[]
/** right-aligned actions (Refresh / New / Sync). */
actions?: React.ReactNode
/** hide the board toggle (roadmap/cycles are list-only). */
listOnly?: boolean
}
export const Toolbar = forwardRef<HTMLInputElement, ToolbarProps>(function Toolbar(
{ view, onView, groupBy, onGroupBy, filters, onFilters, query, onQuery, assignees, labels, actions, listOnly },
searchRef,
) {
const set = (patch: Partial<IssueFilters>) => onFilters({ ...filters, ...patch })
const n = countFilters({ ...filters, q: '' }) // q shown in the search box, not as a chip
return (
<YStack gap="$2.5">
<XStack items="center" gap="$2" flexWrap="wrap">
{/* Search — `/` focuses this (see module). */}
<XStack
items="center"
gap="$2"
px="$2.5"
height={34}
minW={220}
flex={1}
$md={{ flex: 0, width: 280 }}
rounded="$3"
borderWidth={1}
borderColor="$borderColor"
bg="$color2"
>
<Search size={14} opacity={0.6} />
<Input
ref={searchRef as never}
flex={1}
unstyled
value={query}
onChangeText={onQuery}
placeholder="Search issues… ( / )"
fontSize="$3"
color="$color12"
autoCapitalize="none"
autoCorrect={false}
/>
{query ? (
<Button size="$1" chromeless icon={<X size={13} />} onPress={() => onQuery('')} aria-label="Clear search" />
) : null}
</XStack>
{/* Group-by */}
<Menu
trigger={
<Button size="$2" icon={<ChevronDown size={14} />} iconAfter={undefined}>
Group: {GROUP_BY_LABEL[groupBy]}
</Button>
}
value={groupBy}
options={GROUP_BY.map((g) => ({ value: g, label: GROUP_BY_LABEL[g] }))}
onChange={(v) => onGroupBy((v || 'status') as GroupBy)}
/>
{/* Filter */}
<Menu
trigger={
<Button size="$2" icon={<FilterIcon size={14} />} theme={n > 0 ? 'light' : undefined}>
Filter{n > 0 ? ` · ${n}` : ''}
</Button>
}
value={filters.status}
clearLabel="Any status"
options={STATUSES.map((s) => ({ value: s, label: STATUS_LABEL[s], glyph: <StatusIcon status={s} size={14} /> }))}
onChange={(v) => set({ status: (v || undefined) as IssueFilters['status'] })}
/>
<Menu
trigger={<Button size="$2">Priority</Button>}
value={filters.priority}
clearLabel="Any priority"
options={PRIORITIES.map((p) => ({ value: p, label: PRIORITY_LABEL[p], glyph: <PriorityIcon priority={p} size={13} /> }))}
onChange={(v) => set({ priority: (v || undefined) as IssueFilters['priority'] })}
/>
<Menu
trigger={<Button size="$2">Kind</Button>}
value={filters.kind}
clearLabel="Any kind"
options={KINDS.map((k) => ({ value: k, label: KIND_LABEL[k] }))}
onChange={(v) => set({ kind: (v || undefined) as IssueFilters['kind'] })}
/>
<Menu
trigger={<Button size="$2">Source</Button>}
value={filters.source}
clearLabel="Any source"
options={SOURCES.map((s) => ({ value: s, label: SOURCE_LABEL[s] }))}
onChange={(v) => set({ source: (v || undefined) as IssueFilters['source'] })}
/>
{assignees.length > 0 ? (
<Menu
trigger={<Button size="$2">Assignee</Button>}
value={filters.assignee}
clearLabel="Anyone"
options={assignees.map((a) => ({ value: a, label: a }))}
onChange={(v) => set({ assignee: v || undefined })}
/>
) : null}
{labels.length > 0 ? (
<Menu
trigger={<Button size="$2">Label</Button>}
value={filters.label}
clearLabel="Any label"
options={labels.map((l) => ({ value: l, label: l }))}
onChange={(v) => set({ label: v || undefined })}
/>
) : null}
{/* View toggle */}
{!listOnly ? (
<XStack gap="$1" ml="auto">
<Button
size="$2"
theme={view === 'list' ? 'light' : undefined}
chromeless={view !== 'list'}
icon={<ListChecks size={15} />}
onPress={() => onView('list')}
aria-label="List view"
/>
<Button
size="$2"
theme={view === 'board' ? 'light' : undefined}
chromeless={view !== 'board'}
icon={<LayoutGrid size={15} />}
onPress={() => onView('board')}
aria-label="Board view"
/>
</XStack>
) : null}
{actions ? (
<XStack gap="$2" items="center" ml={listOnly ? 'auto' : undefined}>
{actions}
</XStack>
) : null}
</XStack>
{/* Active-filter chips */}
{n > 0 ? (
<XStack gap="$1.5" flexWrap="wrap" items="center">
{filters.status ? <Chip label={`Status: ${STATUS_LABEL[filters.status]}`} onClear={() => set({ status: undefined })} /> : null}
{filters.priority ? <Chip label={`Priority: ${PRIORITY_LABEL[filters.priority]}`} onClear={() => set({ priority: undefined })} /> : null}
{filters.kind ? <Chip label={`Kind: ${KIND_LABEL[filters.kind]}`} onClear={() => set({ kind: undefined })} /> : null}
{filters.source ? <Chip label={`Source: ${SOURCE_LABEL[filters.source]}`} onClear={() => set({ source: undefined })} /> : null}
{filters.assignee ? <Chip label={`Assignee: ${filters.assignee}`} onClear={() => set({ assignee: undefined })} /> : null}
{filters.label ? <Chip label={`Label: ${filters.label}`} onClear={() => set({ label: undefined })} /> : null}
{filters.team ? <Chip label={`Team: ${filters.team}`} onClear={() => set({ team: undefined })} /> : null}
<Button size="$1" chromeless onPress={() => onFilters({ q: filters.q })} aria-label="Clear all filters">
<Text fontSize="$1" color="$color10" fontWeight="600">
Clear all
</Text>
</Button>
</XStack>
) : null}
</YStack>
)
})
@@ -0,0 +1,195 @@
'use client'
/**
* Tracker command palette a focused quick-switcher over the tracker's own actions +
* navigation + issues. In the STANDALONE tracker shell (tracker.<brand>) it owns K
* via a capture-phase listener that stops the event before the console's global
* palette sees it, so each shell has exactly one K owner (the embedded /tracker view
* defers to the global palette instead the module only mounts this when
* `config.shell === 'tracker'`). Type to filter commands + jump straight to an issue
* by identifier or title; / to move, Enter to run, Esc to close.
*/
import { useEffect, useMemo, useRef, useState } from 'react'
import { Input, ScrollView, Text, XStack, YStack } from '@hanzo/gui'
import type { ComponentType } from 'react'
export type Command = {
id: string
label: string
hint?: string
group: string
icon?: ComponentType<{ size?: number }>
run: () => void
}
export function TrackerCommand({
open,
onClose,
commands,
}: {
open: boolean
onClose: () => void
commands: Command[]
}) {
const [q, setQ] = useState('')
const [active, setActive] = useState(0)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (open) {
setQ('')
setActive(0)
// focus after mount
const t = setTimeout(() => inputRef.current?.focus(), 10)
return () => clearTimeout(t)
}
}, [open])
const filtered = useMemo(() => {
const needle = q.trim().toLowerCase()
if (!needle) return commands
return commands.filter((c) => `${c.label} ${c.hint ?? ''} ${c.group}`.toLowerCase().includes(needle))
}, [q, commands])
useEffect(() => {
if (active >= filtered.length) setActive(0)
}, [filtered.length, active])
const run = (c?: Command) => {
if (!c) return
onClose()
c.run()
}
// Arrow/Enter/Escape on a document listener (Tamagui Input doesn't reliably forward
// onKeyDown); capture phase so it wins while the palette is open.
useEffect(() => {
if (!open) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault()
onClose()
} else if (e.key === 'ArrowDown') {
e.preventDefault()
setActive((i) => Math.min(i + 1, filtered.length - 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setActive((i) => Math.max(i - 1, 0))
} else if (e.key === 'Enter') {
e.preventDefault()
run(filtered[active])
}
}
document.addEventListener('keydown', onKey, true)
return () => document.removeEventListener('keydown', onKey, true)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, filtered, active])
if (!open) return null
// Group order preserved as first-seen.
const groups: { name: string; items: Command[] }[] = []
for (const c of filtered) {
let g = groups.find((x) => x.name === c.group)
if (!g) groups.push((g = { name: c.group, items: [] }))
g.items.push(c)
}
let idx = -1
return (
<YStack position="fixed" t={0} l={0} r={0} b={0} items="center" style={{ zIndex: 2000 }} pt="$10">
{/* Backdrop as its own layer BEHIND the card — a card click never bubbles to it. */}
<YStack
position="absolute"
t={0}
l={0}
r={0}
b={0}
style={{ backgroundColor: 'rgba(0,0,0,0.45)' }}
onPress={onClose}
/>
<YStack
width="92%"
maxW={620}
bg="$color1"
borderWidth={1}
borderColor="$borderColor"
rounded="$5"
overflow="hidden"
className="hz-elevation-4"
>
<XStack items="center" gap="$2" px="$3" height={48} borderBottomWidth={1} borderColor="$borderColor">
<Text fontSize="$4" color="$color9">
</Text>
<Input
ref={inputRef as never}
flex={1}
unstyled
value={q}
onChangeText={setQ}
placeholder="Type a command or jump to an issue…"
fontSize="$4"
color="$color12"
autoCapitalize="none"
autoCorrect={false}
/>
<Text fontSize="$1" color="$color9">
Esc
</Text>
</XStack>
<ScrollView maxH={420}>
<YStack py="$1.5">
{groups.length === 0 ? (
<Text fontSize="$2" color="$color9" px="$3" py="$3">
No matching commands.
</Text>
) : (
groups.map((g) => (
<YStack key={g.name} pb="$1">
<Text px="$3" py="$1" fontSize="$1" color="$color9" fontWeight="700" textTransform="uppercase" letterSpacing={0.4}>
{g.name}
</Text>
{g.items.map((c) => {
idx += 1
const on = idx === active
const Icon = c.icon
return (
<XStack
key={c.id}
items="center"
gap="$2.5"
px="$3"
height={38}
bg={on ? '$color4' : 'transparent'}
hoverStyle={{ bg: '$color3' }}
cursor="pointer"
onPress={() => run(c)}
>
{Icon ? (
<Text color="$color10">
<Icon size={15} />
</Text>
) : (
<YStack width={15} />
)}
<Text flex={1} fontSize="$3" color="$color12" numberOfLines={1}>
{c.label}
</Text>
{c.hint ? (
<Text fontSize="$1" color="$color9" className="hz-mono" numberOfLines={1}>
{c.hint}
</Text>
) : null}
</XStack>
)
})}
</YStack>
))
)}
</YStack>
</ScrollView>
</YStack>
</YStack>
)
}
+204
View File
@@ -0,0 +1,204 @@
'use client'
/**
* Tracker presentational atoms the tiny, reusable marks shared by every tracker
* view (list rows, board cards, the detail pane). Pure @hanzo/gui v5 shorthands, no
* state, no network. Colors come from `logic.ts` token maps (as-const so the literal
* satisfies the GUI color union), so a status/priority reads the same everywhere.
*/
import { Text, XStack, YStack } from '@hanzo/gui'
import {
CircleDashed,
Circle,
CircleDot,
CircleCheck,
CircleSlash,
GitPullRequest,
Layers,
Bot,
Github,
SignalHigh,
SignalMedium,
SignalLow,
AlertTriangle,
Minus,
} from '@hanzogui/lucide-icons-2'
import { type Status, type Priority, type Kind, type Source } from '~/lib/api/tracker'
import {
STATUS_LABEL,
STATUS_DOT,
PRIORITY_LABEL,
PRIORITY_COLOR,
KIND_LABEL,
SOURCE_LABEL,
} from './logic'
// @hanzogui/lucide-icons-2 icons are Tamagui-wrapped: they accept a `color` token
// (e.g. "$red10") + `size`, exactly like DashboardShell's `<ChevronRight color=…/>`.
// `typeof Circle` IS that icon type, so a token color prop type-checks (a plain
// `string` would not — the color prop is a ColorToken union, not string).
type Glyph = typeof Circle
// ── Status ───────────────────────────────────────────────────────────────────
const STATUS_ICON: Record<Status, Glyph> = {
backlog: CircleDashed,
todo: Circle,
in_progress: CircleDot,
done: CircleCheck,
canceled: CircleSlash,
}
/** The colored status glyph (Linear's leading state icon). */
export function StatusIcon({ status, size = 15 }: { status: Status; size?: number }) {
const Icon = STATUS_ICON[status]
return <Icon size={size} color={STATUS_DOT[status]} />
}
/** A small round status dot (used where a full glyph is too heavy). */
export function StatusDot({ status, size = 9 }: { status: Status; size?: number }) {
return <YStack width={size} height={size} rounded="$10" bg={STATUS_DOT[status]} />
}
export function StatusLabel({ status }: { status: Status }) {
return (
<XStack items="center" gap="$1.5">
<StatusIcon status={status} />
<Text fontSize="$2" color="$color11">
{STATUS_LABEL[status]}
</Text>
</XStack>
)
}
// ── Priority ─────────────────────────────────────────────────────────────────
const PRIORITY_ICON: Record<Priority, Glyph> = {
urgent: AlertTriangle,
high: SignalHigh,
medium: SignalMedium,
low: SignalLow,
none: Minus,
}
/** The priority bars glyph (urgent = alert), colored per level. */
export function PriorityIcon({ priority, size = 15 }: { priority: Priority; size?: number }) {
const Icon = PRIORITY_ICON[priority]
return <Icon size={size} color={PRIORITY_COLOR[priority]} />
}
export function PriorityTag({ priority }: { priority: Priority }) {
if (priority === 'none') {
return (
<Text fontSize="$2" color="$color9">
</Text>
)
}
return (
<XStack items="center" gap="$1.5">
<PriorityIcon priority={priority} size={13} />
<Text fontSize="$1" color="$color11" fontWeight="500">
{PRIORITY_LABEL[priority]}
</Text>
</XStack>
)
}
// ── Kind / Source badges ─────────────────────────────────────────────────────
const KIND_ICON: Record<Kind, Glyph> = {
issue: Circle,
pr: GitPullRequest,
epic: Layers,
}
/** A kind glyph — a PR/epic reads distinctly from a plain issue. */
export function KindIcon({ kind, size = 14 }: { kind: Kind; size?: number }) {
const Icon = KIND_ICON[kind]
// Inline so the literal color token type-checks against the icon's ColorToken prop.
return <Icon size={size} color={kind === 'pr' ? '$green10' : kind === 'epic' ? '$purple10' : '$color9'} />
}
/** A source chip — GitHub / Agent get their mark; team is quiet. */
export function SourceBadge({ source }: { source: Source }) {
if (source === 'team') return null
const Icon: Glyph = source === 'git' ? Github : source === 'agent' ? Bot : Circle
return (
<XStack items="center" gap="$1" px="$1.5" py="$0.5" rounded="$2" bg="$color3">
<Icon size={11} color="$color10" />
<Text fontSize="$1" color="$color10" fontWeight="600">
{SOURCE_LABEL[source]}
</Text>
</XStack>
)
}
// ── Labels / assignee / meta ─────────────────────────────────────────────────
export function LabelChips({ labels, max = 3 }: { labels: string[]; max?: number }) {
if (labels.length === 0) return null
const shown = labels.slice(0, max)
const extra = labels.length - shown.length
return (
<XStack gap="$1" flexWrap="wrap" items="center">
{shown.map((l) => (
<XStack key={l} items="center" gap="$1" px="$1.5" py="$0.5" rounded="$10" bg="$color3">
<YStack width={6} height={6} rounded="$10" bg="$color9" />
<Text fontSize="$1" color="$color11" numberOfLines={1}>
{l}
</Text>
</XStack>
))}
{extra > 0 ? (
<Text fontSize="$1" color="$color9">
+{extra}
</Text>
) : null}
</XStack>
)
}
/** A small round identity chip — initials on a tinted tile. */
export function Avatar({ name, size = 20 }: { name?: string; size?: number }) {
const label = (name ?? '').trim()
if (!label) {
return (
<YStack
width={size}
height={size}
rounded="$10"
borderWidth={1}
borderColor="$color6"
borderStyle="dashed"
/>
)
}
const initials = (label.split(/[\s@._-]+/).map((w) => w[0]).join('').slice(0, 2) || 'U').toUpperCase()
return (
<XStack width={size} height={size} items="center" justify="center" rounded="$10" bg="$color5">
<Text fontSize={Math.round(size * 0.42)} fontWeight="800" color="$color12">
{initials}
</Text>
</XStack>
)
}
/** A mono identifier tag (ENG-12). */
export function Identifier({ id, muted }: { id: string; muted?: boolean }) {
return (
<Text className="hz-mono" fontSize="$1" color={muted ? '$color9' : '$color10'} numberOfLines={1}>
{id}
</Text>
)
}
/** A thin progress bar (done / total) — used by Cycles + Roadmap. */
export function ProgressBar({ value, width = 120 }: { value: number; width?: number }) {
const pct = Math.max(0, Math.min(1, Number.isFinite(value) ? value : 0))
return (
<YStack width={width} height={6} rounded="$10" bg="$color4" overflow="hidden">
<YStack height={6} rounded="$10" bg={pct >= 1 ? '$green10' : '$color11'} width={`${pct * 100}%`} />
</YStack>
)
}
@@ -0,0 +1,243 @@
import { describe, it, expect } from 'vitest'
import type { Issue } from '~/lib/api/tracker'
import {
filterIssues,
countFilters,
matchesQuery,
sortIssues,
groupIssues,
boardColumns,
myIssues,
deriveCurrentCycle,
deriveRoadmap,
githubUrl,
gitHanzoUrl,
linkedPRs,
relTime,
assigneesOf,
labelsOf,
parseLabels,
} from './logic'
// ── fixtures ─────────────────────────────────────────────────────────────────
let seq = 0
function mk(p: Partial<Issue> = {}): Issue {
seq += 1
const key = p.projectKey ?? 'ENG'
const n = p.number ?? seq
return {
id: p.id ?? `issue_${seq}`,
identifier: p.identifier ?? `${key}-${n}`,
projectKey: key,
number: n,
kind: p.kind ?? 'issue',
source: p.source ?? 'team',
repo: p.repo,
extRef: p.extRef,
title: p.title ?? `Issue ${n}`,
description: p.description,
status: p.status ?? 'backlog',
priority: p.priority ?? 'none',
assignee: p.assignee,
labels: p.labels ?? [],
createdAt: p.createdAt ?? 1_700_000_000,
updatedAt: p.updatedAt ?? 1_700_000_000,
}
}
describe('filterIssues', () => {
const issues = [
mk({ status: 'todo', priority: 'high', assignee: 'ada@hanzo.ai', labels: ['bug'], source: 'git', repo: 'hanzoai/cloud' }),
mk({ status: 'done', priority: 'low', assignee: 'bob@hanzo.ai', labels: ['chore'], source: 'team' }),
mk({ status: 'todo', priority: 'urgent', kind: 'pr', source: 'agent', repo: 'hanzoai/cloud' }),
]
it('constrains only on set fields (empty = pass-through)', () => {
expect(filterIssues(issues, {})).toHaveLength(3)
expect(filterIssues(issues, { status: 'todo' })).toHaveLength(2)
expect(filterIssues(issues, { kind: 'pr' })).toHaveLength(1)
expect(filterIssues(issues, { source: 'git' })).toHaveLength(1)
expect(filterIssues(issues, { assignee: 'ada@hanzo.ai' })).toHaveLength(1)
expect(filterIssues(issues, { label: 'bug' })).toHaveLength(1)
expect(filterIssues(issues, { team: 'ENG' })).toHaveLength(3)
expect(filterIssues(issues, { team: 'ZZZ' })).toHaveLength(0)
})
it('AND-composes multiple constraints', () => {
expect(filterIssues(issues, { status: 'todo', priority: 'urgent' })).toHaveLength(1)
})
it('counts active constraints', () => {
expect(countFilters({})).toBe(0)
expect(countFilters({ status: 'todo', q: 'x' })).toBe(2)
expect(countFilters({ q: '' })).toBe(0)
})
})
describe('matchesQuery', () => {
const i = mk({ identifier: 'GH-42', title: 'Login redirect loops', labels: ['auth'], assignee: 'ada@hanzo.ai', repo: 'hanzoai/iam' })
it('is a case-insensitive substring over identity/title/labels/assignee/repo', () => {
expect(matchesQuery(i, 'gh-42')).toBe(true)
expect(matchesQuery(i, 'redirect')).toBe(true)
expect(matchesQuery(i, 'AUTH')).toBe(true)
expect(matchesQuery(i, 'iam')).toBe(true)
expect(matchesQuery(i, 'nope')).toBe(false)
expect(matchesQuery(i, '')).toBe(true)
})
})
describe('sortIssues', () => {
it('orders by priority (urgent→none) then most-recently-updated', () => {
const a = mk({ priority: 'low', updatedAt: 100 })
const b = mk({ priority: 'urgent', updatedAt: 1 })
const c = mk({ priority: 'urgent', updatedAt: 50 })
const out = sortIssues([a, b, c])
expect(out.map((x) => x.id)).toEqual([c.id, b.id, a.id])
})
it('does not mutate the input', () => {
const input = [mk({ priority: 'low' }), mk({ priority: 'urgent' })]
const snap = input.map((x) => x.id)
sortIssues(input)
expect(input.map((x) => x.id)).toEqual(snap)
})
})
describe('groupIssues', () => {
const issues = [
mk({ status: 'todo', priority: 'high', assignee: 'ada', projectKey: 'ENG' }),
mk({ status: 'done', priority: 'low', projectKey: 'GH' }),
mk({ status: 'todo', priority: 'urgent', assignee: 'ada', projectKey: 'ENG' }),
]
it('status grouping renders all five columns in order', () => {
const g = groupIssues(issues, 'status')
expect(g.map((x) => x.key)).toEqual(['backlog', 'todo', 'in_progress', 'done', 'canceled'])
expect(g.find((x) => x.key === 'todo')!.issues).toHaveLength(2)
})
it('assignee grouping puts Unassigned last', () => {
const g = groupIssues(issues, 'assignee')
expect(g[g.length - 1].label).toBe('Unassigned')
expect(g[0].label).toBe('ada')
})
it('team grouping buckets by projectKey', () => {
const g = groupIssues(issues, 'team')
expect(g.map((x) => x.key).sort()).toEqual(['ENG', 'GH'])
})
it('none is a single group', () => {
expect(groupIssues(issues, 'none')).toHaveLength(1)
expect(groupIssues(issues, 'none')[0].issues).toHaveLength(3)
})
it('board columns are always the five statuses', () => {
expect(boardColumns([]).map((x) => x.key)).toEqual(['backlog', 'todo', 'in_progress', 'done', 'canceled'])
})
})
describe('myIssues', () => {
const issues = [
mk({ assignee: 'ada@hanzo.ai' }),
mk({ assignee: 'Ada' }),
mk({ assignee: 'bob@hanzo.ai' }),
mk({ assignee: undefined }),
]
it('matches full email, local-part, and case-insensitively', () => {
expect(myIssues(issues, 'ada@hanzo.ai')).toHaveLength(2)
expect(myIssues(issues, 'ADA')).toHaveLength(2)
expect(myIssues(issues, 'bob')).toHaveLength(1)
})
it('empty identity → nothing (honest)', () => {
expect(myIssues(issues, '')).toHaveLength(0)
expect(myIssues(issues, null)).toHaveLength(0)
})
})
describe('deriveCurrentCycle', () => {
const now = 1_700_000_000_000
const recent = Math.floor(now / 1000) - 2 * 86400 // 2d ago (seconds)
const old = Math.floor(now / 1000) - 30 * 86400 // 30d ago
const issues = [
mk({ status: 'done', updatedAt: recent }),
mk({ status: 'in_progress', updatedAt: recent }),
mk({ status: 'todo', updatedAt: recent }),
mk({ status: 'done', updatedAt: old }), // outside the window
mk({ status: 'canceled', updatedAt: recent }), // excluded
]
it('windows recent non-canceled work and computes progress', () => {
const c = deriveCurrentCycle(issues, 14, now)
expect(c.total).toBe(3) // recent done + in_progress + todo
expect(c.done).toBe(1)
expect(c.progress).toBeCloseTo(1 / 3)
expect(c.active.map((i) => i.status).sort()).toEqual(['in_progress', 'todo'])
})
it('empty set → zero progress, no divide-by-zero', () => {
expect(deriveCurrentCycle([], 14, now).progress).toBe(0)
})
})
describe('deriveRoadmap', () => {
it('links epic children by ExtRef (identifier or id) and computes progress', () => {
const epic = mk({ id: 'epic_1', identifier: 'ENG-1', kind: 'epic', title: 'Auth revamp' })
const c1 = mk({ identifier: 'ENG-2', extRef: 'ENG-1', status: 'done' })
const c2 = mk({ identifier: 'ENG-3', extRef: 'ENG-1', status: 'todo' })
const c3 = mk({ identifier: 'ENG-4', extRef: 'epic_1', status: 'in_progress' })
const unrelated = mk({ identifier: 'ENG-5' })
const rows = deriveRoadmap([epic, c1, c2, c3, unrelated])
expect(rows).toHaveLength(1)
expect(rows[0].total).toBe(3)
expect(rows[0].done).toBe(1)
expect(rows[0].children.map((x) => x.identifier).sort()).toEqual(['ENG-2', 'ENG-3', 'ENG-4'])
})
it('no epics → empty roadmap (honest)', () => {
expect(deriveRoadmap([mk(), mk()])).toHaveLength(0)
})
})
describe('external links', () => {
it('parses the github: anchor form to an issues URL', () => {
expect(githubUrl(mk({ extRef: 'github:hanzoai/cloud#123' }))).toBe('https://github.com/hanzoai/cloud/issues/123')
})
it('passes an absolute URL through', () => {
expect(githubUrl(mk({ extRef: 'https://github.com/hanzoai/cloud/pull/9' }))).toBe('https://github.com/hanzoai/cloud/pull/9')
})
it('is null without a recognizable anchor', () => {
expect(githubUrl(mk({ extRef: 'some-branch' }))).toBeNull()
expect(githubUrl(mk({}))).toBeNull()
})
it('builds the native git.hanzo.ai URL', () => {
expect(gitHanzoUrl('hanzoai/cloud')).toBe('https://git.hanzo.ai/hanzoai/cloud')
expect(gitHanzoUrl('')).toBeNull()
expect(gitHanzoUrl(undefined)).toBeNull()
})
})
describe('linkedPRs', () => {
it('finds agent/git PR rows by shared repo or ExtRef parent', () => {
const issue = mk({ identifier: 'ENG-10', repo: 'hanzoai/cloud' })
const prSameRepo = mk({ kind: 'pr', source: 'agent', repo: 'hanzoai/cloud', extRef: 'feat/x' })
const prParent = mk({ kind: 'pr', source: 'agent', repo: 'other/repo', extRef: 'ENG-10' })
const otherPr = mk({ kind: 'pr', repo: 'unrelated/repo', extRef: 'feat/y' })
const links = linkedPRs(issue, [issue, prSameRepo, prParent, otherPr])
expect(links.map((x) => x.id).sort()).toEqual([prParent.id, prSameRepo.id].sort())
})
it('a PR row itself links to nothing', () => {
expect(linkedPRs(mk({ kind: 'pr' }), [])).toHaveLength(0)
})
})
describe('relTime', () => {
const now = 1_700_000_000_000
it('renders compact buckets', () => {
expect(relTime(Math.floor(now / 1000), now)).toBe('just now')
expect(relTime(Math.floor(now / 1000) - 3600, now)).toBe('1h')
expect(relTime(Math.floor(now / 1000) - 3 * 86400, now)).toBe('3d')
expect(relTime(undefined, now)).toBe('—')
})
})
describe('menu helpers', () => {
const issues = [mk({ assignee: 'ada', labels: ['bug', 'p1'] }), mk({ assignee: 'bob', labels: ['bug'] }), mk({})]
it('assigneesOf is distinct + sorted', () => {
expect(assigneesOf(issues)).toEqual(['ada', 'bob'])
})
it('labelsOf is distinct + sorted', () => {
expect(labelsOf(issues)).toEqual(['bug', 'p1'])
})
it('parseLabels trims and drops empties', () => {
expect(parseLabels('bug, , p1 ,')).toEqual(['bug', 'p1'])
})
})
+348
View File
@@ -0,0 +1,348 @@
/**
* Tracker logic PURE, dependency-free decisions for the Linear-grade tracker, so
* the grouping / filtering / sorting / cycle+roadmap derivation / link building is
* unit-tested in isolation (logic.test.ts) with no React, no network. The views are
* thin renderers over these functions; every "where does this issue go / how is it
* ordered / what links to it" question is answered HERE, once.
*
* All of it operates over the real `Issue`/`Project` payloads from `lib/api/tracker`
* no fabricated rows, honest empties. Cycles + Roadmap are DERIVED from the real
* model (active set + progress; epics and their ExtRef children), never invented.
*/
import {
type Issue,
type Project,
type Status,
type Priority,
type Kind,
type Source,
STATUSES,
ACTIVE_STATUSES,
} from '~/lib/api/tracker'
// ── Presentation tokens (as-const so literals satisfy the GUI color union, exactly
// like StatusTag's TONE maps) ──────────────────────────────────────────────
export const STATUS_ORDER: readonly Status[] = STATUSES
export const STATUS_LABEL: Record<Status, string> = {
backlog: 'Backlog',
todo: 'Todo',
in_progress: 'In Progress',
done: 'Done',
canceled: 'Canceled',
}
export const STATUS_DOT = {
backlog: '$color8',
todo: '$color11',
in_progress: '$yellow10',
done: '$green10',
canceled: '$red10',
} as const
export const PRIORITY_ORDER: readonly Priority[] = ['urgent', 'high', 'medium', 'low', 'none']
export const PRIORITY_LABEL: Record<Priority, string> = {
none: 'No priority',
urgent: 'Urgent',
high: 'High',
medium: 'Medium',
low: 'Low',
}
export const PRIORITY_COLOR = {
none: '$color9',
urgent: '$red10',
high: '$yellow10',
medium: '$yellow10',
low: '$color10',
} as const
/** Sort rank — urgent first, no-priority last. Lower = higher up. */
export const PRIORITY_RANK: Record<Priority, number> = { urgent: 0, high: 1, medium: 2, low: 3, none: 4 }
export const KIND_LABEL: Record<Kind, string> = { issue: 'Issue', pr: 'Pull Request', epic: 'Epic' }
export const SOURCE_LABEL: Record<Source, string> = {
team: 'Team',
git: 'GitHub',
crm: 'CRM',
helpdesk: 'Helpdesk',
cms: 'CMS',
agent: 'Agent',
}
// ── Dates ────────────────────────────────────────────────────────────────────
/** Format an epoch timestamp (seconds OR milliseconds) as a local date. */
export const fmtDate = (ts?: number): string => {
if (!ts) return '—'
const d = new Date(ts < 1e12 ? ts * 1000 : ts)
return Number.isNaN(d.getTime()) ? '—' : d.toLocaleDateString()
}
/** Compact relative time ("3d", "2h", "just now"). `now` injectable for tests. */
export const relTime = (ts?: number, now: number = Date.now()): string => {
if (!ts) return '—'
const ms = ts < 1e12 ? ts * 1000 : ts
const s = Math.max(0, Math.floor((now - ms) / 1000))
if (s < 45) return 'just now'
const m = Math.floor(s / 60)
if (m < 60) return `${m}m`
const h = Math.floor(m / 60)
if (h < 24) return `${h}h`
const d = Math.floor(h / 24)
if (d < 7) return `${d}d`
const w = Math.floor(d / 7)
if (w < 5) return `${w}w`
return fmtDate(ts)
}
// ── Filtering ────────────────────────────────────────────────────────────────
export type IssueFilters = {
status?: Status
priority?: Priority
kind?: Kind
source?: Source
assignee?: string
label?: string
team?: string
/** free-text — substring over identifier / title / labels / assignee / repo. */
q?: string
}
/** Split a comma input into a clean label list. */
export const parseLabels = (s: string): string[] => s.split(',').map((x) => x.trim()).filter(Boolean)
/** Does `issue` match the free-text query (case-insensitive substring, any field)? */
export function matchesQuery(i: Issue, q: string): boolean {
const needle = q.trim().toLowerCase()
if (!needle) return true
const hay = [i.identifier, i.title, i.assignee ?? '', i.repo ?? '', i.labels.join(' ')]
.join(' ')
.toLowerCase()
return hay.includes(needle)
}
/** Apply the active filter set. Empty fields impose no constraint. Pure. */
export function filterIssues(issues: Issue[], f: IssueFilters): Issue[] {
return issues.filter((i) => {
if (f.status && i.status !== f.status) return false
if (f.priority && i.priority !== f.priority) return false
if (f.kind && i.kind !== f.kind) return false
if (f.source && i.source !== f.source) return false
if (f.team && i.projectKey !== f.team) return false
if (f.assignee && (i.assignee ?? '') !== f.assignee) return false
if (f.label && !i.labels.includes(f.label)) return false
if (f.q && !matchesQuery(i, f.q)) return false
return true
})
}
/** Count active (non-empty) constraints — drives the "N filters" pill. */
export const countFilters = (f: IssueFilters): number =>
(['status', 'priority', 'kind', 'source', 'assignee', 'label', 'team', 'q'] as const).filter(
(k) => (f[k] ?? '') !== '',
).length
// ── Sorting ──────────────────────────────────────────────────────────────────
/** Canonical in-group order: priority (urgent→none), then most-recently-updated. */
export function sortIssues(issues: Issue[]): Issue[] {
return [...issues].sort(
(a, b) => PRIORITY_RANK[a.priority] - PRIORITY_RANK[b.priority] || b.updatedAt - a.updatedAt,
)
}
// ── Grouping ─────────────────────────────────────────────────────────────────
export type GroupBy = 'status' | 'priority' | 'assignee' | 'team' | 'none'
export const GROUP_BY: readonly GroupBy[] = ['status', 'priority', 'assignee', 'team', 'none']
export const GROUP_BY_LABEL: Record<GroupBy, string> = {
status: 'Status',
priority: 'Priority',
assignee: 'Assignee',
team: 'Team',
none: 'No grouping',
}
export type IssueGroup = { key: string; label: string; issues: Issue[] }
/**
* Group + sort issues for a sectioned List. `status`/`priority` render EVERY column
* in canonical order (even empty ones a board shows its empty columns);
* `assignee`/`team` render only the groups that occur, alphabetically (Unassigned
* last). `none` is one anonymous group. Each group's issues are `sortIssues`-ordered.
*/
export function groupIssues(issues: Issue[], by: GroupBy): IssueGroup[] {
if (by === 'none') return [{ key: 'all', label: '', issues: sortIssues(issues) }]
if (by === 'status') {
return STATUS_ORDER.map((s) => ({
key: s,
label: STATUS_LABEL[s],
issues: sortIssues(issues.filter((i) => i.status === s)),
}))
}
if (by === 'priority') {
return PRIORITY_ORDER.map((p) => ({
key: p,
label: PRIORITY_LABEL[p],
issues: sortIssues(issues.filter((i) => i.priority === p)),
}))
}
// assignee | team — dynamic buckets, sorted, "Unassigned"/blank team last.
const keyOf = (i: Issue) => (by === 'assignee' ? i.assignee ?? '' : i.projectKey)
const buckets = new Map<string, Issue[]>()
for (const i of issues) {
const k = keyOf(i)
;(buckets.get(k) ?? buckets.set(k, []).get(k)!).push(i)
}
const keys = [...buckets.keys()].sort((a, b) => {
if (a === '' && b !== '') return 1
if (b === '' && a !== '') return -1
return a.localeCompare(b)
})
return keys.map((k) => ({
key: k || 'unassigned',
label: k || (by === 'assignee' ? 'Unassigned' : 'No team'),
issues: sortIssues(buckets.get(k)!),
}))
}
/** Board columns — always the five statuses, in order (Board view is status-fixed). */
export function boardColumns(issues: Issue[]): IssueGroup[] {
return STATUS_ORDER.map((s) => ({
key: s,
label: STATUS_LABEL[s],
issues: sortIssues(issues.filter((i) => i.status === s)),
}))
}
// ── "My Issues" ──────────────────────────────────────────────────────────────
/**
* Issues assigned to `me` matched case-insensitively against the assignee, by
* either the full value or its local-part (so "ada@hanzo.ai", "ada", and "Ada" all
* hit). Empty `me` nothing (honest; the caller shows a sign-in-scoped empty).
*/
export function myIssues(issues: Issue[], me?: string | null): Issue[] {
const id = (me ?? '').trim().toLowerCase()
if (!id) return []
const local = id.split('@')[0]
return issues.filter((i) => {
const a = (i.assignee ?? '').trim().toLowerCase()
return a !== '' && (a === id || a === local || a.split('@')[0] === local)
})
}
// ── Cycles (derived, honest) ─────────────────────────────────────────────────
export type CycleSummary = {
/** active work (todo + in_progress) — the current cycle's scope. */
active: Issue[]
/** everything touched recently (default 14d) — the cycle window. */
inWindow: Issue[]
total: number
done: number
/** 0..1 completion of the windowed set. */
progress: number
}
/**
* The "current cycle" a DERIVED iteration view (Linear's default cadence is a
* rolling 12 week cycle). We do not invent a cycle entity the backend lacks: the
* current cycle is the issues touched inside `windowDays` (default 14), its progress
* the share already Done, and its scope the still-active work. Honest + real.
*/
export function deriveCurrentCycle(issues: Issue[], windowDays = 14, now: number = Date.now()): CycleSummary {
const cutoff = now - windowDays * 86400_000
const inWindow = issues.filter((i) => {
const ms = i.updatedAt < 1e12 ? i.updatedAt * 1000 : i.updatedAt
return ms >= cutoff && i.status !== 'canceled'
})
const active = issues.filter((i) => (ACTIVE_STATUSES as readonly Status[]).includes(i.status))
const total = inWindow.length
const done = inWindow.filter((i) => i.status === 'done').length
return { active: sortIssues(active), inWindow, total, done, progress: total ? done / total : 0 }
}
// ── Roadmap (epics + children, derived) ──────────────────────────────────────
export type EpicRow = { epic: Issue; children: Issue[]; total: number; done: number; progress: number }
/**
* Roadmap = every epic (Kind:"epic") with its children the issues whose ExtRef
* anchors that epic (per the cloud contract: "an epic's children are issues whose
* ExtRef is the epic"). We match ExtRef against the epic's identifier OR id, so a
* child can point at either. Progress is the share of children already Done. Epics
* with the most open work sort first.
*/
export function deriveRoadmap(issues: Issue[]): EpicRow[] {
const epics = issues.filter((i) => i.kind === 'epic')
const rows = epics.map((epic) => {
const children = issues.filter(
(i) => i.id !== epic.id && (i.extRef === epic.identifier || i.extRef === epic.id),
)
const total = children.length
const done = children.filter((c) => c.status === 'done').length
return { epic, children: sortIssues(children), total, done, progress: total ? done / total : 0 }
})
return rows.sort((a, b) => b.total - b.done - (a.total - a.done) || b.epic.updatedAt - a.epic.updatedAt)
}
// ── External links (GitHub source + native git.hanzo.ai) ─────────────────────
/**
* The upstream GitHub URL for a mirrored issue, or null. The App-webhook seam stores
* ExtRef as "github:owner/repo#123" (or an https URL). Parse the anchor form to the
* canonical issues URL; pass an already-absolute URL through.
*/
export function githubUrl(i: Issue): string | null {
const ref = (i.extRef ?? '').trim()
if (/^https?:\/\//i.test(ref)) return ref
const m = ref.match(/^github:([^#\s]+)#(\d+)$/i)
if (m) return `https://github.com/${m[1]}/issues/${m[2]}`
return null
}
/** The native git.hanzo.ai URL for a repo binding (owner/name), or null. */
export function gitHanzoUrl(repo?: string): string | null {
const r = (repo ?? '').trim().replace(/^\/+|\/+$/g, '')
return r ? `https://git.hanzo.ai/${r}` : null
}
/**
* PRs linked to `issue` within `all`: agent/git pull-request rows (Kind:"pr") that
* either share the issue's repo binding or anchor this issue by ExtRef (the coding
* seam sets the PR's ExtRef to its branch and can parent the originating issue). The
* issuebranchPR chain the detail pane renders. Excludes the issue itself.
*/
export function linkedPRs(issue: Issue, all: Issue[]): Issue[] {
if (issue.kind === 'pr') return []
return sortIssues(
all.filter(
(i) =>
i.id !== issue.id &&
i.kind === 'pr' &&
((issue.repo && i.repo === issue.repo) || i.extRef === issue.identifier),
),
)
}
/** True when the DOM target is a text-entry element — suppress single-key shortcuts. */
export function isTypingTarget(el: EventTarget | null): boolean {
const t = el as HTMLElement | null
if (!t) return false
const tag = t.tagName
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t.isContentEditable === true
}
/** Distinct assignees present in a set, sorted (for the filter menu). */
export const assigneesOf = (issues: Issue[]): string[] =>
[...new Set(issues.map((i) => (i.assignee ?? '').trim()).filter(Boolean))].sort((a, b) => a.localeCompare(b))
/** Distinct labels present in a set, sorted (for the filter menu). */
export const labelsOf = (issues: Issue[]): string[] =>
[...new Set(issues.flatMap((i) => i.labels))].filter(Boolean).sort((a, b) => a.localeCompare(b))
/** Map a team KEY to its display name from the project list (falls back to the key). */
export const teamName = (key: string, projects: Project[]): string =>
projects.find((p) => p.key === key)?.name || key
+502
View File
@@ -0,0 +1,502 @@
'use client'
/**
* Tracker views the Linear-grade surfaces rendered by the module's router:
* IssuesView the unified issues board (All / My / a Team) toolbar + group/filter/
* search + ListBoard + keyboard selection (j/k///Enter/e, c, /).
* TeamsView the teams (KEY-prefixed projects) list + create, with a GitHub sync.
* CyclesView the current cycle derived from the active window, honest progress.
* RoadmapView epics + their ExtRef children, with completion.
*
* Every view reads the SAME unified `issues` array (composed cross-team in the module)
* and decides layout purely via `logic.ts`. Honest empties; no fabricated rows.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
import { ClipboardList, Plus, RefreshCw, Github, Layers, Target, ArrowLeft } from '@hanzogui/lucide-icons-2'
import {
TrackerApi,
type Issue,
type Project,
type NewProject,
} from '~/lib/api/tracker'
import { PageHeader } from '~/components/ui/PageHeader'
import { EmptyState } from '~/components/ui/EmptyState'
import { FieldRow, FieldText, FieldTextArea } from '~/components/ui/Field'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import { useToast } from '~/components/ui/Toast'
import { asApiError } from '~/components/ui/States'
import {
type GroupBy,
type IssueFilters,
filterIssues,
groupIssues,
boardColumns,
deriveCurrentCycle,
deriveRoadmap,
assigneesOf,
labelsOf,
isTypingTarget,
teamName,
relTime,
STATUS_LABEL,
} from './logic'
import { Toolbar } from './Toolbar'
import { GroupedList, Board } from './IssueList'
import { StatusIcon, PriorityIcon, Identifier, ProgressBar } from './atoms'
// ── The unified issues view (All / My / a Team) ──────────────────────────────
export function IssuesView({
issues,
projects,
header,
showTeam = true,
defaultFilters,
onOpen,
onCreate,
onReload,
actions,
}: {
issues: Issue[]
projects: Project[]
header: React.ReactNode
showTeam?: boolean
defaultFilters?: IssueFilters
onOpen: (i: Issue) => void
onCreate: () => void
onReload: () => void
actions?: React.ReactNode
}) {
const [view, setView] = useState<'list' | 'board'>('list')
const [groupBy, setGroupBy] = useState<GroupBy>('status')
const [filters, setFilters] = useState<IssueFilters>({})
const [query, setQuery] = useState('')
const [selectedId, setSelectedId] = useState<string | undefined>(undefined)
const searchRef = useRef<HTMLInputElement>(null)
const filtered = useMemo(
() => filterIssues(issues, { ...defaultFilters, ...filters, q: query }),
[issues, defaultFilters, filters, query],
)
const groups = useMemo(
() => (view === 'board' ? boardColumns(filtered) : groupIssues(filtered, groupBy)),
[filtered, groupBy, view],
)
const flat = useMemo(() => groups.flatMap((g) => g.issues), [groups])
const assignees = useMemo(() => assigneesOf(issues), [issues])
const labels = useMemo(() => labelsOf(issues), [issues])
// Keep the selection valid as the list changes.
useEffect(() => {
if (selectedId && !flat.some((i) => i.id === selectedId)) setSelectedId(flat[0]?.id)
}, [flat, selectedId])
// Keyboard-first (Linear): c=create, /=search, j/k/↑/↓=move, Enter=open, e=edit.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.metaKey || e.ctrlKey || e.altKey) return
if (e.key === '/') {
if (isTypingTarget(e.target)) return
e.preventDefault()
searchRef.current?.focus()
return
}
if (isTypingTarget(e.target)) return
if (e.key === 'c') {
e.preventDefault()
onCreate()
} else if (e.key === 'j' || e.key === 'ArrowDown') {
e.preventDefault()
setSelectedId((cur) => {
const idx = flat.findIndex((i) => i.id === cur)
return flat[Math.min(idx + 1, flat.length - 1)]?.id ?? flat[0]?.id
})
} else if (e.key === 'k' || e.key === 'ArrowUp') {
e.preventDefault()
setSelectedId((cur) => {
const idx = flat.findIndex((i) => i.id === cur)
return flat[Math.max(idx - 1, 0)]?.id ?? flat[0]?.id
})
} else if (e.key === 'Enter' || e.key === 'e') {
const sel = flat.find((i) => i.id === selectedId)
if (sel) {
e.preventDefault()
onOpen(sel)
}
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [flat, selectedId, onCreate, onOpen])
const glyphFor = useCallback(
(g: { key: string }) =>
groupBy === 'status' || view === 'board'
? <StatusIcon status={g.key as never} size={14} />
: groupBy === 'priority'
? <PriorityIcon priority={g.key as never} size={13} />
: undefined,
[groupBy, view],
)
return (
<YStack gap="$4">
{header}
<Toolbar
ref={searchRef}
view={view}
onView={setView}
groupBy={groupBy}
onGroupBy={setGroupBy}
filters={filters}
onFilters={setFilters}
query={query}
onQuery={setQuery}
assignees={assignees}
labels={labels}
actions={
<>
<Button size="$2" icon={<RefreshCw size={14} />} onPress={onReload} aria-label="Refresh" />
{actions}
<Button size="$2" icon={<Plus size={14} />} onPress={onCreate}>
New issue
</Button>
</>
}
/>
{filtered.length === 0 ? (
<EmptyState
icon={ClipboardList}
title={issues.length === 0 ? 'No issues yet' : 'No issues match'}
description={
issues.length === 0
? 'Create an issue, or connect GitHub so every repos issues flow in here.'
: 'Adjust the filters or search to see issues.'
}
primary={issues.length === 0 ? { label: 'New issue', icon: <Plus size={15} />, onPress: onCreate } : undefined}
/>
) : view === 'board' ? (
<Board columns={groups} projects={projects} selectedId={selectedId} showTeam={showTeam} glyphFor={glyphFor} onOpen={onOpen} />
) : (
<GroupedList
groups={groups}
projects={projects}
selectedId={selectedId}
showTeam={showTeam}
keepEmpty={groupBy === 'status'}
glyphFor={glyphFor}
onOpen={onOpen}
/>
)}
</YStack>
)
}
// ── Teams (KEY-prefixed projects) ────────────────────────────────────────────
export function TeamsView({
projects,
issues,
loading,
onOpenTeam,
onReload,
onSyncGitHub,
syncing,
}: {
projects: Project[]
issues: Issue[]
loading: boolean
onOpenTeam: (key: string) => void
onReload: () => void
onSyncGitHub: () => void
syncing: boolean
}) {
const toast = useToast()
const [creating, setCreating] = useState(false)
const [key, setKey] = useState('')
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [busy, setBusy] = useState(false)
const counts = useMemo(() => {
const m = new Map<string, { total: number; open: number }>()
for (const i of issues) {
const c = m.get(i.projectKey) ?? { total: 0, open: 0 }
c.total += 1
if (i.status !== 'done' && i.status !== 'canceled') c.open += 1
m.set(i.projectKey, c)
}
return m
}, [issues])
const reset = () => {
setKey('')
setName('')
setDescription('')
setCreating(false)
}
const create = async () => {
const n = name.trim()
if (!n) {
toast.error('Name required', 'Give the team a name.')
return
}
setBusy(true)
try {
const payload: NewProject = { name: n, description: description.trim() || undefined, ...(key.trim() ? { key: key.trim().toUpperCase() } : {}) }
const created = await TrackerApi.createProject(payload)
toast.success('Team created', `${created.key} · ${created.name}`)
reset()
onReload()
} catch (e) {
toast.error('Could not create team', asApiError(e).message)
} finally {
setBusy(false)
}
}
return (
<YStack gap="$4">
<PageHeader
title="Teams"
subtitle="Each team groups issues under a KEY (ENG-1, ENG-2…). GitHub-mirrored repos share the GH team."
actions={
<XStack gap="$2" flexWrap="wrap">
<Button size="$2" icon={<RefreshCw size={14} />} onPress={onReload} aria-label="Refresh" />
<Button size="$2" icon={<Github size={14} />} onPress={onSyncGitHub} disabled={syncing}>
{syncing ? 'Syncing…' : 'Sync GitHub'}
</Button>
<Button size="$2" icon={<Plus size={14} />} onPress={() => setCreating((v) => !v)}>
New team
</Button>
</XStack>
}
/>
{creating ? (
<Card borderWidth={1} borderColor="$borderColor" p="$4" gap="$3" maxW={640}>
<Text fontSize="$5" fontWeight="700">
New team
</Text>
<FieldRow label="Key (optional)">
<FieldText value={key} onChange={setKey} placeholder="ENG" disabled={busy} />
</FieldRow>
<FieldRow label="Name">
<FieldText value={name} onChange={setName} placeholder="Engineering" disabled={busy} />
</FieldRow>
<FieldRow label="Description">
<FieldTextArea value={description} onChange={setDescription} rows={3} disabled={busy} />
</FieldRow>
<Text fontSize="$2" color="$color10">
The key is uppercase (AZ then AZ09, 28 chars) and prefixes every issue (ENG-1). Omit it to derive one from the name.
</Text>
<XStack gap="$2">
<PrimaryButton disabled={busy} onPress={() => void create()}>
{busy ? 'Creating…' : 'Create team'}
</PrimaryButton>
<Button chromeless disabled={busy} onPress={reset}>
Cancel
</Button>
</XStack>
</Card>
) : null}
{!loading && projects.length === 0 && !creating ? (
<EmptyState
icon={ClipboardList}
title="No teams yet"
description="A team groups issues under a key. Create one, or Sync GitHub to mirror every repos issues into the GH team."
primary={{ label: 'New team', icon: <Plus size={15} />, onPress: () => setCreating(true) }}
/>
) : (
<YStack gap="$2">
{projects.map((p) => {
const c = counts.get(p.key) ?? { total: 0, open: 0 }
return (
<Card
key={p.key}
p="$3.5"
gap="$2"
borderWidth={1}
borderColor="$borderColor"
hoverStyle={{ borderColor: '$color8' }}
cursor="pointer"
onPress={() => onOpenTeam(p.key)}
>
<XStack items="center" gap="$2.5">
<XStack items="center" justify="center" width={34} height={34} rounded="$3" bg="$color4">
<Text className="hz-mono" fontSize="$3" fontWeight="800" color="$color12">
{p.key.slice(0, 3)}
</Text>
</XStack>
<YStack flex={1} minW={0}>
<Text fontSize="$4" fontWeight="700" color="$color12" numberOfLines={1}>
{p.name}
</Text>
<Text fontSize="$2" color="$color10" numberOfLines={1}>
{p.description || `${p.key} · updated ${relTime(p.updatedAt)}`}
</Text>
</YStack>
<YStack items="flex-end">
<Text fontSize="$4" fontWeight="700" color="$color12">
{c.open}
</Text>
<Text fontSize="$1" color="$color9">
open · {c.total} total
</Text>
</YStack>
</XStack>
</Card>
)
})}
</YStack>
)}
</YStack>
)
}
// ── Cycles (derived current cycle) ───────────────────────────────────────────
export function CyclesView({
issues,
projects,
onOpen,
}: {
issues: Issue[]
projects: Project[]
onOpen: (i: Issue) => void
}) {
const cycle = useMemo(() => deriveCurrentCycle(issues), [issues])
const groups = useMemo(() => groupIssues(cycle.active, 'status'), [cycle.active])
return (
<YStack gap="$4">
<PageHeader
title="Current cycle"
subtitle="A rolling two-week iteration, derived from recently-updated work. Progress is the share already done."
/>
<Card p="$4" gap="$3" borderWidth={1} borderColor="$borderColor">
<XStack items="center" gap="$3" flexWrap="wrap">
<XStack items="center" gap="$2">
<Target size={16} />
<Text fontSize="$5" fontWeight="700" color="$color12">
{cycle.done}/{cycle.total} done
</Text>
</XStack>
<ProgressBar value={cycle.progress} width={220} />
<Text fontSize="$2" color="$color10">
{Math.round(cycle.progress * 100)}% · {cycle.active.length} active
</Text>
</XStack>
</Card>
{cycle.active.length === 0 ? (
<EmptyState
icon={Target}
title="Nothing active this cycle"
description="Move issues to Todo or In Progress to bring them into the current cycle."
/>
) : (
<GroupedList
groups={groups}
projects={projects}
keepEmpty={false}
showTeam
glyphFor={(g) => <StatusIcon status={g.key as never} size={14} />}
onOpen={onOpen}
/>
)}
</YStack>
)
}
// ── Roadmap (epics + children) ───────────────────────────────────────────────
export function RoadmapView({ issues, projects, onOpen }: { issues: Issue[]; projects: Project[]; onOpen: (i: Issue) => void }) {
const rows = useMemo(() => deriveRoadmap(issues), [issues])
return (
<YStack gap="$4">
<PageHeader
title="Roadmap"
subtitle="Epics and the issues that roll up to them (a childs ExtRef anchors its epic)."
/>
{rows.length === 0 ? (
<EmptyState
icon={Layers}
title="No epics yet"
description="Create an issue with kind “epic”, then set a child issues ExtRef to the epics identifier to roll it up here."
/>
) : (
<YStack gap="$2.5">
{rows.map((r) => (
<Card key={r.epic.id} p="$3.5" gap="$3" borderWidth={1} borderColor="$borderColor">
<XStack
items="center"
gap="$2.5"
cursor="pointer"
onPress={() => onOpen(r.epic)}
hoverStyle={{ opacity: 0.85 }}
>
<Layers size={16} />
<YStack flex={1} minW={0}>
<XStack items="center" gap="$2">
<Identifier id={r.epic.identifier} />
<Text fontSize="$4" fontWeight="700" color="$color12" numberOfLines={1}>
{r.epic.title}
</Text>
</XStack>
</YStack>
<Text fontSize="$2" color="$color10">
{r.done}/{r.total}
</Text>
<ProgressBar value={r.progress} width={140} />
</XStack>
{r.children.length > 0 ? (
<YStack gap="$0.5" pl="$6">
{r.children.slice(0, 6).map((c) => (
<XStack
key={c.id}
items="center"
gap="$2"
py="$1"
cursor="pointer"
hoverStyle={{ opacity: 0.8 }}
onPress={() => onOpen(c)}
>
<StatusIcon status={c.status} size={13} />
<Identifier id={c.identifier} muted />
<Text flex={1} fontSize="$2" color="$color11" numberOfLines={1}>
{c.title}
</Text>
<Text fontSize="$1" color="$color9">
{STATUS_LABEL[c.status]}
</Text>
</XStack>
))}
{r.children.length > 6 ? (
<Text fontSize="$1" color="$color9" pl="$5">
+{r.children.length - 6} more
</Text>
) : null}
</YStack>
) : null}
</Card>
))}
</YStack>
)}
</YStack>
)
}
/** A back-to-teams header used by the single-team board. */
export function TeamHeader({ project, onBack }: { project: Project; onBack: () => void }) {
return (
<PageHeader
title={project.name}
subtitle={`${project.key} · ${project.description || 'Team board — grouped by status. Press “c” to create an issue.'}`}
actions={
<Button size="$2" chromeless icon={<ArrowLeft size={15} />} onPress={onBack}>
Teams
</Button>
}
/>
)
}
+107
View File
@@ -0,0 +1,107 @@
/**
* The telemetry wire pins what `TelemetrySurface` (Analytics.tsx) actually
* configures, so the ONE front door and the error plane cannot silently regress.
*
* This asserts the CLIENT half in a plain Node env (the suite convention: stub the
* browser globals a test needs, mock `fetch`). The React mount is one line in
* Analytics.tsx; what is worth pinning is that the client it builds emits a
* pageview to `POST https://api.hanzo.ai/v1/event` as `product: 'console'`, carries
* the console's IAM bearer, and resolves the hanzo-console Sentry DSN from the
* product name alone no `dsn` prop, no env var.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createTelemetry } from '@hanzogui/telemetry'
type FetchCall = { url: string; init: RequestInit }
let calls: FetchCall[]
beforeEach(() => {
calls = []
const store = new Map<string, string>()
const listeners = () => {}
const win = {
location: { hostname: 'console.hanzo.ai', search: '', href: 'https://console.hanzo.ai/' },
localStorage: {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
removeItem: (k: string) => void store.delete(k),
},
addEventListener: listeners,
removeEventListener: listeners,
}
vi.stubGlobal('window', win)
vi.stubGlobal('localStorage', win.localStorage)
vi.stubGlobal('document', { referrer: '', visibilityState: 'visible', addEventListener: listeners })
// No DNT/GPC — the consent-refused path is the telemetry package's own test.
vi.stubGlobal('navigator', { doNotTrack: undefined, userAgent: 'test' })
vi.stubGlobal('fetch', (url: string, init: RequestInit) => {
calls.push({ url: String(url), init })
return Promise.resolve({ ok: true, status: 200, text: () => Promise.resolve('{}') })
})
})
afterEach(() => vi.unstubAllGlobals())
/** Exactly how `TelemetrySurface` builds it: product + the IAM PKCE bearer. */
const consoleTelemetry = (token: string | null = 'iam-access-token') =>
createTelemetry({ product: 'console', getToken: () => token })
describe('console telemetry wire', () => {
it('POSTs a pageview to the ONE front door as product=console', () => {
const t = consoleTelemetry()
expect(t.enabled).toBe(true)
expect(t.product).toBe('console')
t.pageview('/')
t.flush()
expect(calls).toHaveLength(1)
expect(calls[0].url).toBe('https://api.hanzo.ai/v1/event')
const body = JSON.parse(String(calls[0].init.body))
const pv = body.batch.find((e: { type: string }) => e.type === 'pageview')
expect(pv).toBeTruthy()
expect(pv.product).toBe('console')
expect(pv.path).toBe('/')
})
it('carries the IAM access token as a Bearer', () => {
const t = consoleTelemetry('iam-access-token')
t.pageview('/')
t.flush()
const headers = calls[0].init.headers as Record<string, string>
expect(headers.Authorization).toBe('Bearer iam-access-token')
})
it('still emits when signed out (no token, no crash)', () => {
const t = consoleTelemetry(null)
t.pageview('/signin')
t.flush()
expect(calls).toHaveLength(1)
const headers = calls[0].init.headers as Record<string, string>
expect(headers.Authorization).toBeUndefined()
})
it('resolves the hanzo-console Sentry DSN from `product` alone', () => {
// The reason @hanzo/event was bumped to ^0.3.4: the DSN registry landed in
// 0.3.x, so declaring the product is what lights up sentry.hanzo.ai. A regression
// here means the console's errors silently stop reaching the dashboard.
const t = consoleTelemetry()
expect(t.client.errorPlaneEnabled).toBe(true)
expect(t.client.errorIngestUrl).toContain('api.hanzo.ai/v1/sentry/')
})
it('identify binds a person id on the one stream', () => {
const t = consoleTelemetry()
t.identify('hanzo/z')
t.flush()
const body = JSON.parse(String(calls[0].init.body))
const id = body.batch.find((e: { type: string }) => e.type === 'identify')
expect(id).toBeTruthy()
expect(id.personId).toBe('hanzo/z')
})
})

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