Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba571999a2 | ||
|
|
594314d351 | ||
|
|
c709b1f96a | ||
|
|
54d6ce9959 | ||
|
|
8a7ae9be23 | ||
|
|
19cebcb92a | ||
|
|
8b0a96c952 | ||
|
|
ad9789b072 | ||
|
|
7e4e052694 | ||
|
|
3cbaf7beb6 | ||
|
|
298accf659 | ||
|
|
12d061f579 | ||
|
|
ff87cdbd98 | ||
|
|
0f6cec3315 | ||
|
|
4d9aabb7bd | ||
|
|
30acbc52d0 | ||
|
|
c1a8c8495d | ||
|
|
a6bc25943f | ||
|
|
70b99204ac | ||
|
|
b88389e91c | ||
|
|
d78a71e50d | ||
|
|
f8d83250f7 | ||
|
|
a7a7fc9ef6 | ||
|
|
7c6305c455 | ||
|
|
15da85d0ac | ||
|
|
d20f0859f2 | ||
|
|
53375e44ed | ||
|
|
87abc48add | ||
|
|
8153606ac8 | ||
|
|
c4ac4eff3f | ||
|
|
922ba7d676 | ||
|
|
15b8cff948 | ||
|
|
bbff6b3a82 | ||
|
|
83e6eb4667 | ||
|
|
32c88caa12 | ||
|
|
b4c71e1aac | ||
|
|
c8295794fa | ||
|
|
40da2fc17d | ||
|
|
d09d3cae1b | ||
|
|
0207df0759 | ||
|
|
a0a489986d | ||
|
|
b6e35ee042 | ||
|
|
06e416365b | ||
|
|
da268976b8 | ||
|
|
2271c29297 | ||
|
|
9da398459e | ||
|
|
b9d31aa305 | ||
|
|
1b50c31a96 | ||
|
|
846069c98b | ||
|
|
06b7f5416a | ||
|
|
80cbfcbbb1 | ||
|
|
4656c316f4 | ||
|
|
d9bccc9e8d | ||
|
|
ffd291fedf | ||
|
|
4da48f9274 | ||
|
|
7a37a8fd92 | ||
|
|
29c98ddf5b | ||
|
|
f64de84a4e | ||
|
|
ecb9be3a8b | ||
|
|
400a206d80 | ||
|
|
628e6fb469 | ||
|
|
c1a7d48113 | ||
|
|
e5f510b994 | ||
|
|
0a883aefaf | ||
|
|
0c097129d8 | ||
|
|
3749f2669f | ||
|
|
06d0681081 | ||
|
|
cdb6f9d091 | ||
|
|
744164c4c5 | ||
|
|
454636c4e2 | ||
|
|
8bdbc9116a | ||
|
|
599f6f3a31 | ||
|
|
f26c8fbcca | ||
|
|
447825ef6d | ||
|
|
9915705d10 | ||
|
|
b10c5ce18f | ||
|
|
d93e9148b2 | ||
|
|
6b881b2a8e | ||
|
|
f85b08e44d | ||
|
|
daef1ea32c | ||
|
|
7a722f0a3c | ||
|
|
58ff94c159 | ||
|
|
b489ab0fdb | ||
|
|
13c5b30cd6 | ||
|
|
108977e0ca | ||
|
|
137432ad42 | ||
|
|
9bd12d7c28 | ||
|
|
bc93b371f6 | ||
|
|
2dc202092b | ||
|
|
eeeeab7074 | ||
|
|
73cdffbefa | ||
|
|
e25d198ea4 | ||
|
|
1bb596309a | ||
|
|
8c2476fff1 | ||
|
|
a9464d7264 | ||
|
|
95d5f0bbaa | ||
|
|
4d5cc48b4d | ||
|
|
2f3495ce53 | ||
|
|
be65648bd0 | ||
|
|
f54074fa14 | ||
|
|
12fc3c53b9 | ||
|
|
f6fb4ae8f9 | ||
|
|
e67dad275a | ||
|
|
702a5208cd | ||
|
|
a0ed4d8729 | ||
|
|
3899017e31 | ||
|
|
bcc1aab571 | ||
|
|
1e647907a0 | ||
|
|
147ecd3227 | ||
|
|
1991033f47 | ||
|
|
431efac48f | ||
|
|
601e0f819c | ||
|
|
9451a61910 | ||
|
|
f9272ec880 | ||
|
|
fc5fe3c616 | ||
|
|
fcdd98a80a | ||
|
|
0681460869 | ||
|
|
c5134dd89f | ||
|
|
30d620ed1e | ||
|
|
cbcf2466a6 | ||
|
|
8f011bea82 | ||
|
|
9059e1c361 | ||
|
|
0fdea766d1 | ||
|
|
cff833e0a2 | ||
|
|
0d68a6277f | ||
|
|
3bcc8ec3ed | ||
|
|
b286d48c7f | ||
|
|
07c1982b02 | ||
|
|
3b832c1a78 | ||
|
|
6116dae865 | ||
|
|
781b8125ef | ||
|
|
ba22401f73 | ||
|
|
3b8e9d7e59 | ||
|
|
0432a5f4d2 | ||
|
|
4f2b8c9fc6 | ||
|
|
c22c187b11 | ||
|
|
01b82acac1 | ||
|
|
832f14cbf7 | ||
|
|
e1db9bd702 | ||
|
|
b55eb201d6 | ||
|
|
e7b36af216 | ||
|
|
9d6190a783 |
@@ -3,6 +3,13 @@ node_modules
|
||||
out/
|
||||
dist/
|
||||
|
||||
# pnpm is the one package manager here — pnpm-lock.yaml is the tracked lockfile and
|
||||
# `packageManager` in package.json pins the version corepack installs. A lockfile
|
||||
# from any other manager is a second source of truth that silently drifts.
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
bun.lockb
|
||||
|
||||
# build artifacts
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
@@ -21,5 +21,5 @@ on:
|
||||
pull_request:
|
||||
jobs:
|
||||
cicd:
|
||||
uses: hanzoai/ci/.hanzo/workflows/build.yml@v2
|
||||
uses: hanzoai/ci/.hanzo/workflows/build.yml@v1
|
||||
secrets: inherit
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
name: Sync from GitHub
|
||||
# git.hanzo.ai is canonical; development also lands on
|
||||
# github.com/hanzoai/console. ONE deterministic direction: an in-cluster PULL.
|
||||
# The runner reaches both ends (GitHub outbound, this Gitea via the instance URL
|
||||
# actions/checkout already uses), so the sync has no ingress dependency.
|
||||
#
|
||||
# Fast-forward ONLY: a divergence fails loudly here instead of force-pushing
|
||||
# either side.
|
||||
#
|
||||
# Inert until hanzoai/console stops being a Gitea pull mirror — while it is one,
|
||||
# the forge overwrites main from GitHub on its own timer and rejects the push
|
||||
# below. That conversion is also what turns Actions on here (measured today:
|
||||
# mirror: true, has_actions: false ⇒ zero native runs).
|
||||
on:
|
||||
schedule:
|
||||
- cron: '*/10 * * * *'
|
||||
workflow_dispatch: {}
|
||||
concurrency:
|
||||
group: sync-from-github
|
||||
cancel-in-progress: false
|
||||
jobs:
|
||||
ff-main:
|
||||
runs-on: [hanzo-build-linux-amd64]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: true
|
||||
- name: Fast-forward main from github.com/hanzoai/console
|
||||
env:
|
||||
GH_PAT: ${{ secrets.GH_PAT }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --quiet "https://x-access-token:${GH_PAT}@github.com/hanzoai/console.git" main
|
||||
LOCAL="$(git rev-parse HEAD)"; REMOTE="$(git rev-parse FETCH_HEAD)"
|
||||
if [ "$LOCAL" = "$REMOTE" ]; then echo "in sync at $LOCAL"; exit 0; fi
|
||||
if git merge-base --is-ancestor "$LOCAL" "$REMOTE"; then
|
||||
echo "fast-forwarding $LOCAL -> $REMOTE"
|
||||
git push origin "$REMOTE:refs/heads/main"
|
||||
# A push made with the workflow token does not trigger workflows, so
|
||||
# synced commits would never build. Dispatch CI explicitly.
|
||||
curl -fsS --max-time 20 -X POST \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${{ github.server_url }}/v1/repos/${{ github.repository }}/actions/workflows/cicd.yml/dispatches" \
|
||||
-d '{"ref":"main"}' \
|
||||
&& echo "CI dispatched" || echo "CI dispatch failed (non-fatal — next direct push builds)"
|
||||
elif git merge-base --is-ancestor "$REMOTE" "$LOCAL"; then
|
||||
echo "canonical is AHEAD of GitHub — nothing to pull (never force from here)."
|
||||
else
|
||||
echo "::error::main DIVERGED between GitHub ($REMOTE) and canonical ($LOCAL) — refusing to force. Reconcile manually."
|
||||
exit 1
|
||||
fi
|
||||
+50
-44
@@ -1,45 +1,51 @@
|
||||
# console2 — Hanzo Cloud Console (Next.js 15 + @hanzo/gui). BSD-3-Clause.
|
||||
# NEXT_PUBLIC_* are inlined at build time (browser config), so they are build args.
|
||||
FROM public.ecr.aws/docker/library/node:24-alpine AS build
|
||||
WORKDIR /app
|
||||
# Exact commit for a deterministic Next build id (next.config.mjs generateBuildId).
|
||||
# The alpine image has no git binary, so CI passes the SHA as a build arg -> ENV,
|
||||
# baked into .next/BUILD_ID so every replica of this image shares ONE build id.
|
||||
ARG SOURCE_COMMIT=""
|
||||
ENV SOURCE_COMMIT=$SOURCE_COMMIT
|
||||
# Copy ALL source FIRST, then install — order matters under Kaniko --single-snapshot:
|
||||
# a `COPY` that FOLLOWS `RUN npm install` in the same stage drops the RUN's freshly
|
||||
# created node_modules (the 'next not found' cause — the install's own `test -f next`
|
||||
# passed, then `COPY . .` wiped node_modules before the build RUN). Putting COPY
|
||||
# before install means node_modules is created by the LAST RUNs and nothing clobbers
|
||||
# it. (Layer-cache for deps is moot here — the on-cluster build runs --cache=false.)
|
||||
COPY . .
|
||||
# public/ may be empty (git doesn't track empty dirs) — ensure it exists for the runner COPY.
|
||||
RUN mkdir -p public
|
||||
# npm install (not ci): @hanzo/gui pulls a react-native dep tree whose platform/
|
||||
# optional packages resolve differently across npm versions, so a lockfile generated
|
||||
# by one npm fails `npm ci` under another. install reconciles the tree for the
|
||||
# build platform; retry-hardened against registry throttling.
|
||||
RUN npm install --no-audit --no-fund --fetch-retries=5 --fetch-retry-mintimeout=20000 --fetch-timeout=120000
|
||||
# ONE brand-agnostic image: brand (IAM org/issuer/app + wordmark) is resolved at
|
||||
# RUNTIME from the request hostname (src/config/index.ts), and /v1 is same-origin
|
||||
# per host. Baking NEXT_PUBLIC_* here would inline a single brand and break that.
|
||||
# Next 15 + @hanzo/gui (large RN dep tree) overflows Node's default heap → OOMKill
|
||||
# (exit 137); cap the heap generously (chat uses 4096).
|
||||
ENV NEXT_TELEMETRY_DISABLED=1 NODE_OPTIONS=--max-old-space-size=6144
|
||||
RUN npm run build
|
||||
# hanzoai/console — the console image. It serves itself.
|
||||
#
|
||||
# The console is a static SPA export; this puts hanzoai/static in front of it. That
|
||||
# itself: hanzoai/static in front of the bundle. It exists so a console change
|
||||
# can reach production without a cloud release.
|
||||
#
|
||||
# Today console.hanzo.ai is answered by the cloud binary, which go:embeds the
|
||||
# bundle (webui/console.go `//go:embed all:dist`). That couples a frontend change
|
||||
# to a backend release: the bundle must be published, its tag pinned in cloud's
|
||||
# Dockerfile, and a whole cloud image rebuilt and rolled out. The pin commit that
|
||||
# preceded this one says what that costs — "four changes that could not reach
|
||||
# production".
|
||||
#
|
||||
# Nothing about the request path changes when this serves instead. The embedded
|
||||
# console is already a static export talking to the SAME origin's /v1, and cloud's
|
||||
# catch-all only ever answered paths that no API route claimed (its apiPrefixes
|
||||
# list is exactly "/v1/", "/api/", "/zap", "/healthz", "/readyz"). So the split is
|
||||
# the one the ingress already expresses for admin.lux.cloud: /v1 + /zap to cloud,
|
||||
# everything else here. Same bytes, same origin, same cookie — one fewer release
|
||||
# in the way.
|
||||
#
|
||||
# -spa, not a 404 page: every unknown path IS a client-side route for an app shell
|
||||
# (/models, /billing/budgets, a deep link someone pasted). The marketing site takes
|
||||
# the opposite setting for the opposite reason — there a miss is a mistake.
|
||||
|
||||
FROM public.ecr.aws/docker/library/node:24-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 PORT=4000
|
||||
RUN addgroup -S app && adduser -S app -G app
|
||||
COPY --from=build /app/.next ./.next
|
||||
COPY --from=build /app/public ./public
|
||||
COPY --from=build /app/node_modules ./node_modules
|
||||
COPY --from=build /app/package.json ./package.json
|
||||
COPY --from=build /app/next.config.mjs ./next.config.mjs
|
||||
# next.config.mjs imports this at load time (build AND standalone runtime); copy it or the server ERR_MODULE_NOT_FOUND-crashes on boot.
|
||||
COPY --from=build /app/src/config/build-id.mjs ./src/config/build-id.mjs
|
||||
USER app
|
||||
EXPOSE 4000
|
||||
CMD ["node", "node_modules/next/dist/bin/next", "start", "-p", "4000"]
|
||||
FROM public.ecr.aws/docker/library/node:24-alpine AS build
|
||||
RUN apk add --no-cache git
|
||||
WORKDIR /console
|
||||
# Heap headroom so the full @hanzo/gui static export never OOMs into a stub; telemetry off.
|
||||
ENV NEXT_TELEMETRY_DISABLED=1 NODE_OPTIONS=--max-old-space-size=8192
|
||||
# The console.hanzo.ai analytics property (public per-site id, not a KMS secret) —
|
||||
# the same default Dockerfile.embed bakes, so a bundle served from here reports
|
||||
# identically to one served from inside cloud.
|
||||
ARG NEXT_PUBLIC_ANALYTICS_WEBSITE_ID=7dce54ee-41f6-4751-96bf-fe005067c7c7
|
||||
ENV NEXT_PUBLIC_ANALYTICS_WEBSITE_ID=$NEXT_PUBLIC_ANALYTICS_WEBSITE_ID
|
||||
COPY . .
|
||||
RUN corepack enable && pnpm install --frozen-lockfile
|
||||
# FAIL-HARD: the export MUST emit a real bundle, never a placeholder shell. An
|
||||
# empty index.html would serve a blank page on every route with a 200, which is
|
||||
# indistinguishable from a working deploy until someone opens it.
|
||||
RUN pnpm build:embed && [ -s out/index.html ] && [ -d out/_next ] \
|
||||
&& echo ">> servable REAL console bundle: $(wc -c < out/index.html)-byte index.html, $(du -sh out/_next | cut -f1) _next/"
|
||||
|
||||
# hanzoai/static, digest-pinned: a base image is pinned by digest so the bytes
|
||||
# cannot change under a rebuild. (The console's OWN release is named by semver in
|
||||
# the values file — that is the version a human reads.)
|
||||
FROM ghcr.io/hanzoai/static@sha256:346ad30dc7f762c508b4467c2801b3d7e9ec201ec9b257bc7a38b60d59cecc05
|
||||
COPY --from=build /console/out/ /srv/
|
||||
EXPOSE 3000
|
||||
ENTRYPOINT ["/static"]
|
||||
CMD ["-root=/srv", "-spa", "-port=3000"]
|
||||
|
||||
+4
-4
@@ -1,9 +1,9 @@
|
||||
# hanzoai/console — the EMBED artifact.
|
||||
#
|
||||
# Builds the console SPA static export (`npm run build:embed` → out/) ONCE, as a
|
||||
# Builds the console SPA static export (`pnpm build:embed` → out/) ONCE, as a
|
||||
# versioned immutable image whose rootfs is just the bundle at /dist. hanzoai/cloud
|
||||
# then does `FROM registry.hanzo.ai/hanzoai/console-embed:<ver> AS console` +
|
||||
# `COPY --from=console /dist/ webui/dist/` instead of re-running npm+Next export on
|
||||
# `COPY --from=console /dist/ webui/dist/` instead of re-running the install+Next export on
|
||||
# EVERY cloud release (the ~15-min cache-busted long pole). Console changes far less
|
||||
# often than cloud ships, so this moves the build to console's own cadence and turns
|
||||
# a cloud rebuild into a registry pull.
|
||||
@@ -21,10 +21,10 @@ ENV NEXT_TELEMETRY_DISABLED=1 NODE_OPTIONS=--max-old-space-size=8192
|
||||
ARG NEXT_PUBLIC_ANALYTICS_WEBSITE_ID=7dce54ee-41f6-4751-96bf-fe005067c7c7
|
||||
ENV NEXT_PUBLIC_ANALYTICS_WEBSITE_ID=$NEXT_PUBLIC_ANALYTICS_WEBSITE_ID
|
||||
COPY . .
|
||||
RUN npm install --no-audit --no-fund --fetch-retries=5 --fetch-retry-mintimeout=20000 --fetch-timeout=120000
|
||||
RUN corepack enable && pnpm install --frozen-lockfile
|
||||
# FAIL-HARD: the export MUST emit a real bundle (non-empty out/index.html + out/_next/),
|
||||
# never a placeholder shell — same invariant cloud's console stage enforced.
|
||||
RUN npm run build:embed && [ -s out/index.html ] && [ -d out/_next ] \
|
||||
RUN pnpm build:embed && [ -s out/index.html ] && [ -d out/_next ] \
|
||||
&& echo ">> embedded REAL console bundle: $(wc -c < out/index.html)-byte index.html, $(du -sh out/_next | cut -f1) _next/"
|
||||
FROM scratch
|
||||
COPY --from=build /console/out/ /dist/
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
@@ -1,7 +1,7 @@
|
||||
# console2 — Hanzo Cloud Console
|
||||
|
||||
Unified admin console for **Hanzo Cloud** and all cloud products. Our code,
|
||||
BSD-3-Clause, built on **@hanzo/gui** (the Tamagui-based cross-platform UI).
|
||||
`MIT OR Apache-2.0` (HIP-0137), built on **@hanzo/gui** (the Tamagui-based cross-platform UI).
|
||||
NOT an observability-console fork, NOT casibase — it is a clean client over the unified `/v1`
|
||||
backend (`hanzoai/cloud`), reached at the ONE Hanzo API endpoint https://api.hanzo.ai/v1/*.
|
||||
|
||||
@@ -92,7 +92,8 @@ keyed by `owner/modelName`, so modelName is form-entered, not generated).
|
||||
|
||||
One `request()` in `lib/api/client.ts`: always `credentials: 'include'` (the
|
||||
backend sets a session cookie at `/v1/signin`), forwards `Accept-Language`,
|
||||
unwraps the casibase `{ status, msg, data, data2 }` envelope, throws typed
|
||||
unwraps the casibase `{ status, msg, data, total }` envelope (named `total`
|
||||
first, legacy `data2` count accepted until the emitters finish renaming), throws typed
|
||||
`ApiError` (401/403 carry status). Base URL = `config.cloudUrl` (default
|
||||
`https://cloud.hanzo.ai`, override `NEXT_PUBLIC_CLOUD_URL`).
|
||||
|
||||
@@ -290,11 +291,18 @@ Findings + fixes (all in console2; honest states everywhere, no fakes):
|
||||
separately) → honest "not available on this deployment" (was a scary error).
|
||||
HUSD balance/top-up already honest "coming" (token unconfigured).
|
||||
- **Providers was broken** — `ProviderListView`/`ProviderEditView` imported the
|
||||
ZAP twin (`~/lib/zap`), but the cloud `/zap` WS face is NOT served (the edge
|
||||
returns SPA HTML, 200 not a WS upgrade — documented in `lib/zap/client.ts`), so
|
||||
the module showed "Failed to load providers". Switched both back to the working
|
||||
REST `~/lib/api` (identical surface). The ZAP twin stays as the proof-of-pattern
|
||||
until `/zap` is bound. Providers now shows real/empty over REST like every module.
|
||||
ZAP twin (`~/lib/zap`), and at the time the cloud `/zap` WS face was not served
|
||||
(the edge returned SPA HTML, 200 rather than a WS upgrade), so the module showed
|
||||
"Failed to load providers". Switched both back to the working REST `~/lib/api`
|
||||
(identical surface). Providers now shows real/empty over REST like every module.
|
||||
|
||||
**STALE AS OF 2026-07-28 — `/zap` IS served.** Measured on all three hosts
|
||||
(api.hanzo.ai, platform.hanzo.ai, cloud.hanzo.ai): a WebSocket upgrade handshake
|
||||
returns **401**, not SPA HTML and not 200. A route that refuses an
|
||||
unauthenticated upgrade is a route that exists. The reason this section gives
|
||||
for preferring REST no longer holds, and read as current it says ZAP is
|
||||
unavailable when it is merely gated. The REST path is still correct and still
|
||||
shipping — this is a stale rationale, not a bug. Re-measure before acting on it.
|
||||
- Already-correct honest states (unchanged): IAM/Audit + KMS/Secrets (`/v1/iam`,
|
||||
`/v1/kms` 404 → "not available on this deployment"); Observability (`/v1/o11y`
|
||||
503 → "runtime not initialized"). Plans/Embeddings show real data; Models/
|
||||
@@ -318,14 +326,14 @@ keep all credentials server-side (the browser only ever sends its session cookie
|
||||
embeddings|rerank` (not a general tunnel). `playground.ts` now points at this
|
||||
proxy (`<origin>/ai`), so Models/Playground/Chat/cmd+K all work with no key in
|
||||
the browser and no rotation on a chat turn.
|
||||
- **`app/keys/route.ts`** — per-user `hk-` Cloud API key. POST mint/rotate, DELETE
|
||||
- **`app/keys/route.ts`** — per-user `sk-` Cloud API key. POST mint/rotate, DELETE
|
||||
revoke, GET status (no secret). Same app-on-behalf pattern via
|
||||
`/v1/iam/mint-user-keys` + `/v1/iam/revoke-user-keys`. The `hk-` secret is shown
|
||||
`/v1/iam/mint-user-keys` + `/v1/iam/revoke-user-keys`. The `sk-` secret is shown
|
||||
ONCE (POST). `ApiKeysModule` is now create/copy/rotate/revoke.
|
||||
- Shared trust boundary: `src/lib/server/identity.ts` (server-only) — `resolveUser`
|
||||
+ `mintUserKey`/`revokeUserKey`/`issueUserToken`. The `hanzo-console` client is
|
||||
allow-listed in IAM `IAM_KEY_MINT_ALLOWED_APPS`; verified end-to-end that a
|
||||
minted `hk-` key and an issued user JWT both 200 on `api.hanzo.ai/v1/chat/
|
||||
minted `sk-` key and an issued user JWT both 200 on `api.hanzo.ai/v1/chat/
|
||||
completions`.
|
||||
- **Chat is interactive** (`chat/ChatConversation.tsx`): a real multi-turn
|
||||
conversation over `AiApi.chat` (→ the `/ai` proxy), with a Zen default model,
|
||||
@@ -1400,7 +1408,7 @@ of session-only reads (get-account, get-cloud-usages) — so it is KEPT, not rep
|
||||
The fix is **additive, one session manager, zero regression** (worst case === v8.4.28):
|
||||
- **`src/lib/server/session.ts`** — THE token manager (server-only by construction:
|
||||
`node:crypto` + `next/server`). Sealed AES-256-GCM (key = HKDF(`IAM_MINT_CLIENT_SECRET`);
|
||||
no-secret → per-process random key, never a constant). Casdoor tokens are ~3.6 KB
|
||||
no-secret → per-process random key, never a constant). IAM tokens are ~3.6 KB
|
||||
full-user JWTs (86 claims incl. password hash / TOTP secret) — the ACCESS token and
|
||||
the REFRESH token are BOTH that big — so a single cookie is impossible (browser ~4 KB
|
||||
per-cookie cap; a real browser silently REJECTS an oversized cookie — a bug curl never
|
||||
@@ -1463,7 +1471,7 @@ for wrong application (client_id)":
|
||||
`/v1/iam/signin`, which the ingress routes to the CLOUD backend (casibase); casibase
|
||||
redeems with ITS confidential `hanzo-cloud` client → mismatch. Now the console redeems
|
||||
the code ITSELF: on an admin host `iam-login.ts` authorizes with **PKCE** (S256
|
||||
`codeChallenge` in the login body — casdoor stores it with the code), and
|
||||
`codeChallenge` in the login body — IAM stores it with the code), and
|
||||
`completeSignIn` posts `{code, codeVerifier}` to the new BFF **`app/auth/signin`**, which
|
||||
runs `pkceCodeGrant(client_id=admin-console, code, code_verifier)` with **NO client secret**.
|
||||
Verified in IAM source (`object/token_oauth.go` GetAuthorizationCodeToken 880-896): an
|
||||
@@ -1475,7 +1483,7 @@ for wrong application (client_id)":
|
||||
- **`durableSessionClientId(host)`** (session.ts) is the ONE host→client decision:
|
||||
admin host → `admin-console` (public — pkceCodeGrant + secretless refreshGrant), else null
|
||||
→ the confidential `hanzo-console` path. `/auth/refresh` uses it so an admin session
|
||||
refreshes with admin-console (casdoor skips the secret check when it's empty, token.go 469).
|
||||
refreshes with admin-console (IAM skips the secret check when it's empty, token.go 469).
|
||||
- The admin session rests on `hz_session` (the code grant returns access + refresh, minted
|
||||
at authorize time), which `resolveUser`/`getAdminGate` read FIRST — so the admin console
|
||||
works without the casibase cookie. `accountOf` + `applyCookies` extracted to session.ts
|
||||
@@ -1503,7 +1511,7 @@ noun was wrong: bots and machines are distinct compute kinds, not one "fleet").
|
||||
global-admin only) BEFORE forwarding a minted user bearer. Same RED-H1 gate as
|
||||
Business/Finance — no new proxy/trust boundary.
|
||||
- **`lib/api/admin-compute.ts` — kind-parameterized client + pure tree.** OPTIONAL-SAFE
|
||||
over BOTH shapes: pre-aggregated `{ leaves }` (the cheap ClickHouse GROUP BY) OR raw
|
||||
over BOTH shapes: pre-aggregated `{ leaves }` (the cheap datastore GROUP BY) OR raw
|
||||
`{ events }` (the coordinated 9-column datastore row: `org, app, project, kind, event,
|
||||
machine_id, size, price_cents, ts`), which pure `foldEvents` folds client-side.
|
||||
`normalizeCompute(raw, kind)` filters to the requested kind (defensive — the endpoint
|
||||
@@ -1573,7 +1581,7 @@ backend's real tenancy model (RED-checkable).
|
||||
`overview | timeseries | top | health` (analytics.go:95-98) with DIFFERENT response
|
||||
shapes — so 5 tabs 404'd and 2 mis-parsed. `analytics.ts` + `AnalyticsModule.tsx`
|
||||
rewritten to the real structs (`clients/analytics/query.go`): the **LLM lens is REAL
|
||||
live per-org data** (`hanzo.cloud_usage`, prod ClickHouse `datastore.hanzo.svc:9000`),
|
||||
live per-org data** (`hanzo.cloud_usage`, prod Hanzo Datastore `datastore.hanzo.svc:9000`),
|
||||
charted over time; the **web + commerce lenses render honest-empty via the backend
|
||||
`available` flag** (`hanzo.events`, until a collector emits) — never fabricated zeros.
|
||||
Dropped the fabricated **Real-Time tab** (no realtime backend exists). Tabs: Overview
|
||||
@@ -2957,25 +2965,34 @@ contract. The law: a client-facing same-origin API path is `/v1/<head>/…` —
|
||||
`/v1/<head>` forms); `next build` ✓ (route table); `npm run build:embed` ✓ (go:embed gate,
|
||||
31 handlers stashed+restored). `git grep -oE '/[a-z-]+/v[0-9]/'` = external hosts only.
|
||||
|
||||
## TODO — embed the Hanzo Social dashboard (follow-up to social.hanzo.ai)
|
||||
## Social — one surface, shared parts (social.hanzo.ai)
|
||||
|
||||
The dedicated Social frontend shipped at **social.hanzo.ai** (hanzoai/social
|
||||
`apps/frontend`, image `ghcr.io/hanzoai/social-frontend`) on the unified cloud BE
|
||||
(`/v1/social/*` + `/v1/marketing/*`). Its pure display components were extracted to
|
||||
**`@hanzo/ui@8.0.2`** under `@hanzo/ui/product/social` — `ChannelBadge`, `PostCard`,
|
||||
`CampaignCard` (on `@hanzo/gui` primitives, data + handlers injected via props,
|
||||
reusing `StatusTag`). To embed the same dashboard here:
|
||||
- Bump `@hanzo/ui` to `>=8.0.2` and import `{ PostCard, CampaignCard, ChannelBadge }
|
||||
from '@hanzo/ui/product/social'` — the console already has the `@hanzo/gui`
|
||||
runtime/provider, so these render directly (unlike the Tailwind `apps/frontend`,
|
||||
which is why the round-trip back into that app is intentionally NOT done).
|
||||
- Data layer: call the same cloud shapes the social FE uses
|
||||
(`GET/POST /v1/social/posts`, `/v1/social/summary`, `/v1/marketing/campaigns`);
|
||||
org comes from the console's existing IAM session, not a new client.
|
||||
- Still to extract into `@hanzo/ui` when needed: `QueueBoard` (compose
|
||||
`@hanzo/data` `BoardView`), `Calendar` (reuse `@hanzo/data` `Calendar`),
|
||||
`PostComposer` (needs `@hanzo/gui` input/select primitives). Ships via the normal
|
||||
cloud release embedding `console@main` — coordinate with the cloud train.
|
||||
**social.hanzo.ai IS this console**, booted in the `social`-only product shell
|
||||
(`PRODUCT_SHELLS.social` → `config.isSocialHost`/`socialOnly` → `SocialModule`) over
|
||||
the folded `/v1/social` in the cloud binary. The standalone `social-frontend` pod was
|
||||
retired by the ingress cutover (`universe` `routes.yaml`: `/v1`+`/healthz` →
|
||||
`cloud-api-hanzo-ai` at prio 100, root → `console-hanzo-ai`) — there is deliberately
|
||||
NO second dedicated app, and login rides the console's own `hanzo-cloud` path.
|
||||
|
||||
The presentational half lives ONCE in **`@hanzo/ui/product/social`**: `ChannelBadge`,
|
||||
`PostCard`, `CampaignCard`, plus `SocialSummaryBar`, `ViewToggle`, `PostAgenda`,
|
||||
`PostComposer`, `ProviderReadinessList` and the pure `format` module
|
||||
(`formatPostTime`/`postDayBucket`/`postPreview`/`parsePostTime`). Data and handlers are
|
||||
injected — `SocialApi` and every failure classification stay here.
|
||||
|
||||
- **Blocked on a publish**: those parts are in the `@hanzo/ui` source at **8.0.12** but
|
||||
the published **8.0.11** never shipped a `product/social` — so `SocialModule` still
|
||||
renders its local copies. `@hanzo/ui`'s own `pnpm run build` fails first: `src/index.ts`
|
||||
re-exports the local `./backends/shadcn`, whose `clsx`/`tailwind-merge`/
|
||||
`class-variance-authority`/`@radix-ui/*` imports are declared nowhere (the package
|
||||
ships `@hanzo/ui-shadcn` as a *peer* instead). Fix that entry, publish 8.0.12, then
|
||||
switch `SocialModule`'s social imports to `@hanzo/ui/product` and drop the local ones.
|
||||
- **No invented endpoints.** Cloud `clients/social` serves only summary, providers,
|
||||
accounts CRUD, posts CRUD, `posts/:id/publish` — there is no OAuth callback, no
|
||||
analytics, no media upload, no teams. Publishing fails CLOSED with the exact missing
|
||||
provider env vars; the composer surfaces that verbatim, never a fake "connected".
|
||||
- `Post.media` round-trips (cloud always serializes an array and its PUT rebuilds the
|
||||
row from the body, so dropping it would wipe a post's media).
|
||||
|
||||
## Inference Router — per-org router policy editor over /v1-first paths (v8.4.136)
|
||||
|
||||
@@ -3287,7 +3304,7 @@ so it rides the `app/admin/aggregate` BFF standalone and cloud-native in the go:
|
||||
(`clients/admin/storage.go`, SuperAdmin `s.guard`) — the DO block-storage inventory
|
||||
(count · total · monthly cost · per-volume region + attachment) from a new paginated
|
||||
`Volumes()` on the existing `DO_API_TOKEN` client, PLUS the datastore's own fill from
|
||||
ClickHouse `system.disks` (the 200Gi PVC) over the SAME shared `aiobject.DatastoreQuery`
|
||||
`system.disks` (the 200Gi PVC) over the SAME shared `aiobject.DatastoreQuery`
|
||||
the analytics/compute lenses use. Each source degrades independently; near-full raises
|
||||
an alert (warn ≥ 80%, critical ≥ 90%). Pure `buildStorageSnapshot`/`alertLevel`/
|
||||
`datastoreFillFromRow` unit-tested (4 Go tests green).
|
||||
@@ -3506,3 +3523,401 @@ inferred:
|
||||
- Integrated the concurrent category-accent restoration: one accent per category,
|
||||
neutral chrome, user overrides still win. Removed its duplicated swatch array and
|
||||
updated the color contract tests.
|
||||
|
||||
## Canonical main convergence (v8.5.31)
|
||||
|
||||
- Merged the newer forge guide, pitch, signal, and route work into the same main
|
||||
after v8.5.30, preserving the unified app search and category accents.
|
||||
- Verified the combined tree: strict typecheck, 3,044 tests, and the production
|
||||
Next.js build all pass.
|
||||
|
||||
## One paper, one leading — overlay elevation and display type (v8.5.32)
|
||||
|
||||
Two rendering contracts were silently not applying. Both were found by measuring
|
||||
computed styles in a real browser, not by reading code.
|
||||
|
||||
**The product-guide headline had a 1px line box.** `PitchHero` set
|
||||
`style={{ lineHeight: 1.12 }}` — a correct, idiomatic ratio in plain React, because
|
||||
React DOM's unitless allow-list includes `lineHeight`. React Native Web's does NOT
|
||||
(`StyleSheet/compiler/unitlessNumbers.js`), so under @hanzo/gui it compiled to
|
||||
`line-height: 1.12px`: a 30px/900 headline in a 1px box, a 29px overflow that dropped
|
||||
its descenders into the subhead and clipped the GET STARTED eyebrow above it. It now
|
||||
wears `hz-display`, the class this app already uses for exactly this (PublicLanding,
|
||||
v8.5.24) — one way, one rule, every size token and breakpoint. Measured after: 30px
|
||||
type on 33px leading at desktop, clean two-line wrap at 390px.
|
||||
|
||||
`e2e/leading.spec.ts` pins the INVARIANT rather than the call site: no visible text
|
||||
node on /models, /agents or /playground may compute a `line-height` smaller than its
|
||||
own `font-size`. That catches the next numeric `lineHeight` anyone writes without
|
||||
their having to know about RNW's allow-list. It fails on the unfixed tree.
|
||||
|
||||
**No overlay was wearing the elevation ladder.** Gui compiles its shadow props to an
|
||||
atomic rule it injects at runtime as `:root ._bxsh-…` — specificity (0,2,0). The
|
||||
design-token utilities were plain `.hz-paper` (0,1,0) and lost, so the command
|
||||
palette, app launcher, floating chat and three menus rendered Gui's
|
||||
`0 12px 24px rgba(0,0,0,.33)` instead of ring + top highlight + `--hz-elevation-3`.
|
||||
On the true-black canvas that shadow is nearly invisible — the sheets did not lift off
|
||||
the page. The utilities are now `:root .hz-x.hz-x` (0,3,0): deterministic in either
|
||||
stylesheet order, no `!important`.
|
||||
|
||||
**And every anchored overlay now wears ONE surface.** Eleven `Popover.Content` call
|
||||
sites passed Gui's `elevate` while three wore `hz-paper` — one concept, two depths,
|
||||
plus the same `bordered`/`bg`/`borderColor` triple repeated fourteen times. All
|
||||
fourteen now spread `~/components/ui/paper`, which holds the surface, the token
|
||||
elevation and the opacity-only `hz-menu-in` entrance in one place. Verified rendering
|
||||
on the scope switcher, the network picker, the model selector, the save-prompt
|
||||
popover and the ⌘K palette: opaque, correctly anchored, ring visible, nothing occluded.
|
||||
|
||||
## ONE level-2 nav — the registry declares it, the sidebar renders it (feat/one-second-level-nav)
|
||||
|
||||
Clicking into a product revealed its options TWICE. The sidebar drilled into the
|
||||
product and rendered its sub-nav from the registry (`productSubpages`), and the
|
||||
module ALSO rendered a private `const TABS` strip in the content column. The two
|
||||
lists were written independently and disagreed: `/models` showed eight rows in the
|
||||
rail and four tabs in the content, and they did not even agree on what the index is
|
||||
called — the rail said "Overview", the module said "Catalog". Eight products
|
||||
(Containers, Fine-tuning, Tasks, Team, Settings, Zero Trust, Evals, Analytics)
|
||||
declared NO sub-pages at all, so their real tabs existed only in the content strip
|
||||
and the rail hid them.
|
||||
|
||||
- **The registry is the one source.** `CatalogEntry` gains `indexLabel` — what a
|
||||
product calls its own index when it is a named surface rather than a summary
|
||||
(Models → Catalog, Tasks → Workflows, Team → Members, CRM → Companies, Cap Table →
|
||||
Summary, Evals → Run, Containers → Workloads, Fine-tuning → Jobs, Automations →
|
||||
Flows, Profile → Account, Settings → General). `productSubpages` reads it, so the
|
||||
rail, `SubNav`, and ⌘K all say the same word. The eight products missing `subpages`
|
||||
now declare them; the icons the strips were carrying moved onto the declarations.
|
||||
- **`components/ui/SubNav.tsx` is the ONE strip**, rendered from `productSubpages` and
|
||||
hidden at `lg+` (`$lg={{ display: 'none' }}`) because the sidebar owns level 2
|
||||
there (then `DrillNav`; now `SubRows` — see "The rail stopped drilling" below). One declaration, two mounts — never two navs painting at once. It
|
||||
takes an optional `href` for a product whose tabs carry URL state (Containers keeps
|
||||
its `?cluster=` selection across tabs). `subpageIcon` moved here and `dashboard.tsx`
|
||||
imports it, so the sub-page icon defaults exist once.
|
||||
- **Level is the URL and nothing else.** New pure `activeSubpage(pathname, id)` (the
|
||||
level the URL is on, `''` = index) + `subpageHref(id, slug)` (one URL per screen) +
|
||||
`subpageSlug(entry, seg, showAdmin)` — the validator that replaced every module's
|
||||
`TABS.some(...)`, so a hand-typed `/tasks/bogus` cannot light a tab the module does
|
||||
not render, and an admin-only sub-page (Models › Routing) cannot be offered to a
|
||||
customer. Bound to the live registry as `productSubpageSlug` in `match.ts`.
|
||||
- **18 modules lost their private `TABS`** (Models, Evals, AI Accounts, Containers,
|
||||
Analytics, Fine-tuning, Team, Automations, Embeddings, Tasks, Functions, Profile,
|
||||
Router, Settings, Zero Trust, Billing, Cap Table, CRM, Infrastructure) — plus their
|
||||
bespoke `TabButton`/`TabBar`/`nav`/`path`/`tabPath` helpers. Net −200 lines.
|
||||
- **Functions had two indexes.** Its `''` route pointed at a `livingOverviewModule`
|
||||
while `FunctionsModule` carried a second, older `OverviewTab` reachable only via a
|
||||
bogus URL — and because the index was not the module, `/functions` had no level-2
|
||||
nav at all on a phone. Now ONE component owns the product at every level and renders
|
||||
the living-overview board as its index; the dead `functions/OverviewTab.tsx` is gone.
|
||||
- **`/crm/companies` was a duplicate URL** for the screen `/crm` already renders. The
|
||||
index IS Companies now (one URL per screen); the old path still resolves.
|
||||
- **One placement, measured.** The strip renders under the page header, never inside
|
||||
`PageHeader`'s actions slot: a View is `flex-shrink: 0` with `min-width: auto`, so
|
||||
in a row it held its max-content width, its own `flex-wrap` never engaged, and at
|
||||
390px the last tabs painted past the right edge (the same bug `ConsoleFooter` had).
|
||||
It carries `style={{ flexShrink: 1 }}` + `minW={0}` for the same reason.
|
||||
- **Not converted, and why:** Playground, Machines, Kubeflow, Cloudflare, Growth,
|
||||
Providers-Explore and the Errors status filter keep local-state tab strips. Those
|
||||
are NOT a second level-2 nav — they are in-page view switches the URL never carried,
|
||||
so converting them changes routing behaviour per product rather than removing a
|
||||
duplicate. They are the honest follow-up: their level does not survive a reload.
|
||||
- Verification: `tsc --noEmit` adds zero errors (the one reported, `src/lib/event.ts`
|
||||
`dsn`, is pre-existing local dep drift — `@hanzo/event` 0.3.1 installed against
|
||||
`^0.3.4` — and reproduces on a clean origin/main tree); `vitest` **3093 passed**
|
||||
(+9 level-2 nav: indexLabel, the validator incl. the admin gate, one-URL-per-screen,
|
||||
and the URL→level read); `next build` compiles (its type step stops on that same
|
||||
pre-existing drift). RENDER-proven in a browser, `e2e/level-2-nav.spec.ts`, 5/5
|
||||
against a local server: at 1440 the rail is drilled and the content strip's
|
||||
COMPUTED `display` is `none` (a hidden element leaves the accessibility tree, so it
|
||||
is located by test id — `getByRole` cannot see it, which is the whole point); at 390
|
||||
the strip is the one nav, lists the same labels, every tab has a painted box ≥28px
|
||||
tall inside the viewport, and the body does not scroll sideways; a reload of
|
||||
`/models/blend` lands on Blend; Back moves the LEVEL without dropping the rail's
|
||||
drill or the account-backed pins; and a sweep asserts all 18 converted products
|
||||
paint no second nav. Screenshots `e2e-shots/level2-{desktop,mobile}-models.png`.
|
||||
## Find and do — one list view, pins that survive, a palette you can act in
|
||||
|
||||
The interaction half of the admin redesign: pin, sort, filter, search, act. Almost
|
||||
none of it was missing; it was duplicated, unpersisted, or quietly broken. Every
|
||||
claim below was measured in a browser on computed style and geometry
|
||||
(`e2e/find-and-do.spec.ts`, 7 tests), not inferred.
|
||||
|
||||
**[BUG, measured] Every preference was lost on reload.** Pin a product, reload, and
|
||||
it was gone — and with it pin groups, product colours, the nav's open sections, and
|
||||
the workbench state. `Preferences` treated the account as authoritative for keys it
|
||||
had *never mentioned*: on each account load it replaced both state and the
|
||||
localStorage cache with `parsePrefs(account.properties['hanzo.preferences'])`. But
|
||||
the account is projected from the IAM access token's claims, and a preference
|
||||
written after sign-in is not in a token minted before it — so that value is `{}`,
|
||||
and the write-through cache was destroyed on every load. Only preferences rewritten
|
||||
each session (e.g. `guide.used`) appeared to persist. Fix: the account wins for
|
||||
every key it CARRIES; the cache fills the rest (`preferences-core.mergePrefs`, pure,
|
||||
7 tests). Stated limit, not papered over: while the account does carry a key it
|
||||
wins, so clearing it on one device can be re-asserted by a stale token — closing
|
||||
that needs a read-back of stored preferences (a fresh account read, or properties
|
||||
riding a refreshed token), which is a session/backend concern.
|
||||
|
||||
**ONE list view, persisted per user** (`src/lib/list/`). `useList(id)` holds a
|
||||
list's search, column order and facets under `list.<id>` in the SAME account-backed
|
||||
store as pins — so a list narrowed once is found narrowed on the next visit and the
|
||||
next device. The comparator, header-click reducer and substring predicate are NOT
|
||||
new: they were promoted verbatim out of the private copy inside `admin/infra/`,
|
||||
which now re-exports them, so there is one implementation (its 30 tests pass
|
||||
unchanged). `nextSort` is a strict superset of the shipped reducer — it only also
|
||||
accepts `null` as "no sort yet" — so no shipped board changes behaviour. `Filters`
|
||||
(in `ui/Filters`, beside its own atoms) is the one bar: search, facets, and a Reset
|
||||
that exists only while something is narrowed. A facet is stored only when it
|
||||
narrows something; "All"/"off" is the ABSENCE of a facet, never a stored sentinel.
|
||||
Adopted by Models and Marketplace, which between them lose two bespoke search boxes
|
||||
and four `useState`s. Scope, flagged not decided: preferences are per USER, so a
|
||||
SuperAdmin masquerading keeps their own sorts — right, I think (they are YOUR
|
||||
tools), but it is a product call.
|
||||
|
||||
**Pins are first-class in search.** `pinnedFirst` (pure, in `pins-core`) is the one
|
||||
"pinned leads" rule, shared by the sidebar and the palette. Every ⌘K result carries
|
||||
a pin at its right edge — invisible until the row is reached, lit while pinned, a
|
||||
26px hit target, `aria-pressed` — and `⌥↵` pins the selection WITHOUT closing, so
|
||||
curating is repeatable. The default view collects pins under one leading "Pinned"
|
||||
heading.
|
||||
|
||||
**[BUG I introduced, then caught] Pins must not outrank what you typed.** Applying
|
||||
`pinnedFirst` to the RANKED list meant a barely-matching pinned product beat an
|
||||
exact name match: typing "billing" and pressing ↵ opened Models. Pins now order the
|
||||
DEFAULT view only; the moment you type, relevance decides. Locked by a test that
|
||||
asserts on where you LAND (`agents`→/agents, `billing`→/billing, `vector`→/vector),
|
||||
not on DOM order.
|
||||
|
||||
**[BUG] The resting pin painted at full strength.** `.hz-pin { opacity: 0 }` lost to
|
||||
Gui's compiled `:root ._ops-…` (0,2,0) — the same specificity trap `.hz-paper` hit.
|
||||
Fixed by dropping the inline `opacity` prop and doubling the selector
|
||||
(`:root .hz-pin.hz-pin`). A broken CSS comment then silently killed the whole rule;
|
||||
only the computed-style assertion caught it. Both are now pinned by a test that
|
||||
reads `getComputedStyle().opacity` at rest, on hover, and while selected.
|
||||
|
||||
Verification: `tsc --noEmit` clean; `vitest` **3121 passed** (+35: list core 22,
|
||||
preferences-core 7, pinnedFirst 6); `e2e/find-and-do.spec.ts` 7/7 against a local
|
||||
fixture with screenshots (`palette-pin`, `palette-pinned-first`, `palette-keyboard`,
|
||||
`list-narrowed`, `list-bar-mobile`), including 4.5:1 contrast and zero horizontal
|
||||
body scroll at 390px. NOT verified against live admin.hanzo.ai — it is auth-gated
|
||||
and I will not type a password; one SuperAdmin session re-running this spec closes
|
||||
that. Untouched and flagged for the caps pass: `MarketplaceModule`'s "CATEGORIES"
|
||||
and the palette's own uppercased section labels are `textTransform` sites that
|
||||
belong to that lane, not this one.
|
||||
|
||||
## Billing calls the route names the server actually registers
|
||||
|
||||
Commerce dropped the compound prefixes from its billing routes — the `/v1/billing/`
|
||||
namespace already says "billing", so `billing/payment-methods` stuttered. Both servers
|
||||
register only the short names, measured against the live edge: `/v1/billing/methods`
|
||||
401, `/v1/billing/settings` 403, `/v1/billing/alerts` 403, while `payment-methods`,
|
||||
`payment-config` and `spend-alerts` are all 404. The console never followed. Its card
|
||||
reads, its card writes and its Square-config read were all addressed at routes that no
|
||||
longer exist, so a new user could not add a card — the revenue path was broken in
|
||||
production.
|
||||
|
||||
The client had already been repointed for alerts, so `payment-methods` (list, save,
|
||||
detach) and `payment-config` were the ones still dead. They now build `methods` and
|
||||
`settings`. There is deliberately no alias and no fallback: one name per concept.
|
||||
|
||||
The tests were part of the defect, not the safety net. Every suite around payment
|
||||
methods stubbed a response body and asserted the normalization, so a client pointed at
|
||||
a 404 stayed green — the exact reason this survived. The URL is now pinned where the
|
||||
request is actually made, including the two reads nothing had ever asserted
|
||||
(`methods`, `settings`) and `alerts` beside them. Reverting any of the four short names
|
||||
turns the suite red, which was checked rather than assumed.
|
||||
|
||||
`POST /v1/billing/me/welcome` and everything feeding it is deleted, not repointed.
|
||||
Commerce removed that route on purpose: it was a self-service mint, a browser could
|
||||
grant its own org $5, and commerce's own `api/billing/mint_gates_test.go` names it the
|
||||
TOCTOU double-mint. Credit is minted only through the mint-gated `POST
|
||||
/v1/billing/credit`. The call had been failing silently, so restoring it would have
|
||||
re-opened a closed money hole to fix nothing. The trial credit still lands — commerce
|
||||
grants it server-side when a card is vaulted, and signup grants it server-side — and
|
||||
that path is untouched. `src/lib/billing/welcome.ts` had no callers left at all.
|
||||
|
||||
Scope, checked rather than assumed: `/v1/finance/payment-methods` is still 401 (alive)
|
||||
and `/v1/finance/methods` is 404, so the finance ledger keeps the compound name — a
|
||||
blanket repo-wide rename would have broken it. The Billing Center's tab slugs
|
||||
(`/billing/payment-methods`, `/billing/credits`) are console page URLs, not server
|
||||
routes, and are unchanged.
|
||||
|
||||
Two headlines were lying about which layer failed. "Card top-up isn't available on this
|
||||
deployment yet" and "Adding a card isn't available on this deployment yet" both fire
|
||||
when the ORG has no Square `applicationId`/`locationId` — a per-organization
|
||||
configuration, not a property of the deployment. Both now name the organization, as
|
||||
does the onboarding step's "Payments aren't set up", which had the same defect. The
|
||||
stale `GET /v1/billing/payment-config` endpoint hints under those cards now read
|
||||
`settings`.
|
||||
|
||||
## The assistant has one home, and the app directory is one you can walk
|
||||
|
||||
Three fixes to the console's own chrome. Each root cause was measured in a browser
|
||||
on computed style, geometry or where the browser lands — never inferred from source.
|
||||
|
||||
**The assistant lived in a third place.** `FloatingChat` owned every shape the
|
||||
assistant can take (the sheet, the docked column, the dock state) except the way
|
||||
in, which was two small buttons in the TOPBAR — a brand-H "Chat with Hanzo" and a
|
||||
"Talk to Hanzo" mic — wedged between the search box and the org/theme/alert
|
||||
cluster. On a 390px phone that put five controls in the header and squeezed the
|
||||
search field to "Search or jump…". Both controls moved into `AssistantFab`, one
|
||||
floating cluster fixed bottom-right, in the corner the assistant actually appears
|
||||
in. Chat and voice are the same surface opened two ways, so they sit together.
|
||||
Nothing about the assistant was rewritten: the FAB calls the same `openChat` /
|
||||
`startVoice` the topbar called, and `open`/`toggle`/`ask` still drive it
|
||||
programmatically (the Code hub's "Ask AI").
|
||||
|
||||
It is suppressed exactly where the assistant is already on screen — while the
|
||||
sheet is open, on the pages that ARE a composer, and, at `lg+` only, while it is
|
||||
docked as a column. That last half is a CSS media prop rather than a JS branch so
|
||||
SSR and first paint agree, and it sits above the Developers dock (whose collapsed
|
||||
bar is 44px and exists only at `lg+`).
|
||||
|
||||
**[BUG, measured] All products was a directory you could not walk.** The pane that
|
||||
lists every Hanzo app — the sidebar's "All products", the one place the whole
|
||||
catalog is browsable — rendered each app as an inert `XStack`: a plain `DIV` with
|
||||
`role=null` and `cursor: auto`, no handler, no pointer affordance. Measured, not
|
||||
read. The only live control in the row was the pin, so a user could curate the
|
||||
sidebar but could not open anything from the list. The row now opens its app
|
||||
through the shared `openProduct` — the ONE opener the sidebar, ⌘K and the category
|
||||
pages already route through — and closes the pane behind it, because a directory is
|
||||
not a destination. Pin stays a separate control on the same row and stops the press
|
||||
from bubbling: curating never navigates, navigating never curates.
|
||||
|
||||
**[BUG] A pin made after sign-in was thrown away on the next reload.** Preferences
|
||||
are read off `properties['hanzo.preferences']` in the IAM access token's claims — a
|
||||
SNAPSHOT taken when that token was minted. An earlier lane fixed the case where the
|
||||
snapshot is SILENT about a key. The other half was never closed: once a user has
|
||||
saved anything, the next token CARRIES a snapshot, and the merge let it win over a
|
||||
newer local write. So the second pin onward read as pinned and was gone after F5.
|
||||
|
||||
The merge is now told the ordering it was missing. `Account` carries the token's
|
||||
own `iat`; the provider stamps `…prefs.<user>.writtenAt` when — and only when — the
|
||||
SERVER acknowledges a write; `mergePrefs(cached, fromAccount, order)` lets the cache
|
||||
win only when a confirmed write is newer than the snapshot. Last writer wins, and
|
||||
both writers are now identifiable. A fresh device (no cache, no stamp) and a fresh
|
||||
sign-in (token minted after the write) both still take the account wholesale, so
|
||||
cross-device is preserved. Stamping only server-confirmed writes is what keeps this
|
||||
from being localStorage impersonating a backend: a save that never landed earns
|
||||
nothing and the account stays authoritative.
|
||||
|
||||
**Backend gap, named rather than papered over.** There is no READ for this
|
||||
document. `PATCH /v1/ai/preferences` (hanzoai/ai `UpdatePreferences`) writes it to
|
||||
the IAM user's `properties['hanzo.preferences']` and returns the merged result;
|
||||
nothing serves a GET, so the token's snapshot is the only read the console has. The
|
||||
smallest seam that removes the ordering problem entirely is `GET
|
||||
/v1/ai/preferences` returning that property after the handler's existing
|
||||
`refreshSessionUser` — the write path already does every part of it. Better still
|
||||
is `GET/PATCH /v1/prefs` (hanzoai/cloud `apps/prefs`), the canonical cross-surface
|
||||
plane, which answers 503 on api.hanzo.ai today.
|
||||
|
||||
**House rule: one filled CTA.** Counted by computed background luminance on the
|
||||
rendered home, not by reading JSX: FIVE white-filled buttons competed — "Take the
|
||||
tour", the getting-started card's active step, and all three `PrimaryActionTile`
|
||||
CTAs (two of them saying "Get API key"). The same measurement now returns ONE: the
|
||||
checklist's ACTIVE step, the thing to do next. The tour is a neutral aside beside its
|
||||
dismiss, and the three tiles are neutral because they are PEERS — a menu of things
|
||||
you can do, not a call to action, and three primaries are none.
|
||||
|
||||
**Verification.** `tsc --noEmit` clean; `vitest` 3175 passed / 8 skipped (256 files,
|
||||
+6 ordering tests). RED→GREEN proven both ways: disabling `cacheIsNewer` turns the
|
||||
two new ordering tests red and the browser test with it. `e2e/assistant-fab-and-apps.spec.ts`
|
||||
(4 tests, 1440 and 390) asserts the control's BOX is in the bottom-right quadrant and
|
||||
≥44px, that `.hz-topbar` carries no assistant control, that clicking an app in All
|
||||
products LANDS on `/agents`, and that a pin survives a reload under a token whose
|
||||
snapshot is an hour old. `e2e/chrome-brand-voice.spec.ts` was retargeted, not
|
||||
deleted — every claim it made still holds, only the location moved.
|
||||
|
||||
Two spec gotchas worth keeping. `_session.ts`'s `b64` emitted plain base64; that is
|
||||
fine while a forged payload is tiny, but `+`/`/` appear as soon as one grows (a
|
||||
`properties` bag is enough) and a strict decoder rejects the token outright — the SDK
|
||||
reports signed out and the app sits on its loader forever. It emits base64URL now,
|
||||
which is what a JWT segment actually is. And the assistant's composer carries its own
|
||||
mic with the same `Talk to Hanzo` label, mounted-but-hidden until the panel opens, so
|
||||
a bare attribute locator matches that one first: scope to `getByTestId('assistant-fab')`.
|
||||
|
||||
|
||||
## The agent quickstart, and the rail that stopped drilling (v8.5.62)
|
||||
|
||||
Two changes that share a shape: something that looked finished was standing in for
|
||||
the thing itself.
|
||||
|
||||
**The builder had no way in.** `AgentBuilder` — the canonical, host-agnostic one —
|
||||
was reachable only as a form in a side pane, from a board you first had to have
|
||||
agents to be looking at. `agents/quickstart` is the way someone with none starts:
|
||||
describe what you want in a sentence, or take a template, then configure, run and
|
||||
integrate.
|
||||
|
||||
- **Every step is an endpoint**, which is the whole design constraint. Describe →
|
||||
`POST /v1/chat/completions` (`draftAgent`) turns a sentence into a spec; Configure →
|
||||
the SAME `AgentBuilder`, seeded; Run → `POST /v1/agents/:ref/run` executes it and
|
||||
shows the RECORDED run; Integrate → prints the request that just worked. A ladder
|
||||
of steps is a promise about what happens, and a step that only draws a checkmark
|
||||
turns the promise into decoration. Steps 1 and 3 are optional by construction —
|
||||
their loaders may be absent, and the step then says exactly what is missing.
|
||||
- **`components/agent-builder/templates.ts`** — eight presets, pure data. A template is
|
||||
a PRESET, never a promise: it may only carry fields `toCreateBody` already expresses
|
||||
(`name`, `description`, `systemPrompt`, and the real `AgentConfig` knobs), and a test
|
||||
pins exactly that. **None names a tool.** Tools are per-org, so a hardcoded
|
||||
`web.search` would name something that may not exist and would fail at the agent's
|
||||
FIRST invocation rather than in the form. What a template CAN say truthfully is
|
||||
`useTools` / `webSearch`, which are real switches in the agent contract.
|
||||
- **The tool plane was live the whole time.** `loaders.ts` said "No live tool catalog
|
||||
endpoint on this deployment yet" and left the field typeable-only. `GET /v1/tools` is
|
||||
bound and serving — one flat set spanning connector actions, functions, zap-service
|
||||
routes, agents, skills and the org's own MCP servers, deduplicated by name, each
|
||||
flagged `activated`. `lib/api/tools.ts` reads it, `proxy-allow` admits the head, and
|
||||
`/v1/tools/call` is REFUSED there: running a tool belongs to whatever runs an agent,
|
||||
never to a browser tab. An org with nothing activated gets `{"tools":[]}` — a real
|
||||
empty answer, shown honestly rather than papered over.
|
||||
- **`defaultModel` was picking an embeddings model.** It named `zen-omni` as its exact
|
||||
match, and the live catalog does not carry that id — so the exact arm never fired and
|
||||
the fallback ran instead: `^zen[-.]` over an alphabetically sorted catalog, which
|
||||
selects `zen-embedding`. Every agent created without touching the model field was
|
||||
pointed at a SKU that cannot hold a conversation, and nothing caught it because the
|
||||
dead exact-match read like the rule. The family test is `^zen\d` now, because zen's
|
||||
naming splits cleanly: **`zen5*` are the text models; `zen-<noun>` names a MODALITY**
|
||||
(embedding, image, video, rerank, voice, vl, guard). The model and tool placeholders
|
||||
were advertising the same dead id and two invented tool names; both now say things
|
||||
that exist.
|
||||
|
||||
**The rail stopped drilling.** Clicking a product used to swap the ENTIRE sidebar for
|
||||
that product's sub-nav, behind a "Back to all products" button. The options were
|
||||
identical either way — what the drill took away was every OTHER product, which is
|
||||
precisely what someone needs when the reason they opened the rail was to go somewhere
|
||||
else. `SubRows` replaces `DrillNav`: a product's sub-pages expand beneath its own row,
|
||||
indented on a hairline, `inert` when collapsed.
|
||||
|
||||
- **The label navigates; the chevron only opens and closes.** One target doing both
|
||||
would make "show me what is in here" and "take me there" the same gesture.
|
||||
- `productIsOpen` / `toggleProduct` in `nav-accordion.ts`, beside the category pair and
|
||||
keyed apart from it. The default is the OPPOSITE of a category's, deliberately:
|
||||
categories are few and describe the catalog, so they open; products are many and each
|
||||
brings four to eight rows, so opening them all would bury the catalog under its own
|
||||
detail. The product you are IN is open unless you closed it, and that choice persists.
|
||||
- **A pinned product appears twice** — once under Pinned, once in its category — and
|
||||
exactly ONE copy may carry the sub-list. Two copies is two navs painting at once,
|
||||
which is the thing this rail exists to avoid, and it doubles the rail's height for no
|
||||
information. The pinned copy owns it.
|
||||
|
||||
**Verification.** `tsc --noEmit` clean; `vitest` **3259 passed / 8 skipped** (262 files;
|
||||
+draft/handle parsing, +templates, +the product accordion, +the tool-plane allow/refuse
|
||||
pair, and a `defaultModel` test that goes red on the `zen-embedding` regression).
|
||||
RENDER-proven: `e2e/agent-quickstart.spec.ts` (3 tests) asserts the ladder, that the
|
||||
gallery sits to the RIGHT of the composer by measured geometry at 1440, that searching
|
||||
narrows it, that picking Deep researcher carries its handle and prompt into step 2, and
|
||||
that 390 stacks without the body scrolling sideways. `e2e/level-2-nav.spec.ts` (5/5) was
|
||||
retargeted, not deleted: it now asserts "All products" is still on screen while a
|
||||
product is open — the invariant the drill could never have satisfied — and that no
|
||||
"Back to all products" button exists on any of the 18 converted products.
|
||||
|
||||
**ONE door.** The board's New-Agent button opened the builder in a side pane —
|
||||
the same component, reached by a different shape, with no templates, no drafting,
|
||||
and nowhere to run what it made. It goes to the quickstart now and
|
||||
`NewAgentForm` is deleted; two entrances to one builder is two things to keep in
|
||||
step, and the pane was the lesser of them. A spec clicks the board's CTA and
|
||||
asserts the URL lands on `/agents/quickstart`.
|
||||
|
||||
One placement note that cost a debug cycle: the quickstart branch must return BEFORE
|
||||
`AgentsModule`'s loading/error/empty states. Building an agent does not depend on
|
||||
reading the ones that exist, and the moments you most need the quickstart — no agents
|
||||
yet, or the registry not answering — are exactly the ones those early returns swallow
|
||||
it in.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -3,11 +3,14 @@ import type { ReactNode } from 'react'
|
||||
import { Preferences } from '~/lib/products/preferences'
|
||||
import { Toast } from '~/components/ui/Toast'
|
||||
import { Entry } from '~/entry/entry'
|
||||
import { Host } from '~/entry/host'
|
||||
|
||||
/**
|
||||
* The console entry, decomplected (see src/entry/). `Preferences` + `Toast` are the
|
||||
* session-tier context: the stage RESOLVER reads the onboarding preference, and the
|
||||
* onboard wizard + every module report through Toast — so they sit above the switch.
|
||||
* `Host` answers the two effects `@hanzo/ui/product`'s state cards ask for (sign in,
|
||||
* add credits), so every card below renders its affordance without being handed one.
|
||||
* `Entry` computes ONE stage value from the session and renders EXACTLY one surface
|
||||
* (sign-in · waitlist · org · onboard · dashboard).
|
||||
*/
|
||||
@@ -15,7 +18,9 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<Preferences>
|
||||
<Toast>
|
||||
<Entry>{children}</Entry>
|
||||
<Host>
|
||||
<Entry>{children}</Entry>
|
||||
</Host>
|
||||
</Toast>
|
||||
</Preferences>
|
||||
)
|
||||
|
||||
@@ -21,15 +21,12 @@ import { ProductRoute } from '~/components/ProductRoute'
|
||||
import { openProduct } from '~/lib/products/open'
|
||||
import { useFavorites } from '~/lib/products/favorites'
|
||||
import { useIsSuperAdmin } from '~/lib/auth/admin'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { PrimaryButton } from '~/components/ui/PrimaryButton'
|
||||
import { ProductIcon } from '~/components/ui/ProductIcon'
|
||||
import type { IconLike } from '~/components/ui/color'
|
||||
import { useProductColors } from '~/lib/products/pins'
|
||||
import { FadeIn } from '~/components/ui/FadeIn'
|
||||
import { livingOverviewModule } from '~/components/products/overview/living/LivingOverviewModule'
|
||||
import { ResourceOverview } from '~/components/products/overview/ResourceOverview'
|
||||
import { ProductObservability } from '~/components/products/observability/ProductObservability'
|
||||
import { FadeIn, PageHeader, type IconLike } from '@hanzo/ui/product'
|
||||
|
||||
// The home centerpiece is the reusable LivingOverview (count-up KPIs, live
|
||||
// sparklines, streaming activity) — the SAME component every product overview uses.
|
||||
@@ -148,13 +145,19 @@ function PrimaryActionTile({
|
||||
{description}
|
||||
</Text>
|
||||
<XStack>
|
||||
<PrimaryButton
|
||||
{/* Neutral, not filled. These three tiles are PEERS — a menu of things you can
|
||||
do, not a call to action — so three white buttons side by side gave the
|
||||
screen three primaries and therefore none. The one filled action on this
|
||||
page is the getting-started card's active step: the thing to do NEXT. */}
|
||||
<Button
|
||||
size="$3"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
iconAfter={external ? <ExternalLink size={15} /> : <ArrowRight size={15} />}
|
||||
onPress={onPress}
|
||||
>
|
||||
{ctaLabel}
|
||||
</PrimaryButton>
|
||||
</Button>
|
||||
</XStack>
|
||||
</Card>
|
||||
)
|
||||
@@ -234,7 +237,7 @@ export default function DashboardHome() {
|
||||
icon={HandCoins}
|
||||
color={colorOf('authors')}
|
||||
title="Earn from your OSS"
|
||||
description="Earn 20% of the compute margin your open-source project drives when teams run it on Hanzo Cloud — paid to your Hanzo wallet."
|
||||
description="Earn 20% of the compute margin your open-source project drives when organizations run it on Hanzo Cloud — paid to your Hanzo wallet."
|
||||
ctaLabel="Start earning"
|
||||
onPress={() => push('/authors')}
|
||||
/>
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -59,7 +59,12 @@ async function handle(req: NextRequest, segments: string[]): Promise<NextRespons
|
||||
if (segments.length !== 1 || segments[0] !== 'secrets') return notFound()
|
||||
|
||||
const org = orgFor(gate, req)
|
||||
const base = `${kmsBaseUrl()}/v1/kms/orgs/${encodeURIComponent(org)}/secrets`
|
||||
// The org travels on the IDENTITY channel, never the URL: cloud's KMS surface
|
||||
// is /v1/kms/secrets and reads the acted-on org from the validated principal
|
||||
// (X-Org-Id — for a SuperAdmin, the switched-into org; the same one-predicate
|
||||
// switch every other subsystem honors). URL-addressed orgs were removed
|
||||
// server-side because a path that names a tenant is caller-selectable.
|
||||
const base = `${kmsBaseUrl()}/v1/kms/secrets`
|
||||
const q = req.nextUrl.searchParams
|
||||
const name = q.get('name') ?? ''
|
||||
const path = q.get('path') ?? ''
|
||||
@@ -105,7 +110,7 @@ async function handle(req: NextRequest, segments: string[]): Promise<NextRespons
|
||||
)
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = { Authorization: `Bearer ${bearer}`, Accept: 'application/json' }
|
||||
const headers: Record<string, string> = { Authorization: `Bearer ${bearer}`, Accept: 'application/json', 'X-Org-Id': org }
|
||||
const init: RequestInit = { method: req.method, headers, cache: 'no-store' }
|
||||
if (body !== undefined) {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* `/v1/chat/completions` (and friends) REQUIRE an `Authorization: Bearer` token; a
|
||||
* browser session cookie alone is rejected. Rather than ship the user's durable
|
||||
* `hk-` key to the browser, the console calls its OWN origin at the canonical, prefix-free
|
||||
* `sk-` key to the browser, the console calls its OWN origin at the canonical, prefix-free
|
||||
* `/v1/<aihead>` (the /v1-first law); `next.config.mjs` dispatches those heads to THIS `/ai`
|
||||
* proxy (re-rooting the upstream at `v1/` — invisible to the client). `forwardWithUserBearer`
|
||||
* resolves the user, mints a SHORT-LIVED, user-bound IAM token (shared per-user cache in
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* /console/mfa/<action> — console-native two-factor (TOTP) enrollment BFF.
|
||||
*
|
||||
* WHY console-native: the console delegated 2FA to hanzo.id's account page, but the
|
||||
* custom hanzo.id login worker doesn't establish a Casdoor account session, so a
|
||||
* custom hanzo.id login worker doesn't establish an IAM account session, so a
|
||||
* user who signed in through it lands on an account page that can't manage MFA
|
||||
* (setup returns "Unauthorized operation"). This closes that gap: the user enrolls
|
||||
* 2FA IN the console. We forward each IAM MFA op as the caller's OWN user bearer
|
||||
@@ -20,7 +20,7 @@ import { fetchWithTimeout } from '~/lib/server/fetch-timeout'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const TOTP = 'app' // Casdoor TotpType
|
||||
const TOTP = 'app' // IAM TotpType
|
||||
|
||||
/**
|
||||
* IAM endpoint + the params each action sends. owner/name are ALWAYS included and
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
+57
-10
@@ -10,6 +10,14 @@ body {
|
||||
background-color: var(--background, #000000);
|
||||
color: var(--color, #ededf1);
|
||||
font-family: 'Geist', system-ui, -apple-system, sans-serif;
|
||||
/* The base of the ONE type scale. Without this the body inherits the browser's
|
||||
16px root, and every element that does not name a size token — a Gui <Button>
|
||||
label, a bare <span>, anything the ladder does not reach — renders at a size
|
||||
that belongs to no scale. That was the single largest source of type drift in
|
||||
the console: hundreds of nodes painting the retired 16px base beside a 14px
|
||||
one. `--text-base` is the same 14px the Gui `$3` token resolves to
|
||||
(gui.config.ts), so the inherited size and the named size agree. */
|
||||
font-size: var(--text-base, 0.875rem);
|
||||
/* Calm type rendering — crisp, low-glare, comfortable rhythm for a full workday. */
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
@@ -390,6 +398,36 @@ html:root.t_light {
|
||||
}
|
||||
}
|
||||
|
||||
/* Row-edge pin — the quiet affordance on a search result. It is a REAL, focusable
|
||||
control at all times (opacity, never `display:none`), so the keyboard and a
|
||||
screen reader always reach it; it is simply not drawn until the pointer reaches
|
||||
its row. A pinned or keyboard-selected row opts out of the class entirely, so
|
||||
its pin stays lit — the lit ones are STATE, not chrome.
|
||||
Touch has no hover, so `hover:none` pointers keep it visible: on a phone an
|
||||
invisible control is an absent one.
|
||||
Doubled selector (`:root .hz-pin.hz-pin`, specificity 0,3,0) for the same reason
|
||||
`.hz-paper` is doubled above: Gui injects its compiled style props at `:root ._x-…`
|
||||
(0,2,0), so a plain `.hz-pin` loses and the pin paints at full strength forever. */
|
||||
:root .hz-pin.hz-pin {
|
||||
opacity: 0;
|
||||
transition: opacity 140ms ease-out;
|
||||
}
|
||||
:root .hz-row-pin:hover .hz-pin.hz-pin,
|
||||
:root .hz-pin.hz-pin:focus-within,
|
||||
:root .hz-pin.hz-pin:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
@media (hover: none) {
|
||||
:root .hz-pin.hz-pin {
|
||||
opacity: 0.45;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:root .hz-pin.hz-pin {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Touch targets — WCAG 2.5.5 (AAA) / Apple HIG ≥44px ──────────────────────
|
||||
On phones/tablets (<lg) every control inside the mobile nav drawer must be at
|
||||
least 44px tall to tap reliably. Scoped to `.hz-touch-target` (set on the drawer
|
||||
@@ -415,7 +453,7 @@ html:root.t_light {
|
||||
.hz-chat-dock {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 5;
|
||||
z-index: var(--z-raised);
|
||||
/* Clear the iOS home indicator when Safari's bottom bar hides (viewport-fit=cover
|
||||
exposes the inset; 0 on devices without one, so no effect elsewhere). */
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
@@ -428,18 +466,27 @@ html:root.t_light {
|
||||
from the per-theme --hz-elevation-* tokens (light AND dark aware). Brand-neutral:
|
||||
the shadow is monochrome and color stays token-driven, so lux/zoo/pars theme
|
||||
cleanly. One place defines the ladder; an overlay wears a class. `className`
|
||||
forwards to the DOM node on web, so a Gui surface can wear these. */
|
||||
.hz-elevation-1 { box-shadow: var(--hz-elevation-1); }
|
||||
.hz-elevation-2 { box-shadow: var(--hz-elevation-2); }
|
||||
.hz-elevation-3 { box-shadow: var(--hz-elevation-3); }
|
||||
.hz-elevation-4 { box-shadow: var(--hz-elevation-4); }
|
||||
.hz-elevation-5 { box-shadow: var(--hz-elevation-5); }
|
||||
forwards to the DOM node on web, so a Gui surface can wear these.
|
||||
|
||||
Each selector is written `:root .hz-x.hz-x` on purpose. @hanzo/gui (Tamagui)
|
||||
compiles its own shadow props to an atomic rule it injects at RUNTIME as
|
||||
`:root ._bxsh-…` — specificity (0,2,0). A plain `.hz-paper` is (0,1,0) and loses;
|
||||
`.hz-paper.hz-paper` merely TIES, and a tie is settled by stylesheet order, which
|
||||
runtime injection makes nondeterministic. It lost in practice: an overlay wearing
|
||||
`hz-paper` rendered Tamagui's `0 12px 24px rgba(0,0,0,.33)` instead of this ladder,
|
||||
and on the true-black canvas that shadow is nearly invisible — the sheet did not
|
||||
lift off the page. (0,3,0) wins outright, in either order, with no `!important`. */
|
||||
:root .hz-elevation-1.hz-elevation-1 { box-shadow: var(--hz-elevation-1); }
|
||||
:root .hz-elevation-2.hz-elevation-2 { box-shadow: var(--hz-elevation-2); }
|
||||
:root .hz-elevation-3.hz-elevation-3 { box-shadow: var(--hz-elevation-3); }
|
||||
:root .hz-elevation-4.hz-elevation-4 { box-shadow: var(--hz-elevation-4); }
|
||||
:root .hz-elevation-5.hz-elevation-5 { box-shadow: var(--hz-elevation-5); }
|
||||
|
||||
/* Paper = an elevated sheet: hairline ring + top highlight + a mid cast shadow, so
|
||||
a menu/palette/dialog reads as a physical sheet floating above the page. */
|
||||
.hz-paper { box-shadow: var(--hz-ring), var(--hz-paper-highlight), var(--hz-elevation-3); }
|
||||
.hz-paper-4 { box-shadow: var(--hz-ring), var(--hz-paper-highlight), var(--hz-elevation-4); }
|
||||
.hz-paper-5 { box-shadow: var(--hz-ring), var(--hz-paper-highlight), var(--hz-elevation-5); }
|
||||
:root .hz-paper.hz-paper { box-shadow: var(--hz-ring), var(--hz-paper-highlight), var(--hz-elevation-3); }
|
||||
:root .hz-paper-4.hz-paper-4 { box-shadow: var(--hz-ring), var(--hz-paper-highlight), var(--hz-elevation-4); }
|
||||
:root .hz-paper-5.hz-paper-5 { box-shadow: var(--hz-ring), var(--hz-paper-highlight), var(--hz-elevation-5); }
|
||||
|
||||
/* Overlay entrance — a fast, physical scale-fade from the origin (menus, palette,
|
||||
dialog, support sheet). 180ms ease-out enter; the overlay's own unmount handles
|
||||
|
||||
+5
-5
@@ -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.
|
||||
|
||||
+13
-1
@@ -4,6 +4,11 @@ import '@hanzogui/core/reset.css'
|
||||
// source of truth. Imported BEFORE globals.css so the console's Tamagui theme can
|
||||
// derive its ladder from the design neutral/semantic tokens.
|
||||
import './design/index.css'
|
||||
// The motion/skeleton classes `@hanzo/ui/product` components emit (`skeleton`,
|
||||
// `row`, `tnum`, `fade-up`, `drag`). Console's own markup still names the `hz-`
|
||||
// prefixed twins in globals.css below; these are the package's, and without this
|
||||
// import a DataTable's skeleton, row hover and tabular figures render unstyled.
|
||||
import '@hanzo/ui/styles/motion.css'
|
||||
import './globals.css'
|
||||
|
||||
import type { Metadata, Viewport } from 'next'
|
||||
@@ -20,12 +25,19 @@ import { resolveConfig } from '~/config'
|
||||
// The visible shell resolves the brand client-side from window.location, but the
|
||||
// tab title is server-rendered — without reading the Host header here the browser
|
||||
// tab leaks "Hanzo Cloud Console" on Lux/Zoo hosts, a white-label violation.
|
||||
//
|
||||
// The description is the same metadata read by the same brand, so it resolves the
|
||||
// same way. It did not, and shipped `content="Unified admin console for Hanzo Cloud
|
||||
// and all cloud products."` to console.lux.cloud and console.zoo.cloud — the title
|
||||
// beside it was already correct, which is exactly why nobody noticed. Every
|
||||
// brand-visible string in this function comes from `brandName`; adding a literal
|
||||
// here re-opens the leak.
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const host = (await headers()).get('host') ?? undefined
|
||||
const { brandName } = resolveConfig(host)
|
||||
return {
|
||||
title: `${brandName} Console`,
|
||||
description: 'Unified admin console for Hanzo Cloud and all cloud products.',
|
||||
description: `Unified admin console for ${brandName} and all cloud products.`,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -57,7 +57,7 @@ const TIMEOUT_MS = Number(process.env.NODES_RPC_TIMEOUT_MS ?? 8000)
|
||||
/** A single allowlisted luxd JSON-RPC call. `path` and `method` are fixed here. */
|
||||
async function rpc<T>(
|
||||
host: string,
|
||||
path: '/ext/bc/P' | '/ext/info',
|
||||
path: '/v1/bc/P' | '/v1/info',
|
||||
method: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<T> {
|
||||
@@ -94,11 +94,11 @@ async function probe(net: NodeNetworkId): Promise<NetworkInventory> {
|
||||
const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS)
|
||||
try {
|
||||
const [valR, peerR, verR, hgtR, chainR] = await Promise.allSettled([
|
||||
rpc<{ validators?: RawValidator[] }>(host, '/ext/bc/P', 'platform.getCurrentValidators', ctrl.signal),
|
||||
rpc<{ numPeers?: string; peers?: RawPeer[] }>(host, '/ext/info', 'info.peers', ctrl.signal),
|
||||
rpc<{ version?: string }>(host, '/ext/info', 'info.getNodeVersion', ctrl.signal),
|
||||
rpc<{ height?: string }>(host, '/ext/bc/P', 'platform.getHeight', ctrl.signal),
|
||||
rpc<{ blockchains?: RawBlockchain[] }>(host, '/ext/bc/P', 'platform.getBlockchains', ctrl.signal),
|
||||
rpc<{ validators?: RawValidator[] }>(host, '/v1/bc/P', 'platform.getCurrentValidators', ctrl.signal),
|
||||
rpc<{ numPeers?: string; peers?: RawPeer[] }>(host, '/v1/info', 'info.peers', ctrl.signal),
|
||||
rpc<{ version?: string }>(host, '/v1/info', 'info.getNodeVersion', ctrl.signal),
|
||||
rpc<{ height?: string }>(host, '/v1/bc/P', 'platform.getHeight', ctrl.signal),
|
||||
rpc<{ blockchains?: RawBlockchain[] }>(host, '/v1/bc/P', 'platform.getBlockchains', ctrl.signal),
|
||||
])
|
||||
|
||||
const reachable =
|
||||
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
// TypeScript 7 reports TS2882 for a side-effect import with no type
|
||||
// declaration ("Cannot find module or type declarations for side-effect import
|
||||
// of './globals.css'"). TS 5.x let these pass silently.
|
||||
//
|
||||
// Next.js resolves stylesheet imports through its own loader pipeline, so these
|
||||
// specifiers never reach the TypeScript module resolver at build time. The
|
||||
// ambient declaration exists to tell the checker they are legitimate, not to
|
||||
// give them a shape — hence no exported members.
|
||||
declare module '*.css';
|
||||
@@ -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'
|
||||
+5
-4
@@ -1,9 +1,10 @@
|
||||
# Unified `/v1` backend endpoints
|
||||
|
||||
The console talks to the unified Hanzo Cloud backend (`hanzoai/cloud`, the
|
||||
casibase API). 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).
|
||||
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, 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
@@ -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')
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* The ONE account control, at the foot of the rail — and the ONE org switch.
|
||||
*
|
||||
* 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
|
||||
* paints with utility class names renders exactly that in this app, because
|
||||
* Tailwind never scanned node_modules. An `expect(locator).toBeVisible()` would
|
||||
* have passed on the broken build.
|
||||
*/
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import { primeSession } from './_session'
|
||||
|
||||
/** The cross-tenant org list an admin console reaches — none of them memberships. */
|
||||
const ORGS = [
|
||||
{ owner: 'admin', name: 'hanzo', displayName: 'Hanzo' },
|
||||
{ owner: 'admin', name: 'maxpower', displayName: 'Max Power' },
|
||||
{ owner: 'admin', name: 'acme-industrial', displayName: 'Acme Industrial' },
|
||||
]
|
||||
|
||||
/** Every org-scoped request the page made, with the scope it carried. */
|
||||
type Scoped = { url: string; org: string | null }
|
||||
|
||||
/**
|
||||
* The account trigger in the persistent rail.
|
||||
*
|
||||
* The shell mounts the SAME control three times — the rail, the collapsed-rail
|
||||
* hover flyout, and the phone drawer — because `SidebarNav` is one component with
|
||||
* three mounts. All three stay in the DOM (the flyout and drawer are offset, not
|
||||
* unmounted), which predates this change and belongs to the shell lane; the first
|
||||
* in document order is the persistent rail, and every geometry assertion below
|
||||
* checks it really is the one on screen.
|
||||
*/
|
||||
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.
|
||||
// Both are covered so the spec does not silently pass on the wrong one.
|
||||
await page.route(/\/(v1|admin\/iam)\//, async (route) => {
|
||||
const url = route.request().url()
|
||||
seen.push({ url, org: route.request().headers()['x-org-id'] ?? null })
|
||||
|
||||
if (url.includes('get-organizations')) {
|
||||
const query = new URL(url).searchParams.get('value') ?? ''
|
||||
const rows = ORGS.filter((o) => o.displayName.toLowerCase().includes(query.toLowerCase()))
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'ok', data: rows, data2: rows.length }) })
|
||||
}
|
||||
if (url.includes('billing/balance')) {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ spendableCents: 4250 }) })
|
||||
}
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'ok', data: [] }) })
|
||||
})
|
||||
// The reserved `admin` org IS the super admin — the only identity that reaches
|
||||
// every tenant, which is what an admin console is for.
|
||||
// Record every write to the console's org scope. The switch reloads the page and
|
||||
// the harness re-seeds the scope on load, so the write is observed as it happens.
|
||||
await page.addInitScript(() => {
|
||||
const setItem = Storage.prototype.setItem
|
||||
Storage.prototype.setItem = function (key: string, value: string) {
|
||||
if (key === 'hanzo.console.org') {
|
||||
const log = JSON.parse(sessionStorage.getItem('spec.scope.writes') ?? '[]') as string[]
|
||||
log.push(`${key}=${value}`)
|
||||
setItem.call(sessionStorage, 'spec.scope.writes', JSON.stringify(log))
|
||||
}
|
||||
return setItem.call(this, key, value)
|
||||
}
|
||||
})
|
||||
await primeSession(page, { owner: 'admin', name: 'z', email: 'z@hanzo.ai', displayName: 'Z Admin' })
|
||||
await page.goto('/')
|
||||
await page.waitForSelector('[data-testid=nav-user]', { state: 'attached', timeout: 30_000 })
|
||||
}
|
||||
|
||||
const px = (v: string) => Number.parseFloat(v)
|
||||
const rgb = (v: string) => (v.match(/\d+(\.\d+)?/g) ?? []).map(Number)
|
||||
const luminance = ([r, g, b]: number[]) => {
|
||||
const f = (c: number) => { const n = c / 255; return n <= 0.03928 ? n / 12.92 : ((n + 0.055) / 1.055) ** 2.4 }
|
||||
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b)
|
||||
}
|
||||
const contrast = (a: number[], b: number[]) => {
|
||||
const [hi, lo] = [luminance(a), luminance(b)].sort((m, n) => n - m)
|
||||
return (hi + 0.05) / (lo + 0.05)
|
||||
}
|
||||
|
||||
test.describe('account control', () => {
|
||||
test('sits at the foot of the rail and paints in a shell with no Tailwind', async ({ page }) => {
|
||||
const seen: Scoped[] = []
|
||||
await mountConsole(page, seen)
|
||||
|
||||
// The control is at the BOTTOM — below the middle of the sidebar, not above it.
|
||||
const trigger = accountTrigger(page)
|
||||
const box = (await trigger.boundingBox())!
|
||||
const viewport = page.viewportSize()!
|
||||
expect(box.y).toBeGreaterThan(viewport.height / 2)
|
||||
expect(box.x).toBeLessThan(300)
|
||||
|
||||
// Nothing else claims to switch orgs: the top-of-rail switcher is gone.
|
||||
await expect(page.getByLabel('Switch organization')).toHaveCount(0)
|
||||
|
||||
await trigger.click()
|
||||
const menu = page.locator('[role=menu]')
|
||||
await menu.waitFor()
|
||||
|
||||
const paint = await menu.evaluate((el) => {
|
||||
const s = getComputedStyle(el)
|
||||
const r = el.getBoundingClientRect()
|
||||
return {
|
||||
bg: s.backgroundColor,
|
||||
radius: s.borderTopLeftRadius,
|
||||
borderWidth: s.borderTopWidth,
|
||||
z: s.zIndex,
|
||||
font: s.fontFamily,
|
||||
rect: { x: r.x, y: r.y, w: r.width, h: r.height },
|
||||
}
|
||||
})
|
||||
|
||||
// It PAINTS — an opaque surface, not a transparent stack of divs.
|
||||
expect(paint.bg).not.toBe('rgba(0, 0, 0, 0)')
|
||||
expect(px(paint.radius)).toBeGreaterThanOrEqual(8)
|
||||
expect(px(paint.borderWidth)).toBeGreaterThanOrEqual(1)
|
||||
// …in the app's own typeface, not a system fallback.
|
||||
expect(paint.font).toMatch(/Geist/i)
|
||||
|
||||
// It is FULLY on screen and above the shell.
|
||||
expect(paint.rect.x).toBeGreaterThanOrEqual(0)
|
||||
expect(paint.rect.y).toBeGreaterThanOrEqual(0)
|
||||
expect(paint.rect.x + paint.rect.w).toBeLessThanOrEqual(viewport.width + 1)
|
||||
expect(paint.rect.y + paint.rect.h).toBeLessThanOrEqual(viewport.height + 1)
|
||||
// Nothing of the shell is painted over it.
|
||||
const onTop = await menu.evaluate((el) => {
|
||||
const r = el.getBoundingClientRect()
|
||||
const hit = document.elementFromPoint(r.x + r.width / 2, r.y + 12)
|
||||
return el.contains(hit)
|
||||
})
|
||||
expect(onTop).toBe(true)
|
||||
|
||||
// Rows are padded, tall enough to hit, and readable.
|
||||
const rows = await menu.locator('.hz-iam-row').evaluateAll((els) =>
|
||||
els.map((el) => {
|
||||
const s = getComputedStyle(el)
|
||||
return { pl: s.paddingLeft, h: el.getBoundingClientRect().height, color: s.color, text: (el.textContent ?? '').trim() }
|
||||
}),
|
||||
)
|
||||
expect(rows.length).toBeGreaterThanOrEqual(5)
|
||||
for (const row of rows) {
|
||||
expect(px(row.pl), `"${row.text}" padding`).toBeGreaterThanOrEqual(8)
|
||||
expect(row.h, `"${row.text}" height`).toBeGreaterThanOrEqual(24)
|
||||
expect(contrast(rgb(row.color), rgb(paint.bg)), `"${row.text}" contrast`).toBeGreaterThanOrEqual(4.5)
|
||||
}
|
||||
|
||||
// Hover is a real state — the switch-that-rendered-identical class of bug.
|
||||
const first = menu.locator('.hz-iam-row').first()
|
||||
const atRest = await first.evaluate((el) => getComputedStyle(el).backgroundColor)
|
||||
await first.hover()
|
||||
expect(await first.evaluate((el) => getComputedStyle(el).backgroundColor)).not.toBe(atRest)
|
||||
|
||||
await page.screenshot({ path: 'e2e-shots/account-menu-desktop.png', animations: 'disabled' })
|
||||
})
|
||||
|
||||
test('nothing in it shouts', async ({ page }) => {
|
||||
await mountConsole(page, [])
|
||||
await accountTrigger(page).click()
|
||||
await page.locator('[role=menu]').waitFor()
|
||||
|
||||
const shouting = await page.locator('[role=menu]').evaluate((el) =>
|
||||
[...el.querySelectorAll('*')].filter((n) => getComputedStyle(n).textTransform === 'uppercase').map((n) => n.textContent ?? ''),
|
||||
)
|
||||
expect(shouting).toEqual([])
|
||||
|
||||
const typedInCaps = await page.locator('[role=menu]').evaluate((el) =>
|
||||
[...el.querySelectorAll('*')]
|
||||
.map((n) => (n.children.length ? '' : (n.textContent ?? '').trim()))
|
||||
.filter((t) => /^[A-Z][A-Z0-9 &/·—-]{3,}$/.test(t)),
|
||||
)
|
||||
expect(typedInCaps).toEqual([])
|
||||
})
|
||||
|
||||
test('the context switcher reaches a tenant the caller is not a member of', async ({ page }) => {
|
||||
const seen: Scoped[] = []
|
||||
await mountConsole(page, seen)
|
||||
// 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')
|
||||
// `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()
|
||||
// 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' })
|
||||
|
||||
// MONEY PATH. Entering a tenant must go through the console's OWN org scope —
|
||||
// the single seam that persists `hanzo.console.org`, reloads, and is read back
|
||||
// as `X-Org-Id` on every call. A switcher that minted its own would bypass the
|
||||
// scoping and its billing attribution without anything visibly breaking, so the
|
||||
// write itself is what is asserted. (The scope key is recorded through a wrapped
|
||||
// setter because the reload re-runs the harness's own seeding.)
|
||||
await acme.click()
|
||||
await page.waitForFunction(
|
||||
() => sessionStorage.getItem('spec.scope.writes')?.includes('acme-industrial') ?? false,
|
||||
)
|
||||
const writes: string[] = JSON.parse(
|
||||
(await page.evaluate(() => sessionStorage.getItem('spec.scope.writes'))) ?? '[]',
|
||||
)
|
||||
expect(writes).toContain('hanzo.console.org=acme-industrial')
|
||||
|
||||
// And every scoped call the page made before that carried the admin's own
|
||||
// scope — the menu never issued a request under someone else's tenant.
|
||||
for (const call of seen.filter((s) => s.org !== null)) expect(call.org).toBe('admin')
|
||||
})
|
||||
|
||||
test('the same control is the account surface on a phone', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await mountConsole(page, [])
|
||||
|
||||
// On a phone the rail is a drawer, so the account control lives in the
|
||||
// right-hand account sheet — the SAME component, not a phone-only copy.
|
||||
await page.getByLabel('Account and settings').click()
|
||||
await drawerTrigger(page).click()
|
||||
const menu = page.locator('[role=menu]')
|
||||
await menu.waitFor()
|
||||
|
||||
const rect = await menu.evaluate((el) => {
|
||||
const r = el.getBoundingClientRect()
|
||||
return { x: r.x, y: r.y, w: r.width, h: r.height, bg: getComputedStyle(el).backgroundColor }
|
||||
})
|
||||
expect(rect.bg).not.toBe('rgba(0, 0, 0, 0)')
|
||||
// …and it paints OVER the sheet it was opened from. A sheet pinned at a
|
||||
// literal 1000 swallowed the menu whole: present, measurable, unclickable.
|
||||
const onTop = await menu.evaluate((el) => {
|
||||
const r = el.getBoundingClientRect()
|
||||
return el.contains(document.elementFromPoint(r.x + r.width / 2, r.y + 12))
|
||||
})
|
||||
expect(onTop).toBe(true)
|
||||
expect(rect.x).toBeGreaterThanOrEqual(0)
|
||||
expect(rect.x + rect.w).toBeLessThanOrEqual(391)
|
||||
expect(rect.y).toBeGreaterThanOrEqual(0)
|
||||
expect(rect.y + rect.h).toBeLessThanOrEqual(845)
|
||||
|
||||
// The page itself never scrolls sideways to accommodate it.
|
||||
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
|
||||
expect(overflow).toBeLessThanOrEqual(0)
|
||||
|
||||
await page.screenshot({ path: 'e2e-shots/account-menu-mobile.png', animations: 'disabled' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* Infrastructure admin board — render + interaction proof.
|
||||
*
|
||||
* Drives the REAL InfraModule (client + pure logic + the shared sortable DataTable)
|
||||
* against a mock of `/v1/admin/infra` seeded with the fleet's REAL shape: 58 nodes,
|
||||
* 295 volumes, 8 clusters, 132 detached, and 3 unreferenced/deletable volumes totalling
|
||||
* 500 GiB ≈ $50/mo. Nothing here is fabricated beyond the fixture — the assertions are
|
||||
* about what the board DOES with real numbers.
|
||||
*
|
||||
* Proves: the Overview totals render and the droplet-local-disk note is unmissable;
|
||||
* every tab renders; sorting a column genuinely REORDERS rows (the first row's text
|
||||
* changes); the `unreferenced` filter yields exactly 3; a NON-deletable volume shows no
|
||||
* delete control (it shows its blockedReason instead); and a deletable volume's confirm
|
||||
* states the name, the size in GiB, and the monthly cost being reclaimed.
|
||||
*
|
||||
* Screenshots every tab to e2e-shots/admin-infra-<tab>.png.
|
||||
*
|
||||
* Run: BASE_URL=http://localhost:4000 npx playwright test admin-infra
|
||||
*/
|
||||
import { test, expect, type Route } from '@playwright/test'
|
||||
import { requireFixtureServer } from './_fixture'
|
||||
import { primeSession } from './_session'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4000'
|
||||
requireFixtureServer()
|
||||
const SHOTS = join(process.cwd(), 'e2e-shots')
|
||||
|
||||
const API_RE = /\/(v1|cloud|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations|auth\/refresh)(\/|$|\?)/
|
||||
|
||||
// ── the fixture: the fleet's REAL shape ───────────────────────────────────────
|
||||
|
||||
const CLUSTER_NAMES = ['hanzo-k8s', 'lux-k8s', 'zoo-k8s', 'bootnode-k8s', 'pars-k8s', 'ci-arc-k8s', 'edge-k8s', 'staging-k8s']
|
||||
|
||||
/** 8 clusters. `zebra-k8s` is deliberately absent — name sorting is proven on the real set. */
|
||||
const clusters = CLUSTER_NAMES.map((name, i) => ({
|
||||
id: `c-${i + 1}`,
|
||||
name,
|
||||
region: ['nyc3', 'sfo3', 'ams3'][i % 3],
|
||||
version: '1.31.1-do.4',
|
||||
status: 'running',
|
||||
nodePools: 2 + (i % 3),
|
||||
nodes: [12, 10, 8, 7, 6, 6, 5, 4][i],
|
||||
pods: 120 - i * 9,
|
||||
pvs: 40 - i * 3,
|
||||
pvcs: 40 - i * 3,
|
||||
idlePVCs: i === 0 ? 6 : i === 1 ? 3 : 0,
|
||||
scanned: true,
|
||||
scanError: '',
|
||||
monthlyCents: [480000, 320000, 180000, 120000, 74000, 60000, 32000, 18000][i],
|
||||
}))
|
||||
|
||||
/** 58 droplets across the 8 clusters; each carries 160 GiB of LOCAL disk (9,280 GiB total). */
|
||||
const nodes = Array.from({ length: 58 }, (_, i) => ({
|
||||
id: 1000 + i,
|
||||
name: `pool-${String.fromCharCode(97 + (i % 8))}-${i + 1}`,
|
||||
cluster: CLUSTER_NAMES[i % 8],
|
||||
clusterId: `c-${(i % 8) + 1}`,
|
||||
region: ['nyc3', 'sfo3', 'ams3'][i % 3],
|
||||
status: 'active',
|
||||
sizeSlug: i % 5 === 0 ? 's-8vcpu-16gb' : 's-4vcpu-8gb',
|
||||
vcpus: i % 5 === 0 ? 8 : 4,
|
||||
memoryMiB: i % 5 === 0 ? 16384 : 8192,
|
||||
localDiskGiB: 160,
|
||||
monthlyCents: i % 5 === 0 ? 9600 : 4800,
|
||||
createdAt: '2026-01-04T10:00:00Z',
|
||||
privateIp: `10.0.${Math.floor(i / 256)}.${i % 256}`,
|
||||
publicIp: '',
|
||||
tags: ['k8s', `k8s:c-${(i % 8) + 1}`],
|
||||
ready: i !== 57,
|
||||
schedulable: i !== 56,
|
||||
pods: 4 + (i % 17),
|
||||
volumes: i % 3 === 0 ? 2 : 1,
|
||||
}))
|
||||
|
||||
/**
|
||||
* 295 volumes: 163 attached, 129 detached-but-referenced (bound/released), and the 3
|
||||
* UNREFERENCED ones that are genuinely reclaimable (500 GiB ≈ $50/mo).
|
||||
* detachedVolumes = 132 = the 129 bound/released + the 3 unreferenced.
|
||||
*/
|
||||
const volumes = [
|
||||
...Array.from({ length: 163 }, (_, i) => ({
|
||||
id: `v-att-${i}`,
|
||||
name: `pvc-attached-${String(i).padStart(3, '0')}`,
|
||||
region: 'nyc3',
|
||||
sizeGiB: 100,
|
||||
monthlyCents: 1000,
|
||||
state: 'attached',
|
||||
dropletIds: [1000 + (i % 58)],
|
||||
nodeName: nodes[i % 58].name,
|
||||
cluster: CLUSTER_NAMES[i % 8],
|
||||
clusterId: `c-${(i % 8) + 1}`,
|
||||
tagCluster: `c-${(i % 8) + 1}`,
|
||||
pv: `pv-att-${i}`,
|
||||
pvPhase: 'Bound',
|
||||
pvcNamespace: 'hanzo',
|
||||
pvcName: `data-${i}`,
|
||||
mountedBy: [`pod-${i}`],
|
||||
idle: false,
|
||||
createdAt: '2026-02-01T00:00:00Z',
|
||||
deletable: false,
|
||||
blockedReason: 'Attached to a droplet.',
|
||||
})),
|
||||
...Array.from({ length: 118 }, (_, i) => ({
|
||||
id: `v-bound-${i}`,
|
||||
name: `pvc-bound-${String(i).padStart(3, '0')}`,
|
||||
region: 'sfo3',
|
||||
sizeGiB: 150,
|
||||
monthlyCents: 1500,
|
||||
state: 'bound',
|
||||
dropletIds: [],
|
||||
nodeName: '',
|
||||
cluster: CLUSTER_NAMES[i % 8],
|
||||
clusterId: `c-${(i % 8) + 1}`,
|
||||
tagCluster: `c-${(i % 8) + 1}`,
|
||||
pv: `pv-bound-${i}`,
|
||||
pvPhase: 'Bound',
|
||||
pvcNamespace: 'hanzo',
|
||||
pvcName: `idle-${i}`,
|
||||
mountedBy: [],
|
||||
idle: true,
|
||||
createdAt: '2026-02-01T00:00:00Z',
|
||||
deletable: false,
|
||||
blockedReason: 'Bound to PVC hanzo/idle — still claimed.',
|
||||
})),
|
||||
...Array.from({ length: 11 }, (_, i) => ({
|
||||
id: `v-rel-${i}`,
|
||||
name: `pvc-released-${String(i).padStart(3, '0')}`,
|
||||
region: 'ams3',
|
||||
sizeGiB: 120,
|
||||
monthlyCents: 1200,
|
||||
state: 'released',
|
||||
dropletIds: [],
|
||||
nodeName: '',
|
||||
cluster: CLUSTER_NAMES[i % 8],
|
||||
clusterId: `c-${(i % 8) + 1}`,
|
||||
tagCluster: `c-${(i % 8) + 1}`,
|
||||
pv: `pv-rel-${i}`,
|
||||
pvPhase: 'Released',
|
||||
pvcNamespace: '',
|
||||
pvcName: '',
|
||||
mountedBy: [],
|
||||
idle: false,
|
||||
createdAt: '2026-01-15T00:00:00Z',
|
||||
deletable: false,
|
||||
blockedReason: 'PV is Released but not yet reclaimed — retain policy holds the data.',
|
||||
})),
|
||||
// The 3 genuinely reclaimable volumes: 500 GiB total, $50.00/mo total.
|
||||
{
|
||||
id: 'v-orphan-1', name: 'pvc-abandoned-alpha', region: 'nyc3', sizeGiB: 200, monthlyCents: 2000,
|
||||
state: 'unreferenced', dropletIds: [], nodeName: '', cluster: '', clusterId: '', tagCluster: 'c-1',
|
||||
pv: '', pvPhase: '', pvcNamespace: '', pvcName: '', mountedBy: [], idle: false,
|
||||
createdAt: '2025-11-02T00:00:00Z', deletable: true, blockedReason: '',
|
||||
},
|
||||
{
|
||||
id: 'v-orphan-2', name: 'pvc-abandoned-bravo', region: 'sfo3', sizeGiB: 200, monthlyCents: 2000,
|
||||
state: 'unreferenced', dropletIds: [], nodeName: '', cluster: '', clusterId: '', tagCluster: '',
|
||||
pv: '', pvPhase: '', pvcNamespace: '', pvcName: '', mountedBy: [], idle: false,
|
||||
createdAt: '2025-12-11T00:00:00Z', deletable: true, blockedReason: '',
|
||||
},
|
||||
{
|
||||
id: 'v-orphan-3', name: 'pvc-abandoned-charlie', region: 'ams3', sizeGiB: 100, monthlyCents: 1000,
|
||||
state: 'unreferenced', dropletIds: [], nodeName: '', cluster: '', clusterId: '', tagCluster: '',
|
||||
pv: '', pvPhase: '', pvcNamespace: '', pvcName: '', mountedBy: [], idle: false,
|
||||
createdAt: '2026-01-20T00:00:00Z', deletable: true, blockedReason: '',
|
||||
},
|
||||
]
|
||||
|
||||
const loadBalancers = [
|
||||
{ id: 'lb-1', name: 'edge-ingress', region: 'nyc3', status: 'active', ip: '143.198.10.1', sizeUnit: 3, monthlyCents: 3600, droplets: 12, cluster: 'hanzo-k8s' },
|
||||
{ id: 'lb-2', name: 'api-gateway', region: 'sfo3', status: 'active', ip: '143.198.10.2', sizeUnit: 1, monthlyCents: 1200, droplets: 10, cluster: 'lux-k8s' },
|
||||
{ id: 'lb-3', name: 'zoo-edge', region: 'ams3', status: 'new', ip: '', sizeUnit: 1, monthlyCents: 1200, droplets: 0, cluster: 'zoo-k8s' },
|
||||
{ id: 'lb-4', name: 'bootnode-rpc', region: 'nyc3', status: 'active', ip: '143.198.10.4', sizeUnit: 1, monthlyCents: 1200, droplets: 7, cluster: 'bootnode-k8s' },
|
||||
]
|
||||
|
||||
const findings = [
|
||||
{ id: 'f-1', severity: 'critical', kind: 'unreferenced-volume', title: 'Three unreferenced volumes', detail: '500 GiB of block storage is referenced by no PV, PVC or droplet.', resource: 'pvc-abandoned-alpha, pvc-abandoned-bravo, pvc-abandoned-charlie', cluster: '', monthlyCents: 5000 },
|
||||
{ id: 'f-2', severity: 'warn', kind: 'idle-pvc', title: 'Idle PVCs on hanzo-k8s', detail: 'Bound to a PVC but no pod mounts them.', resource: '6 PVCs', cluster: 'hanzo-k8s', monthlyCents: 9000 },
|
||||
{ id: 'f-3', severity: 'warn', kind: 'released-pv', title: 'Released PVs retained', detail: 'Retain reclaim policy is holding the data.', resource: '11 PVs', cluster: 'lux-k8s', monthlyCents: 13200 },
|
||||
{ id: 'f-4', severity: 'info', kind: 'cost-outlier', title: 'hanzo-k8s is 28% of fleet spend', detail: 'Largest single cluster by monthly cost.', resource: 'hanzo-k8s', cluster: 'hanzo-k8s', monthlyCents: 480000 },
|
||||
]
|
||||
|
||||
const snapshot = {
|
||||
at: new Date().toISOString(),
|
||||
complete: true,
|
||||
incompleteReason: '',
|
||||
sources: [
|
||||
{ name: 'digitalocean', ok: true, rows: 359, error: '', at: new Date().toISOString() },
|
||||
{ name: 'hanzo-k8s', ok: true, rows: 40, error: '', at: new Date().toISOString() },
|
||||
],
|
||||
totals: {
|
||||
clusters: 8, nodes: 58, volumes: 295, loadBalancers: 4,
|
||||
volumeGiB: 41200, attachedVolumes: 163, attachedGiB: 16300,
|
||||
detachedVolumes: 132, detachedGiB: 20120,
|
||||
unreferencedVolumes: 3, unreferencedGiB: 500,
|
||||
idlePVCs: 118, localDiskGiB: 9280,
|
||||
},
|
||||
cost: { dropletsMonthly: 1284000, volumesMonthly: 412000, loadBalancersMonthly: 7200, totalMonthly: 1703200, reclaimableMonthly: 5000 },
|
||||
clusters, nodes, volumes, loadBalancers, findings,
|
||||
}
|
||||
|
||||
// ── the spec ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Mock everything; `/v1/admin/infra` answers with the fixture, all else an empty envelope. */
|
||||
async function mockFleet(page: import('@playwright/test').Page) {
|
||||
await page.route('**/*', async (route: Route) => {
|
||||
const req = route.request()
|
||||
if (req.resourceType() === 'document') return route.continue()
|
||||
const url = new URL(req.url())
|
||||
const path = url.pathname
|
||||
|
||||
if (path === '/v1/admin/infra' && req.method() === 'GET') {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'ok', msg: '', data: snapshot }) })
|
||||
}
|
||||
|
||||
const sameOrigin = url.origin === new URL(BASE_URL).origin
|
||||
if (sameOrigin && !API_RE.test(path)) return route.continue()
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'ok', msg: '', data: [], data2: 0 }) })
|
||||
})
|
||||
await primeSession(page, { owner: 'admin', name: 'z', email: 'z@hanzo.ai', isAdmin: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Open one tab by URL. This also proves the registry declares the `:tab` route — an
|
||||
* undeclared tab slug 404s (the v8.4.86 class of bug), which a click-only spec hides.
|
||||
* URL navigation is also unambiguous: the sidebar carries its own "Clusters" / "Nodes"
|
||||
* product entries, so a bare button match would be a coin flip.
|
||||
*/
|
||||
async function openTab(page: import('@playwright/test').Page, slug: string, tabLabel: string) {
|
||||
await page.goto(`${BASE_URL}/infra${slug ? `/${slug}` : ''}`, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page.getByText('Infrastructure').first()).toBeVisible({ timeout: 30_000 })
|
||||
// The module's own tab bar rendered this tab (and it is the selected one).
|
||||
await expect(page.getByRole('button', { name: tabLabel, exact: true }).last()).toBeVisible({ timeout: 15_000 })
|
||||
}
|
||||
|
||||
test('infrastructure board renders the fleet, sorts, filters, and gates deletion', async ({ page }) => {
|
||||
mkdirSync(SHOTS, { recursive: true })
|
||||
await mockFleet(page)
|
||||
|
||||
// ── Overview: the totals + the unmissable local-disk note ───────────────────
|
||||
await openTab(page, '', 'Overview')
|
||||
|
||||
// Cost breakdown: total / droplets / block storage / load balancers / reclaimable.
|
||||
await expect(page.getByText('$17,032.00').first()).toBeVisible({ timeout: 15_000 })
|
||||
await expect(page.getByText('$12,840.00').first()).toBeVisible()
|
||||
await expect(page.getByText('$4,120.00').first()).toBeVisible()
|
||||
await expect(page.getByText('$72.00').first()).toBeVisible()
|
||||
// The reclaimable card: the 3 unreferenced volumes ≈ $50/mo, 500 GiB.
|
||||
await expect(page.getByText('$50.00').first()).toBeVisible()
|
||||
await expect(page.getByText('3 unreferenced · 500 GiB').first()).toBeVisible()
|
||||
// Fleet counts.
|
||||
await expect(page.getByText('8 clusters · 58 nodes').first()).toBeVisible()
|
||||
await expect(page.getByText('295 volumes · 40.2 TiB').first()).toBeVisible()
|
||||
|
||||
// THE distinction: droplet local disk is inside the droplet price, not block storage.
|
||||
await expect(page.getByText('Droplet local disk is included in the droplet price — it is never billed separately')).toBeVisible()
|
||||
await expect(page.getByText(/9,280 GiB of local disk is already inside the droplet number/)).toBeVisible()
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'admin-infra-overview.png'), fullPage: false })
|
||||
|
||||
// ── Clusters: sorting a column genuinely REORDERS rows ──────────────────────
|
||||
await page.getByRole('button', { name: 'Clusters', exact: true }).first().click()
|
||||
await expect(page.getByText('hanzo-k8s').first()).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
// Default sort is Monthly desc → hanzo-k8s ($4,800.00) is first.
|
||||
const clusterRows = page.locator('.hz-row')
|
||||
await expect(clusterRows.first()).toContainText('hanzo-k8s')
|
||||
const beforeSort = (await clusterRows.first().innerText()).trim()
|
||||
|
||||
// Click the "Cluster" header → sort by name ASC → bootnode-k8s is first (a different row).
|
||||
await page.getByLabel('Sort by Cluster').click()
|
||||
await expect(clusterRows.first()).toContainText('bootnode-k8s', { timeout: 10_000 })
|
||||
const afterAsc = (await clusterRows.first().innerText()).trim()
|
||||
expect(afterAsc).not.toBe(beforeSort) // the first row's text genuinely CHANGED
|
||||
|
||||
// Click it again → DESC → zoo-k8s is first (the reverse end of the same column).
|
||||
await page.getByLabel('Sort by Cluster').click()
|
||||
await expect(clusterRows.first()).toContainText('zoo-k8s', { timeout: 10_000 })
|
||||
expect((await clusterRows.first().innerText()).trim()).not.toBe(afterAsc)
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'admin-infra-clusters.png'), fullPage: false })
|
||||
|
||||
// ── Nodes: 58 droplets, sortable, with a cordon control ─────────────────────
|
||||
await page.getByRole('button', { name: 'Nodes', exact: true }).first().click()
|
||||
await expect(page.getByLabel('Sort by Node')).toBeVisible({ timeout: 15_000 })
|
||||
await expect(page.getByRole('button', { name: 'Cordon' }).first()).toBeVisible()
|
||||
|
||||
// Sort by vCPU ascending → a 4-vCPU node leads; descending → an 8-vCPU node leads.
|
||||
const nodeRows = page.locator('.hz-row')
|
||||
await page.getByLabel('Sort by vCPU').click()
|
||||
await expect(nodeRows.first()).toContainText('s-4vcpu-8gb', { timeout: 10_000 })
|
||||
const nodeAsc = (await nodeRows.first().innerText()).trim()
|
||||
await page.getByLabel('Sort by vCPU').click()
|
||||
await expect(nodeRows.first()).toContainText('s-8vcpu-16gb', { timeout: 10_000 })
|
||||
expect((await nodeRows.first().innerText()).trim()).not.toBe(nodeAsc)
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'admin-infra-nodes.png'), fullPage: false })
|
||||
|
||||
// ── Volumes: the unreferenced filter yields EXACTLY 3 ───────────────────────
|
||||
await page.getByRole('button', { name: 'Volumes', exact: true }).first().click()
|
||||
await expect(page.getByText('295').first()).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
await page.getByRole('button', { name: 'Unreferenced', exact: true }).click()
|
||||
const volumeRows = page.locator('.hz-row')
|
||||
await expect(volumeRows).toHaveCount(3, { timeout: 10_000 })
|
||||
await expect(page.getByText('pvc-abandoned-alpha')).toBeVisible()
|
||||
await expect(page.getByText('pvc-abandoned-bravo')).toBeVisible()
|
||||
await expect(page.getByText('pvc-abandoned-charlie')).toBeVisible()
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'admin-infra-volumes.png'), fullPage: false })
|
||||
|
||||
// A DELETABLE volume: the confirm states name + GiB + the monthly cost reclaimed.
|
||||
await page.getByText('pvc-abandoned-alpha').first().click()
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10_000 })
|
||||
const confirmText = page.getByText(/Delete volume “pvc-abandoned-alpha”/)
|
||||
await expect(confirmText).toBeVisible()
|
||||
await expect(confirmText).toContainText('200 GiB')
|
||||
await expect(confirmText).toContainText('$20.00/month')
|
||||
await expect(confirmText).toContainText('A snapshot is taken first')
|
||||
await expect(page.getByRole('button', { name: 'Delete pvc-abandoned-alpha' })).toBeVisible()
|
||||
await page.screenshot({ path: join(SHOTS, 'admin-infra-volume-delete.png'), fullPage: false })
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(page.getByRole('dialog')).toBeHidden({ timeout: 10_000 })
|
||||
|
||||
// A NON-deletable volume: NO delete control anywhere — the blocked reason instead.
|
||||
await page.getByRole('button', { name: 'Attached', exact: true }).click()
|
||||
await expect(page.getByText('pvc-attached-000').first()).toBeVisible({ timeout: 10_000 })
|
||||
await page.getByText('pvc-attached-000').first().click()
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10_000 })
|
||||
await expect(page.getByText('This volume cannot be deleted')).toBeVisible()
|
||||
await expect(page.getByText('Attached to a droplet.').first()).toBeVisible()
|
||||
// The gate, asserted negatively: no delete button, no confirm text, no snapshot toggle.
|
||||
await expect(page.getByRole('button', { name: /^Delete / })).toHaveCount(0)
|
||||
await expect(page.getByText(/Delete volume “/)).toHaveCount(0)
|
||||
await expect(page.getByText('Take a snapshot first')).toHaveCount(0)
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(page.getByRole('dialog')).toBeHidden({ timeout: 10_000 })
|
||||
|
||||
// ── Load balancers ──────────────────────────────────────────────────────────
|
||||
await page.getByRole('button', { name: 'Load balancers', exact: true }).first().click()
|
||||
await expect(page.getByText('edge-ingress').first()).toBeVisible({ timeout: 15_000 })
|
||||
await expect(page.getByText('$36.00').first()).toBeVisible()
|
||||
await page.screenshot({ path: join(SHOTS, 'admin-infra-load-balancers.png'), fullPage: false })
|
||||
|
||||
// ── Audit: findings grouped by severity, with cost impact ───────────────────
|
||||
await page.getByRole('button', { name: 'Audit', exact: true }).first().click()
|
||||
await expect(page.getByText('Three unreferenced volumes').first()).toBeVisible({ timeout: 15_000 })
|
||||
await expect(page.getByText('critical · 1').first()).toBeVisible()
|
||||
await expect(page.getByText('warn · 2').first()).toBeVisible()
|
||||
await expect(page.getByText('info · 1').first()).toBeVisible()
|
||||
// Group cost impact: the two warns sum to $222.00/mo (9000 + 13200 cents).
|
||||
await expect(page.getByText('$222.00/mo').first()).toBeVisible()
|
||||
await page.screenshot({ path: join(SHOTS, 'admin-infra-audit.png'), fullPage: false })
|
||||
})
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* e2e: the agent quickstart.
|
||||
*
|
||||
* The surface in the screenshot: a step ladder, "What do you want to build?" with a
|
||||
* composer, and a searchable template gallery beside it. These are assertions only a
|
||||
* browser can make — that the two columns actually paint side by side at desktop,
|
||||
* stack on a phone without the body scrolling sideways, and that picking a template
|
||||
* carries its preset into the builder.
|
||||
*
|
||||
* Run: BASE_URL=http://localhost:4000 npx playwright test agent-quickstart
|
||||
*/
|
||||
import { test, expect, type Route, type Page } from '@playwright/test'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { requireFixtureServer } from './_fixture'
|
||||
import { primeSession } from './_session'
|
||||
|
||||
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4000'
|
||||
|
||||
requireFixtureServer()
|
||||
const SHOTS = join(process.cwd(), 'e2e-shots')
|
||||
|
||||
const ACCOUNT = { owner: 'hanzo', name: 'z', email: 'z@hanzo.ai', displayName: 'Z Admin', isAdmin: true }
|
||||
|
||||
const API_RE = /\/(v1|cloud|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations|auth\/refresh)(\/|$|\?)/
|
||||
const json = (route: Route, body: unknown, status = 200) =>
|
||||
route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
|
||||
|
||||
/** Every backend 401s — this spec is about the SURFACE, not data. */
|
||||
async function mock(route: Route) {
|
||||
const req = route.request()
|
||||
if (req.resourceType() === 'document') return route.continue()
|
||||
const url = new URL(req.url())
|
||||
if (url.pathname.startsWith('/auth/')) return json(route, { ok: true })
|
||||
const sameOrigin = url.origin === new URL(BASE_URL).origin
|
||||
if (sameOrigin && !API_RE.test(url.pathname)) return route.continue()
|
||||
return json(route, { error: 'Sign in to use Hanzo Cloud.' }, 401)
|
||||
}
|
||||
|
||||
async function open(page: Page) {
|
||||
await page.route('**/*', mock)
|
||||
await primeSession(page, ACCOUNT)
|
||||
await page.goto(`${BASE_URL}/agents/quickstart`, { waitUntil: 'domcontentloaded' })
|
||||
await page.locator('[data-testid="product-content"]').first().waitFor({ state: 'attached', timeout: 30_000 })
|
||||
await page.waitForTimeout(1500)
|
||||
}
|
||||
|
||||
test.beforeAll(() => mkdirSync(SHOTS, { recursive: true }))
|
||||
|
||||
test('desktop: the ladder, the composer and the gallery', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
await open(page)
|
||||
|
||||
await expect(page.getByText('What do you want to build?')).toBeVisible()
|
||||
await expect(page.getByLabel('Describe your agent')).toBeVisible()
|
||||
await expect(page.getByText('Browse templates')).toBeVisible()
|
||||
|
||||
// Step 1 is current; later steps are present but not yet reachable.
|
||||
await expect(page.getByRole('button', { name: /Step 1: Describe/ })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: /Step 3: Run/ })).toBeDisabled()
|
||||
|
||||
// The two columns sit SIDE BY SIDE — geometry, not source.
|
||||
const composer = await page.getByLabel('Describe your agent').boundingBox()
|
||||
const gallery = await page.getByText('Browse templates').boundingBox()
|
||||
expect(composer && gallery).toBeTruthy()
|
||||
expect(gallery!.x, 'the gallery is to the right of the composer').toBeGreaterThan(composer!.x + composer!.width - 1)
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'agent-quickstart-desktop.png'), fullPage: false })
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('the gallery searches, and picking a template carries its preset into the builder', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
await open(page)
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Start from Deep researcher' })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Start from Code reviewer' })).toBeVisible()
|
||||
|
||||
await page.getByLabel('Search templates').fill('extract')
|
||||
await page.waitForTimeout(400)
|
||||
await expect(page.getByRole('button', { name: 'Start from Structured extractor' })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Start from Deep researcher' })).toHaveCount(0)
|
||||
|
||||
await page.getByLabel('Search templates').fill('')
|
||||
await page.waitForTimeout(300)
|
||||
await page.getByRole('button', { name: 'Start from Deep researcher' }).click()
|
||||
await page.waitForTimeout(700)
|
||||
|
||||
// Step 2: the ONE builder, carrying the template's preset — the handle and the
|
||||
// prompt the template declares, not an empty form.
|
||||
await expect(page.getByRole('button', { name: /Step 2: Configure/ })).toBeVisible()
|
||||
await expect(page.locator('input[value="researcher"]').first()).toBeVisible()
|
||||
await expect(page.getByText(/You research questions/).first()).toBeVisible()
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'agent-quickstart-configure.png'), fullPage: false })
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('phone: it stacks and the body never scrolls sideways', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 390, height: 844 } })
|
||||
const page = await ctx.newPage()
|
||||
await open(page)
|
||||
|
||||
await expect(page.getByText('What do you want to build?')).toBeVisible()
|
||||
await expect(page.getByLabel('Describe your agent')).toBeVisible()
|
||||
|
||||
const scrolls = await page.evaluate(
|
||||
() => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
|
||||
)
|
||||
expect(scrolls, 'body must not scroll horizontally').toBe(false)
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'agent-quickstart-phone.png'), fullPage: true })
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('a template card is reachable and operable by keyboard, and it rings', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
await open(page)
|
||||
|
||||
const card = page.getByRole('button', { name: 'Start from Deep researcher' })
|
||||
await card.focus()
|
||||
await expect(card).toBeFocused()
|
||||
|
||||
// The focus law lives in globals.css and keys off [tabindex] among others — a card
|
||||
// that takes focus and shows nothing is worse than one that cannot be reached.
|
||||
const ring = await card.evaluate((el) => {
|
||||
const s = getComputedStyle(el)
|
||||
return { width: s.outlineWidth, style: s.outlineStyle, color: s.outlineColor }
|
||||
})
|
||||
expect(ring.style, 'the focused card draws an outline').not.toBe('none')
|
||||
expect(parseFloat(ring.width), 'the outline has real width').toBeGreaterThan(0)
|
||||
|
||||
// Enter picks it — the same thing a click does.
|
||||
await page.keyboard.press('Enter')
|
||||
await page.waitForTimeout(700)
|
||||
await expect(page.getByRole('button', { name: /Step 2: Configure/ })).toBeVisible()
|
||||
await expect(page.locator('input[value="researcher"]').first()).toBeVisible()
|
||||
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('the board\'s New Agent button is the SAME door as the quickstart', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
await page.route('**/*', mock)
|
||||
await primeSession(page, ACCOUNT)
|
||||
await page.goto(`${BASE_URL}/agents`, { waitUntil: 'domcontentloaded' })
|
||||
await page.locator('[data-testid="product-content"]').first().waitFor({ state: 'attached', timeout: 30_000 })
|
||||
await page.waitForTimeout(1200)
|
||||
|
||||
// Whichever New-Agent affordance the board is showing (header button or empty
|
||||
// state), it must LAND on the quickstart — not open a second, differently-shaped
|
||||
// create form in a side pane.
|
||||
const cta = page.getByRole('button', { name: /New Agent/i }).filter({ visible: true }).first()
|
||||
await cta.click()
|
||||
await page.waitForTimeout(900)
|
||||
|
||||
expect(new URL(page.url()).pathname).toBe('/agents/quickstart')
|
||||
await expect(page.getByText('What do you want to build?')).toBeVisible()
|
||||
|
||||
await ctx.close()
|
||||
})
|
||||
@@ -149,7 +149,7 @@ test.describe('(A) fixture render — model mix (fable-5 75%) + 62% margin + hon
|
||||
|
||||
// Page rendered (not the operator gate).
|
||||
await expect(page.getByText('AI Economics').first()).toBeVisible()
|
||||
await expect(page.locator('text=/Operator access required|not authorized|access denied/i')).toHaveCount(0)
|
||||
await expect(page.locator('text=/SuperAdmin access required|not authorized|access denied/i')).toHaveCount(0)
|
||||
|
||||
// (a) model mix — the mocked rows WITH request-share %.
|
||||
const modelMix = page.getByTestId('model-mix')
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* e2e: the assistant's ONE entry point, and the All-products directory you can act in.
|
||||
*
|
||||
* Three claims, each measured in a real browser rather than inferred from source:
|
||||
*
|
||||
* 1. The assistant opens from a FLOATING bottom-right control, not from the header —
|
||||
* asserted on GEOMETRY (the control's box is in the bottom-right quadrant of the
|
||||
* viewport) and on the header carrying no assistant control at all.
|
||||
* 2. Clicking an app in the All-products directory NAVIGATES to that app. This is the
|
||||
* regression that matters: the rows rendered, hovered, and did nothing, so the
|
||||
* directory looked interactive and was not. Asserted on where the browser LANDS.
|
||||
* 3. A pin made in the directory survives a reload EVEN WHEN the identity token
|
||||
* carries an older preferences snapshot — the exact production condition (the
|
||||
* token is minted at sign-in; a pin made after it is not in it).
|
||||
*
|
||||
* Local dev server + mocked network; `primeSession` supplies the IAM-PKCE identity.
|
||||
*
|
||||
* Run: BASE_URL=http://localhost:4000 npx playwright test assistant-fab-and-apps
|
||||
*/
|
||||
import { test, expect, type Route, type Page } from '@playwright/test'
|
||||
import { requireFixtureServer } from './_fixture'
|
||||
import { primeSession } from './_session'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4000'
|
||||
|
||||
requireFixtureServer()
|
||||
const SHOTS = join(process.cwd(), 'e2e-shots')
|
||||
|
||||
const API_RE = /\/(v1|cloud|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations)(\/|$|\?)/
|
||||
|
||||
async function mock(route: Route) {
|
||||
const req = route.request()
|
||||
if (req.resourceType() === 'document') return route.continue()
|
||||
const url = new URL(req.url())
|
||||
const sameOrigin = url.origin === new URL(BASE_URL).origin
|
||||
if (sameOrigin && !API_RE.test(url.pathname)) return route.continue()
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ status: 'ok', msg: '', data: [], data2: 0 }),
|
||||
})
|
||||
}
|
||||
|
||||
/** Sign in and land on `path`, waiting for the signed-in shell to have mounted. */
|
||||
async function boot(page: Page, path = '/', claims?: Parameters<typeof primeSession>[1]) {
|
||||
await page.route('**/*', mock)
|
||||
await primeSession(page, claims)
|
||||
await page.goto(`${BASE_URL}${path}`, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page.getByRole('button', { name: 'Ask Hanzo' })).toBeVisible({ timeout: 60_000 })
|
||||
}
|
||||
|
||||
test.beforeAll(() => mkdirSync(SHOTS, { recursive: true }))
|
||||
|
||||
test('the assistant opens from the bottom-right, and the header carries no AI control', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
await boot(page)
|
||||
|
||||
const fab = page.getByRole('button', { name: 'Ask Hanzo' })
|
||||
const box = await fab.boundingBox()
|
||||
expect(box).not.toBeNull()
|
||||
// Bottom-right quadrant: the whole point of the relocation.
|
||||
expect(box!.x).toBeGreaterThan(1440 / 2)
|
||||
expect(box!.y).toBeGreaterThan(900 / 2)
|
||||
// A comfortable target, not a hairline.
|
||||
expect(box!.width).toBeGreaterThanOrEqual(44)
|
||||
expect(box!.height).toBeGreaterThanOrEqual(44)
|
||||
|
||||
// The topbar itself holds no assistant control any more — it used to carry two
|
||||
// (a brand-H "Chat with Hanzo" and a "Talk to Hanzo" mic) beside the search box.
|
||||
const inTopbar = await page.evaluate(() =>
|
||||
Array.from(document.querySelectorAll('.hz-topbar [aria-label]')).map((n) => n.getAttribute('aria-label') ?? ''),
|
||||
)
|
||||
expect(inTopbar).not.toHaveLength(0) // the topbar was found at all
|
||||
expect(inTopbar.filter((l) => /Hanzo/i.test(l))).toHaveLength(0)
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'assistant-fab-desktop.png') })
|
||||
|
||||
// It opens the SAME assistant surface.
|
||||
await fab.click()
|
||||
await expect(page.getByText('Assistant').first()).toBeVisible({ timeout: 15_000 })
|
||||
await page.screenshot({ path: join(SHOTS, 'assistant-open-desktop.png') })
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('the assistant control is reachable on a phone and never scrolls the body sideways', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 390, height: 844 } })
|
||||
const page = await ctx.newPage()
|
||||
await boot(page)
|
||||
|
||||
const fab = page.getByRole('button', { name: 'Ask Hanzo' })
|
||||
const box = await fab.boundingBox()
|
||||
expect(box).not.toBeNull()
|
||||
expect(box!.x + box!.width).toBeLessThanOrEqual(390)
|
||||
expect(box!.y).toBeGreaterThan(844 / 2)
|
||||
|
||||
const [scrollW, clientW] = await page.evaluate(() => [
|
||||
document.documentElement.scrollWidth,
|
||||
document.documentElement.clientWidth,
|
||||
])
|
||||
expect(scrollW).toBe(clientW)
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'assistant-fab-mobile.png') })
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('clicking an app in All products opens that app', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
await boot(page)
|
||||
|
||||
await page.getByRole('button', { name: 'All products' }).first().click()
|
||||
const row = page.getByRole('button', { name: 'Open Agents' })
|
||||
await expect(row).toBeVisible({ timeout: 15_000 })
|
||||
await page.screenshot({ path: join(SHOTS, 'all-products-desktop.png') })
|
||||
|
||||
await row.click()
|
||||
// Where the browser LANDS is the claim — not that a handler fired.
|
||||
await expect(page).toHaveURL(/\/agents$/, { timeout: 15_000 })
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('a pin made in All products survives a reload under a STALE token snapshot', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
|
||||
// The production condition: the identity token was minted an hour ago and carries a
|
||||
// preferences SNAPSHOT from then. Treating that snapshot as authoritative is what
|
||||
// silently threw away every pin made since — the pin reads as pinned, and is gone
|
||||
// after a reload.
|
||||
const snapshot = { pins: [{ id: 'models', group: '' }], pinGroups: [] }
|
||||
await boot(page, '/', {
|
||||
properties: { 'hanzo.preferences': JSON.stringify(snapshot) },
|
||||
issuedAt: Math.floor(Date.now() / 1000) - 3600,
|
||||
})
|
||||
|
||||
const openDirectory = async () => {
|
||||
await page.getByRole('button', { name: 'All products' }).first().click()
|
||||
// "…to sidebar" / "…from sidebar" are the directory's own labels — the home page's
|
||||
// Apps map carries a plain "Pin Agents", so the short form is ambiguous.
|
||||
await expect(page.getByRole('button', { name: /Agents (to|from) sidebar/ })).toBeVisible({ timeout: 15_000 })
|
||||
}
|
||||
|
||||
// The snapshot the token carries is what the sidebar starts from.
|
||||
await openDirectory()
|
||||
await page.getByRole('button', { name: 'Pin Agents to sidebar' }).click()
|
||||
await expect(page.getByRole('button', { name: 'Remove Agents from sidebar' })).toBeVisible()
|
||||
|
||||
// Only a write the SERVER acknowledged earns the stamp that out-ranks the snapshot.
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => localStorage.getItem('hanzo.console2.prefs.z.writtenAt')), { timeout: 10_000 })
|
||||
.not.toBeNull()
|
||||
|
||||
await page.reload({ waitUntil: 'domcontentloaded' })
|
||||
await expect(page.getByRole('button', { name: 'Ask Hanzo' })).toBeVisible({ timeout: 60_000 })
|
||||
|
||||
// Still pinned — the hour-old snapshot did not win. Asserted on what the user sees…
|
||||
await openDirectory()
|
||||
await expect(page.getByRole('button', { name: 'Remove Agents from sidebar' })).toBeVisible({ timeout: 15_000 })
|
||||
// …and on what was actually kept (models from the snapshot, agents from the write).
|
||||
const pins = await page.evaluate(() => {
|
||||
const raw = JSON.parse(localStorage.getItem('hanzo.console2.prefs.z') ?? '{}')
|
||||
return (raw.pins ?? []).map((p: { id: string }) => p.id)
|
||||
})
|
||||
expect(pins).toContain('agents')
|
||||
expect(pins).toContain('models')
|
||||
await ctx.close()
|
||||
})
|
||||
@@ -1,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)
|
||||
|
||||
|
||||
@@ -120,7 +120,7 @@ async function auditSurface(page: Page, slug: string, name: string, marker: RegE
|
||||
// Honest states that count as a truthful render for ANY surface (real content is added
|
||||
// per-surface). Kept in ONE place so every marker is consistent.
|
||||
const HONEST =
|
||||
'Add credits|Your session expired|Access required|Not enabled|Not available on this deployment|initializing|runtime|managed by Hanzo|Connected|Operator access|No .* yet|not connected|not configured|Sign in'
|
||||
'Add credits|Your session expired|Access required|Not enabled|Not available on this deployment|initializing|runtime|managed by Hanzo|Connected|SuperAdmin access|No .* yet|not connected|not configured|Sign in'
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════════
|
||||
// A. UNAUTHENTICATED fail-closed proof — ALWAYS runs (no credentials required).
|
||||
@@ -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',
|
||||
@@ -314,9 +314,9 @@ test.describe.serial('Billing / Settings / Usage / o11y render smoke (authentica
|
||||
test('Alerts renders (alerting rules or honest state)', async () => {
|
||||
await auditSurface(page, 'alerts', 'Alerts', new RegExp(`Alert|rule|notification|${HONEST}`, 'i'))
|
||||
})
|
||||
test('Fleet Observability renders (global-admin board or honest operator-access state)', async () => {
|
||||
// For a non-global-admin this is honestly `OperatorAccessRequired` — that IS a pass.
|
||||
await auditSurface(page, 'fleet-o11y', 'Fleet Observability', new RegExp(`Fleet Observability|Requests|Tokens|Latency|Top organizations|Operator access|${HONEST}`, 'i'))
|
||||
test('Fleet Observability renders (global-admin board or honest superadmin-access state)', async () => {
|
||||
// For a non-global-admin this is honestly `SuperAdminRequired` — that IS a pass.
|
||||
await auditSurface(page, 'fleet-o11y', 'Fleet Observability', new RegExp(`Fleet Observability|Requests|Tokens|Latency|Top organizations|SuperAdmin access|${HONEST}`, 'i'))
|
||||
})
|
||||
|
||||
// ── AGGREGATE — the dead-card audit. FLAGS every surface that showed a dead
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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) })
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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)}…`)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* The design gate — the invariants of the shell, asserted on COMPUTED STYLE and
|
||||
* GEOMETRY in a real browser.
|
||||
*
|
||||
* This spec is the deliverable, not the screenshots. Every rule below was a real
|
||||
* defect measured on the running console, and a rule that only lives in a review
|
||||
* comes back. A status code proves a server answered; this proves a human can
|
||||
* read the page.
|
||||
*
|
||||
* WHAT IT PINS
|
||||
* 1. No all-caps, anywhere — neither `text-transform: uppercase` nor a string
|
||||
* TYPED in caps. This is the hard rule and the whole reason the file exists.
|
||||
* 2. ONE type scale, ONE radius scale, ONE spacing ramp — asserted as
|
||||
* membership, so a new value cannot be introduced without deciding to.
|
||||
* 3. Every stacking layer resolves to the ladder in app/design/z.css, never a
|
||||
* literal. The console had drifted to 9999 / 100000 / 100001 / 100002.
|
||||
* 4. The overlays actually paint: opaque background, on-screen box. Two of
|
||||
* tonight's bugs were a control that rendered identically in both states and
|
||||
* a footer that ate clicks while returning 200.
|
||||
* 5. Contrast is computed from the colours that actually painted.
|
||||
* 6. The body never scrolls sideways, at 1440 or at 390.
|
||||
*
|
||||
* KNOWN EXEMPTIONS, each deliberate and narrow:
|
||||
* - Acronyms (`API`, `GPU`, `CIDR`, …) are not shouting; the allow-list is
|
||||
* explicit so a new one is a decision, not an accident.
|
||||
* - An avatar/brand MONOGRAM scales with its circle — it is a graphic, not app
|
||||
* text — so text-size membership skips it. Marking it takes BOTH a
|
||||
* `[data-monogram]` ancestor AND text of at most three characters, so a marker
|
||||
* placed around a whole distributed component (the only place it CAN go, since
|
||||
* @hanzo/ui paints the org mark itself) still cannot exempt that component's
|
||||
* labels — only its glyph.
|
||||
* - Tamagui's `circular` variant compiles to a 100000px radius; that is the
|
||||
* same concept as our pill token, so both count as "pill".
|
||||
* - Next's dev overlay injects its own chrome; specs run against the app root.
|
||||
* - An `aria-hidden` subtree is not content. A CLOSED drawer parks off screen by
|
||||
* design — the nav drawer at x = -320, the account drawer at x = 390 — which is
|
||||
* how a slide-over animates, not a clip.
|
||||
* - The ladder governs where OUR chrome sits. A library ordering its own
|
||||
* internals is its business: @hanzo/gui's Dialog puts its overlay at 1 and its
|
||||
* content at 2 INSIDE its portal, so the rule applies above 10. And the Gui
|
||||
* portal HOST itself is pinned to a hardcoded 105001 that no console config can
|
||||
* reach — REPORTED as a library finding, excluded here by its own class marker
|
||||
* rather than by raising the ceiling and quietly letting our literals back in.
|
||||
*/
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
import { primeSession } from './_session'
|
||||
|
||||
/** Genuine acronyms — capitalised because that is how they are spelled. */
|
||||
const ACRONYMS = new Set([
|
||||
'AI', 'ML', 'LLM', 'API', 'SDK', 'IDE', 'CLI', 'CDN', 'DNS', 'DNSSEC', 'TTL', 'CIDR', 'VPC',
|
||||
'IAM', 'KMS', 'HSM', 'MPC', 'SSO', 'MFA', 'TOTP', 'OIDC', 'PKCE', 'JWT', 'CSRF', 'SAFE',
|
||||
'CPU', 'GPU', 'GPUS', 'RAM', 'SSD', 'VRAM', 'PVC', 'S3', 'KV', 'SQL', 'URL', 'URI', 'JSON',
|
||||
'HTTP', 'HTTPS', 'POST', 'CNAME', 'AAAA', 'P95', 'P99', 'MRR', 'SKU', 'OSS', 'CRM', 'ERP',
|
||||
'CMS', 'RAG', 'OTEL', 'OTLP', 'RED', 'ZIP', 'PDF', 'CSV', 'ID', 'IDS', 'UI', 'UX', 'WCAG',
|
||||
])
|
||||
|
||||
/** The ONE type scale (gui.config.ts FONT_SIZE + app/design/typography.css). */
|
||||
const TYPE = new Set([11, 13, 14, 15, 17, 21, 26, 32, 40, 48])
|
||||
/** The ONE radius scale: control · input/row · panel · pill. */
|
||||
const RADIUS = new Set([0, 6, 8, 12, 9999, 100000])
|
||||
/** The ONE spacing ramp (gui.config.ts STEP). */
|
||||
const SPACE = new Set([0, 1, 2, 3, 4, 6, 8, 10, 12, 14, 16, 20, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 208])
|
||||
/** The ladder in app/design/z.css — the only stacking values that may paint. */
|
||||
const Z = new Set([10, 200, 300, 400, 500, 600, 700, 800])
|
||||
|
||||
type Audit = {
|
||||
capsComputed: string[]
|
||||
capsTyped: string[]
|
||||
offType: { size: number; text: string }[]
|
||||
offRadius: { radius: number; cls: string }[]
|
||||
offSpace: { pad: number; cls: string }[]
|
||||
offZ: { z: number; cls: string }[]
|
||||
lowContrast: { ratio: number; text: string; fg: string; bg: string }[]
|
||||
hScroll: boolean
|
||||
bodyBg: string
|
||||
}
|
||||
|
||||
/** Runs entirely in the page: reads what PAINTED, never what the source says. */
|
||||
async function audit(page: Page, acronyms: string[]): Promise<Audit> {
|
||||
return page.evaluate((acr) => {
|
||||
const ACR = new Set(acr)
|
||||
const out: Audit = {
|
||||
capsComputed: [], capsTyped: [], offType: [], offRadius: [], offSpace: [],
|
||||
offZ: [], lowContrast: [], hScroll: false, bodyBg: '',
|
||||
}
|
||||
const TYPE = new Set([11, 13, 14, 15, 17, 21, 26, 32, 40, 48])
|
||||
const RADIUS = new Set([0, 6, 8, 12, 9999, 100000])
|
||||
const SPACE = new Set([0, 1, 2, 3, 4, 6, 8, 10, 12, 14, 16, 20, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 208])
|
||||
const Z = new Set([10, 200, 300, 400, 500, 600, 700, 800])
|
||||
|
||||
const cls = (el: Element) => (typeof el.className === 'string' ? el.className.slice(0, 90) : el.tagName)
|
||||
const px = (v: string) => Math.round(parseFloat(v) || 0)
|
||||
const rendered = (el: Element) => !!(el as HTMLElement).offsetParent || el === document.body
|
||||
|
||||
// sRGB relative luminance → WCAG contrast ratio.
|
||||
const lum = (c: string) => {
|
||||
const m = c.match(/[\d.]+/g)
|
||||
if (!m || m.length < 3) return null
|
||||
if (m.length > 3 && parseFloat(m[3]) === 0) return null // fully transparent
|
||||
const [r, g, b] = m.slice(0, 3).map((n) => {
|
||||
const s = parseFloat(n) / 255
|
||||
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4
|
||||
})
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
||||
}
|
||||
/** The nearest ancestor that actually paints a background. */
|
||||
const bgOf = (el: Element): string => {
|
||||
for (let e: Element | null = el; e; e = e.parentElement) {
|
||||
const b = getComputedStyle(e).backgroundColor
|
||||
if (b && !/rgba\(0, 0, 0, 0\)|transparent/.test(b)) return b
|
||||
}
|
||||
return getComputedStyle(document.body).backgroundColor
|
||||
}
|
||||
|
||||
for (const el of Array.from(document.querySelectorAll('body *'))) {
|
||||
if (el.closest('nextjs-portal, [data-nextjs-toast], [aria-hidden="true"]')) continue
|
||||
const cs = getComputedStyle(el)
|
||||
if (cs.display === 'none' || cs.visibility === 'hidden') continue
|
||||
const leaf = el.children.length === 0
|
||||
const text = (el.textContent || '').trim()
|
||||
|
||||
// 1 · caps
|
||||
if (cs.textTransform === 'uppercase' && leaf && text) out.capsComputed.push(text.slice(0, 48))
|
||||
|
||||
// 2 · scales — only on nodes that actually paint
|
||||
if (rendered(el)) {
|
||||
const isMonogram = text.length <= 3 && !!el.closest('[data-monogram]')
|
||||
if (leaf && text && !isMonogram && !el.closest('svg')) {
|
||||
const s = px(cs.fontSize)
|
||||
if (!TYPE.has(s)) out.offType.push({ size: s, text: text.slice(0, 40) })
|
||||
// 5 · contrast, on the colours that painted
|
||||
const f = lum(cs.color)
|
||||
const b = lum(bgOf(el))
|
||||
if (f !== null && b !== null) {
|
||||
const ratio = (Math.max(f, b) + 0.05) / (Math.min(f, b) + 0.05)
|
||||
const large = s >= 21 || (s >= 17 && Number(cs.fontWeight) >= 700)
|
||||
if (ratio < (large ? 3 : 4.5)) {
|
||||
out.lowContrast.push({ ratio: Math.round(ratio * 100) / 100, text: text.slice(0, 32), fg: cs.color, bg: bgOf(el) })
|
||||
}
|
||||
}
|
||||
}
|
||||
const r = px(cs.borderTopLeftRadius)
|
||||
if (r && !RADIUS.has(r)) out.offRadius.push({ radius: r, cls: cls(el) })
|
||||
for (const p of [cs.paddingLeft, cs.paddingTop]) {
|
||||
const v = px(p)
|
||||
if (v && !SPACE.has(v)) out.offSpace.push({ pad: v, cls: cls(el) })
|
||||
}
|
||||
}
|
||||
|
||||
// 3 · stacking — only where a layer actually paints, only above a library's
|
||||
// own local ordering, and never the Gui portal host (see the header).
|
||||
const guiPortalHost = typeof el.className === 'string' && el.className.includes('_dsp_contents')
|
||||
if (cs.zIndex !== 'auto' && !guiPortalHost) {
|
||||
const z = Number(cs.zIndex)
|
||||
if (z > 10 && !Z.has(z)) out.offZ.push({ z, cls: cls(el) })
|
||||
}
|
||||
}
|
||||
|
||||
// 1b · caps TYPED into a string
|
||||
const w = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT)
|
||||
let n: Node | null
|
||||
while ((n = w.nextNode())) {
|
||||
if ((n.parentElement as HTMLElement | null)?.closest('nextjs-portal, [data-monogram]')) continue
|
||||
const s = (n.nodeValue || '').trim()
|
||||
if (s.length < 4 || !/^[A-Z][A-Z0-9 &/·—-]+$/.test(s) || !/[A-Z]{4,}/.test(s)) continue
|
||||
if (!s.split(/[^A-Z0-9]+/).every((p) => !p || ACR.has(p))) out.capsTyped.push(s.slice(0, 48))
|
||||
}
|
||||
|
||||
out.hScroll = document.documentElement.scrollWidth > document.documentElement.clientWidth
|
||||
out.bodyBg = getComputedStyle(document.body).backgroundColor
|
||||
return out
|
||||
}, acronyms)
|
||||
}
|
||||
|
||||
/** Land on a dashboard route with a primed session and let the SPA settle. */
|
||||
async function open(page: Page, path: string): Promise<void> {
|
||||
await page.route('**/v1/**', (r) =>
|
||||
r.fulfill({ status: 200, contentType: 'application/json', body: '{"data":[],"items":[],"status":"ok"}' }))
|
||||
await primeSession(page)
|
||||
await page.goto(path, { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForSelector('nav[aria-label="Products"]', { timeout: 45_000 }).catch(() => {})
|
||||
await page.waitForTimeout(6000)
|
||||
}
|
||||
|
||||
const dedupe = <T,>(xs: T[]): T[] => Array.from(new Set(xs.map((x) => JSON.stringify(x)))).map((s) => JSON.parse(s) as T)
|
||||
|
||||
// The shell's real states. Level 1 is the rail + home; drilled is the second-level
|
||||
// nav; settings is the panel-of-rows surface; the palette is the top overlay.
|
||||
const STATES: [name: string, path: string][] = [
|
||||
['level 1', '/'],
|
||||
['drilled', '/agents'],
|
||||
['settings panels', '/agents/settings'],
|
||||
]
|
||||
|
||||
for (const [name, path] of STATES) {
|
||||
test(`${name} — no caps, one scale, one ladder`, async ({ page }) => {
|
||||
await open(page, path)
|
||||
const a = await audit(page, [...ACRONYMS])
|
||||
|
||||
// THE HARD RULE. No exceptions, no text-transform, no typed caps.
|
||||
expect(dedupe(a.capsComputed), 'text-transform: uppercase').toEqual([])
|
||||
expect(dedupe(a.capsTyped), 'strings typed in caps').toEqual([])
|
||||
|
||||
// ONE of each scale.
|
||||
expect(dedupe(a.offType), 'font-size off the type scale').toEqual([])
|
||||
expect(dedupe(a.offRadius), 'border-radius off the radius scale').toEqual([])
|
||||
expect(dedupe(a.offSpace), 'padding off the 4px ramp').toEqual([])
|
||||
expect(dedupe(a.offZ), 'z-index not from the --z-* ladder').toEqual([])
|
||||
|
||||
// Readable, on the black canvas the brief asks for.
|
||||
expect(a.bodyBg).toBe('rgb(0, 0, 0)')
|
||||
expect(dedupe(a.lowContrast), 'text below WCAG AA against its painted background').toEqual([])
|
||||
expect(a.hScroll, 'the body must never scroll sideways').toBe(false)
|
||||
})
|
||||
}
|
||||
|
||||
test('the command palette paints, is on screen, and shouts at nobody', async ({ page }) => {
|
||||
await open(page, '/')
|
||||
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+k' : 'Control+k')
|
||||
await page.waitForTimeout(1200)
|
||||
|
||||
// It must actually PAINT — a transparent, unstacked overlay is the library
|
||||
// failure mode this repo has already been bitten by twice.
|
||||
const box = await page.evaluate(() => {
|
||||
// Several dialogs live in the DOM at rest — the nav drawer is parked OFF
|
||||
// screen at x = -320 — so pick the one that is actually on screen.
|
||||
const el = Array.from(document.querySelectorAll<HTMLElement>('[role="dialog"]')).find((d) => {
|
||||
const r = d.getBoundingClientRect()
|
||||
return r.width > 240 && r.height > 40 && r.left >= 0 && r.right <= innerWidth + 1 &&
|
||||
getComputedStyle(d).visibility !== 'hidden' && getComputedStyle(d).display !== 'none'
|
||||
}) ?? null
|
||||
if (!el) return null
|
||||
const cs = getComputedStyle(el)
|
||||
const r = el.getBoundingClientRect()
|
||||
const bg = (() => {
|
||||
for (let e: Element | null = el; e; e = e.parentElement) {
|
||||
const b = getComputedStyle(e).backgroundColor
|
||||
if (b && !/rgba\(0, 0, 0, 0\)|transparent/.test(b)) return b
|
||||
}
|
||||
return 'rgba(0, 0, 0, 0)'
|
||||
})()
|
||||
return { bg, z: cs.zIndex, x: r.x, y: r.y, w: r.width, h: r.height, vw: innerWidth, vh: innerHeight }
|
||||
})
|
||||
expect(box, 'the palette did not open').not.toBeNull()
|
||||
expect(box!.bg, 'the palette rendered transparent').not.toMatch(/rgba\(0, 0, 0, 0\)|transparent/)
|
||||
expect(box!.w).toBeGreaterThan(240)
|
||||
expect(box!.x).toBeGreaterThanOrEqual(0)
|
||||
expect(box!.y).toBeGreaterThanOrEqual(0)
|
||||
expect(box!.x + box!.w).toBeLessThanOrEqual(box!.vw + 1)
|
||||
|
||||
const a = await audit(page, [...ACRONYMS])
|
||||
expect(dedupe(a.capsComputed)).toEqual([])
|
||||
expect(dedupe(a.capsTyped)).toEqual([])
|
||||
expect(dedupe(a.offZ), 'the palette must sit on the ladder').toEqual([])
|
||||
})
|
||||
|
||||
test('the rail is keyboard-reachable and a collapsed section is out of the tab order', async ({ page }) => {
|
||||
await open(page, '/')
|
||||
const reach = await page.evaluate(() => {
|
||||
const nav = document.querySelector('nav[aria-label="Products"]') as HTMLElement | null
|
||||
if (!nav) return null
|
||||
const focusable = Array.from(nav.querySelectorAll<HTMLElement>('button, a[href], [tabindex]:not([tabindex="-1"])'))
|
||||
.filter((el) => el.offsetParent !== null)
|
||||
// Rows inside a collapsed accordion are `inert` — present, but not tabbable.
|
||||
const inertRows = Array.from(nav.querySelectorAll('.hz-acc[data-open="false"] button')).length
|
||||
const inertTabbable = Array.from(nav.querySelectorAll<HTMLElement>('.hz-acc[data-open="false"] button'))
|
||||
.filter((el) => el.offsetParent !== null && !el.closest('[inert]')).length
|
||||
// Every reachable row must be inside the viewport — a control tab lands on
|
||||
// but cannot be seen is the same defect as one that cannot be reached.
|
||||
const offscreen = focusable.filter((el) => {
|
||||
const r = el.getBoundingClientRect()
|
||||
return r.width > 0 && (r.right < 0 || r.left > innerWidth || r.bottom < 0)
|
||||
}).length
|
||||
return { count: focusable.length, offscreen, inertRows, inertTabbable }
|
||||
})
|
||||
expect(reach, 'no rail found').not.toBeNull()
|
||||
expect(reach!.count, 'the rail has no keyboard-reachable rows').toBeGreaterThan(3)
|
||||
expect(reach!.offscreen, 'a rail row is focusable but painted off screen').toBe(0)
|
||||
expect(reach!.inertTabbable, 'a collapsed section leaked rows into the tab order').toBe(0)
|
||||
})
|
||||
|
||||
test('nothing scrolls sideways on a phone', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await open(page, '/')
|
||||
const a = await audit(page, [...ACRONYMS])
|
||||
expect(a.hScroll).toBe(false)
|
||||
expect(dedupe(a.capsComputed)).toEqual([])
|
||||
expect(dedupe(a.capsTyped)).toEqual([])
|
||||
// Painted past the right edge is only a defect when nothing can scroll to it.
|
||||
// Wide content (a DataTable, a code block) is REQUIRED to scroll inside its own
|
||||
// container, and DataTable already does — that is correct, not a clip.
|
||||
const overflow = await page.evaluate(() => {
|
||||
const scrollable = (el: Element) => {
|
||||
for (let e: Element | null = el.parentElement; e; e = e.parentElement) {
|
||||
const ox = getComputedStyle(e).overflowX
|
||||
if (ox === 'auto' || ox === 'scroll') return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
return Array.from(document.querySelectorAll('body *'))
|
||||
.filter((el) => {
|
||||
if (el.closest('[aria-hidden="true"]')) return false
|
||||
const r = el.getBoundingClientRect()
|
||||
return r.width > 0 && r.right > innerWidth + 1 && !scrollable(el)
|
||||
})
|
||||
.slice(0, 6)
|
||||
.map((el) => (el.textContent || el.tagName).trim().slice(0, 44))
|
||||
})
|
||||
expect(overflow, 'clipped past the right edge with nothing to scroll it').toEqual([])
|
||||
})
|
||||
@@ -0,0 +1,397 @@
|
||||
/**
|
||||
* e2e: find and do — pin, sort, filter, search, and the keyboard that drives them.
|
||||
*
|
||||
* Every claim here is measured in a real browser on COMPUTED STYLE and GEOMETRY,
|
||||
* because the failures this lane exists to prevent are invisible to a status code:
|
||||
* a pin that reports success and is gone after a reload, an affordance that renders
|
||||
* at zero opacity forever, a control painted off its own row.
|
||||
*
|
||||
* Local fixture server + mocked network; `primeSession` supplies the IAM-PKCE
|
||||
* identity. The preference PATCH is mocked to echo nothing, which is the HONEST
|
||||
* worst case — it is exactly the condition (an account that never reports the key
|
||||
* back) under which pins used to be lost.
|
||||
*
|
||||
* Run: BASE_URL=http://localhost:4300 npx playwright test find-and-do
|
||||
*/
|
||||
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:4300'
|
||||
|
||||
requireFixtureServer()
|
||||
const SHOTS = join(process.cwd(), 'e2e-shots')
|
||||
|
||||
const API_RE = /\/(v1|cloud|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations)(\/|$|\?)/
|
||||
|
||||
/** A small real-shaped model catalog, enough for the Models + Marketplace lists. */
|
||||
const MODELS = {
|
||||
object: 'list',
|
||||
data: [
|
||||
{ id: 'zen5', owned_by: 'hanzo' },
|
||||
{ id: 'zen5-mini', owned_by: 'hanzo' },
|
||||
{ id: 'anthropic/claude-opus-4.6', owned_by: 'anthropic' },
|
||||
{ id: 'qwen3.5-397b', owned_by: 'hanzo' },
|
||||
],
|
||||
}
|
||||
|
||||
async function mock(route: Route) {
|
||||
const req = route.request()
|
||||
if (req.resourceType() === 'document') return route.continue()
|
||||
const url = new URL(req.url())
|
||||
if (url.pathname === '/v1/models') {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MODELS) })
|
||||
}
|
||||
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`. `ready` is what proves the signed-in shell mounted —
|
||||
* it defaults to the rail's own pin affordances, which exist only on the DESKTOP
|
||||
* rail (below lg the nav is a drawer), so a mobile viewport passes its own signal.
|
||||
*/
|
||||
async function boot(page: Page, path = '/', ready?: () => Promise<void>) {
|
||||
await page.route('**/*', mock)
|
||||
await primeSession(page)
|
||||
await page.goto(`${BASE_URL}${path}`, { waitUntil: 'domcontentloaded' })
|
||||
if (ready) return ready()
|
||||
await expect(page.getByRole('button', { name: /^(Pin|Unpin) / }).first()).toBeVisible({ timeout: 30_000 })
|
||||
}
|
||||
|
||||
/**
|
||||
* The palette, opened by the real ⌘K path rather than by clicking chrome.
|
||||
*
|
||||
* ⌘K is a TOGGLE bound on `window`, so a press that lands mid-navigation (before the
|
||||
* destination route has mounted its listener) is simply lost. Retrying the real
|
||||
* gesture is honest — it still proves the shortcut works — where a single press would
|
||||
* only prove the test's timing.
|
||||
*/
|
||||
/** The one mounted palette dialog (the one that owns the search input). */
|
||||
const palette = (page: Page) =>
|
||||
page
|
||||
.locator('[role="dialog"]')
|
||||
.filter({ has: page.getByPlaceholder('Search apps and commands…') })
|
||||
.last()
|
||||
|
||||
async function openPalette(page: Page) {
|
||||
const input = page.getByPlaceholder('Search apps and commands…')
|
||||
await expect(async () => {
|
||||
await page.keyboard.press('ControlOrMeta+k')
|
||||
await expect(input).toBeVisible({ timeout: 2_000 })
|
||||
}).toPass({ timeout: 20_000 })
|
||||
}
|
||||
|
||||
test.beforeAll(() => mkdirSync(SHOTS, { recursive: true }))
|
||||
|
||||
test('a pin survives a reload — the account is silent, the cache is not', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
await boot(page)
|
||||
|
||||
const pin = page.getByRole('button', { name: 'Pin Agents' }).first()
|
||||
await expect(pin).toBeVisible()
|
||||
await pin.click()
|
||||
|
||||
// It reads as pinned immediately…
|
||||
await expect(page.getByRole('button', { name: 'Unpin Agents' }).first()).toBeVisible()
|
||||
|
||||
// …and is STILL pinned after a full reload. Before this lane's fix the account's
|
||||
// (silent) view overwrote the cache here and the pin was gone.
|
||||
await page.reload({ waitUntil: 'domcontentloaded' })
|
||||
await expect(page.getByRole('button', { name: 'Unpin Agents' }).first()).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
const stored = await page.evaluate(() =>
|
||||
JSON.parse(localStorage.getItem('hanzo.console2.prefs.z') ?? '{}'),
|
||||
)
|
||||
expect(stored.pins.map((p: { id: string }) => p.id)).toContain('agents')
|
||||
|
||||
// Unpinning is just as durable, so the state is genuinely the user's, not sticky.
|
||||
await page.getByRole('button', { name: 'Unpin Agents' }).first().click()
|
||||
await page.reload({ waitUntil: 'domcontentloaded' })
|
||||
await expect(page.getByRole('button', { name: 'Pin Agents' }).first()).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('typing a product name opens that product — pins never outrank what you typed', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
await boot(page)
|
||||
|
||||
// Models and Chat are pinned by default. Before this was split, `pinnedFirst` was
|
||||
// applied to the RANKED list too, so a barely-matching pinned product outranked an
|
||||
// exact name match and "billing" + ↵ opened /models. Enter is the honest probe:
|
||||
// it asserts on where the user actually lands, not on DOM order.
|
||||
for (const [query, path] of [
|
||||
['agents', '/agents'],
|
||||
['billing', '/billing'],
|
||||
['vector', '/vector'],
|
||||
]) {
|
||||
await openPalette(page)
|
||||
await page.getByPlaceholder('Search apps and commands…').fill(query)
|
||||
await expect(page.locator('#cmdk-active').first()).toBeVisible()
|
||||
await page.keyboard.press('Enter')
|
||||
await expect
|
||||
.poll(() => new URL(page.url()).pathname, { timeout: 15_000 })
|
||||
.toBe(path)
|
||||
}
|
||||
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('the default view leads with pins, and a result can be pinned without leaving', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
await boot(page)
|
||||
|
||||
await openPalette(page)
|
||||
|
||||
// A product query (not one that also matches a verb like "ask"/"apps"), so the
|
||||
// selection lands on a destination — actions rank first and carry no pin.
|
||||
await page.getByPlaceholder('Search apps and commands…').fill('agents')
|
||||
await expect(page.locator('#cmdk-active')).toBeVisible()
|
||||
|
||||
// The pin on the SELECTED result: a real control, at the row's RIGHT edge.
|
||||
const activeRow = page.locator('#cmdk-active')
|
||||
const pinBtn = activeRow.getByRole('button', { name: /^(Pin|Unpin) / })
|
||||
await expect(pinBtn).toHaveCount(1)
|
||||
|
||||
const rowBox = await activeRow.boundingBox()
|
||||
const pinBox = await pinBtn.boundingBox()
|
||||
expect(rowBox).not.toBeNull()
|
||||
expect(pinBox).not.toBeNull()
|
||||
// Right edge: the pin sits in the last quarter of its row, and inside it.
|
||||
expect(pinBox!.x).toBeGreaterThan(rowBox!.x + rowBox!.width * 0.75)
|
||||
expect(pinBox!.x + pinBox!.width).toBeLessThanOrEqual(rowBox!.x + rowBox!.width + 1)
|
||||
// Vertically centred on its own row, not floating above or below it.
|
||||
const rowMid = rowBox!.y + rowBox!.height / 2
|
||||
const pinMid = pinBox!.y + pinBox!.height / 2
|
||||
expect(Math.abs(rowMid - pinMid)).toBeLessThan(4)
|
||||
// A real hit target, not a 2px sliver.
|
||||
expect(pinBox!.width).toBeGreaterThanOrEqual(20)
|
||||
expect(pinBox!.height).toBeGreaterThanOrEqual(20)
|
||||
|
||||
const label = (await pinBtn.getAttribute('aria-label')) ?? ''
|
||||
const product = label.replace(/^(Pin|Unpin) /, '')
|
||||
|
||||
// ⌥↵ pins the selection and KEEPS the palette open — curating is repeatable.
|
||||
await page.keyboard.press('Alt+Enter')
|
||||
await expect(page.getByPlaceholder('Search apps and commands…')).toBeVisible()
|
||||
await expect(activeRow.getByRole('button', { name: `Unpin ${product}` })).toHaveCount(1)
|
||||
await expect(activeRow.getByRole('button', { name: /^(Pin|Unpin) / })).toHaveAttribute('aria-pressed', 'true')
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'palette-pin.png') })
|
||||
|
||||
// Clear the query: the default view collects the pins into one leading section,
|
||||
// under the same word the sidebar uses.
|
||||
await page.getByPlaceholder('Search apps and commands…').fill('')
|
||||
|
||||
// Scoped to the PALETTE. The sidebar has its own "Pinned" heading, so an unscoped
|
||||
// text match here would pass whether or not the palette groups anything at all —
|
||||
// and an assertion that can pass for the wrong reason is worse than no assertion.
|
||||
//
|
||||
// Read via textContent, not a text locator: the section labels are uppercased in
|
||||
// CSS, so `getByText('Pinned')` matches the rendered "PINNED" inconsistently.
|
||||
const scan = await page.evaluate(() => {
|
||||
const inputs = Array.from(document.querySelectorAll('input')).filter((i) =>
|
||||
(i.getAttribute('placeholder') ?? '').startsWith('Search apps'),
|
||||
)
|
||||
const host = inputs[0]?.closest('[role="dialog"]')
|
||||
if (!host) return null
|
||||
const text = Array.from(host.querySelectorAll('*'))
|
||||
.filter((e) => e.children.length === 0)
|
||||
.map((e) => (e.textContent ?? '').trim())
|
||||
.filter(Boolean)
|
||||
return { palettes: inputs.length, hasPinnedSection: text.includes('Pinned') }
|
||||
})
|
||||
expect(scan).not.toBeNull()
|
||||
// Exactly one palette is mounted, so nothing read here can be a stale copy.
|
||||
expect(scan!.palettes).toBe(1)
|
||||
// The pins are collected under their own heading rather than scattered through
|
||||
// the categories.
|
||||
expect(scan!.hasPinnedSection).toBe(true)
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'palette-pinned-first.png') })
|
||||
|
||||
// And the selection starts on a PINNED product, so ↵ on the untouched default
|
||||
// view goes somewhere the user chose. `models` is pinned out of the box.
|
||||
await page.keyboard.press('Enter')
|
||||
await expect.poll(() => new URL(page.url()).pathname, { timeout: 15_000 }).toBe('/models')
|
||||
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('the row pin is quiet until reached, and lit while pinned', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
await boot(page)
|
||||
await openPalette(page)
|
||||
await page.getByPlaceholder('Search apps and commands…').fill('agents')
|
||||
await expect(page.locator('#cmdk-active')).toBeVisible()
|
||||
|
||||
// A result that is NOT the keyboard selection: its pin is painted at zero opacity
|
||||
// — present in the DOM and reachable, but not drawn.
|
||||
const rows = page.locator('.hz-row-pin')
|
||||
const count = await rows.count()
|
||||
let restingOpacity: number | null = null
|
||||
for (let i = 0; i < count; i++) {
|
||||
const row = rows.nth(i)
|
||||
if ((await row.getAttribute('id')) === 'cmdk-active') continue
|
||||
const quiet = row.locator('.hz-pin')
|
||||
if ((await quiet.count()) === 0) continue
|
||||
restingOpacity = await quiet.first().evaluate((el) => Number(getComputedStyle(el).opacity))
|
||||
// Hovering the ROW reveals it — the affordance appears where the eye already is.
|
||||
await row.hover()
|
||||
await expect
|
||||
.poll(async () => quiet.first().evaluate((el) => Number(getComputedStyle(el).opacity)))
|
||||
.toBeGreaterThan(0.9)
|
||||
break
|
||||
}
|
||||
expect(restingOpacity).not.toBeNull()
|
||||
expect(restingOpacity).toBeLessThan(0.05)
|
||||
|
||||
// The SELECTED row's pin is drawn without any hover — the keyboard user is never
|
||||
// shown an empty row where the mouse user is shown a control.
|
||||
const activePin = page.locator('#cmdk-active').locator('[aria-label^="Pin "], [aria-label^="Unpin "]').first()
|
||||
const activeOpacity = await activePin.evaluate((el) => Number(getComputedStyle(el).opacity))
|
||||
expect(activeOpacity).toBeGreaterThan(0.3)
|
||||
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('⌘K, arrows, Enter and Escape drive the whole surface', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
await boot(page)
|
||||
|
||||
await openPalette(page)
|
||||
const first = await page.locator('#cmdk-active').getAttribute('aria-label').catch(() => null)
|
||||
const firstText = await page.locator('#cmdk-active').innerText()
|
||||
|
||||
// ↓ moves the selection to a different row (the selection is a single element, so
|
||||
// "moved" is provable by its text changing).
|
||||
await page.keyboard.press('ArrowDown')
|
||||
await expect.poll(async () => page.locator('#cmdk-active').innerText()).not.toBe(firstText)
|
||||
|
||||
// ↑ returns to it.
|
||||
await page.keyboard.press('ArrowUp')
|
||||
await expect.poll(async () => page.locator('#cmdk-active').innerText()).toBe(firstText)
|
||||
expect(first === null || typeof first === 'string').toBe(true)
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'palette-keyboard.png') })
|
||||
|
||||
// Esc closes.
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(page.getByPlaceholder('Search apps and commands…')).toHaveCount(0)
|
||||
|
||||
// ↵ on a selection navigates — the palette is a way to ACT, not just to look.
|
||||
await openPalette(page)
|
||||
await page.getByPlaceholder('Search apps and commands…').fill('marketplace')
|
||||
await expect(page.locator('#cmdk-active')).toBeVisible()
|
||||
await page.keyboard.press('Enter')
|
||||
await expect.poll(() => page.url(), { timeout: 15_000 }).toContain('/marketplace')
|
||||
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('a list keeps the narrowing you gave it, and Reset gives it back', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
const search = page.getByPlaceholder('Search listings, providers, descriptions…')
|
||||
// Drilled into a product the rail shows that product's sub-nav, not the catalog
|
||||
// with its pins — so the surface under test is its own readiness signal.
|
||||
await boot(page, '/marketplace', async () => {
|
||||
await expect(search).toBeVisible({ timeout: 30_000 })
|
||||
})
|
||||
|
||||
// Nothing is narrowed yet, so Reset is not there. A control that is always present
|
||||
// but usually inert teaches a user to ignore it.
|
||||
await expect(page.getByRole('button', { name: 'Reset filters' })).toHaveCount(0)
|
||||
|
||||
await search.fill('zen')
|
||||
const available = page.getByRole('button', { name: 'Available now' })
|
||||
await available.click()
|
||||
await expect(available).toHaveAttribute('aria-pressed', 'true')
|
||||
await expect(page.getByRole('button', { name: 'Reset filters' })).toBeVisible()
|
||||
await page.screenshot({ path: join(SHOTS, 'list-narrowed.png') })
|
||||
|
||||
// Navigate away and back: the view is exactly as it was left. This is the whole
|
||||
// point of persisting it — a list you must re-narrow on every visit is a list you
|
||||
// stop narrowing.
|
||||
await page.goto(`${BASE_URL}/models`, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page.getByPlaceholder('Search models across every family…')).toBeVisible({ timeout: 30_000 })
|
||||
await page.goto(`${BASE_URL}/marketplace`, { waitUntil: 'domcontentloaded' })
|
||||
|
||||
const back = page.getByPlaceholder('Search listings, providers, descriptions…')
|
||||
await expect(back).toBeVisible({ timeout: 30_000 })
|
||||
await expect(back).toHaveValue('zen')
|
||||
await expect(page.getByRole('button', { name: 'Available now' })).toHaveAttribute('aria-pressed', 'true')
|
||||
|
||||
// …and it survives a full reload, like the pins.
|
||||
await page.reload({ waitUntil: 'domcontentloaded' })
|
||||
await expect(page.getByPlaceholder('Search listings, providers, descriptions…')).toHaveValue('zen', {
|
||||
timeout: 30_000,
|
||||
})
|
||||
|
||||
// Reset clears every narrowing at once and takes its own control away with it.
|
||||
await page.getByRole('button', { name: 'Reset filters' }).click()
|
||||
await expect(page.getByPlaceholder('Search listings, providers, descriptions…')).toHaveValue('')
|
||||
await expect(page.getByRole('button', { name: 'Available now' })).toHaveAttribute('aria-pressed', 'false')
|
||||
await expect(page.getByRole('button', { name: 'Reset filters' })).toHaveCount(0)
|
||||
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('the list bar reads on the black canvas and never scrolls the page sideways', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 390, height: 844 } })
|
||||
const page = await ctx.newPage()
|
||||
const search = page.getByPlaceholder('Search models across every family…')
|
||||
// At 390px the rail is a drawer, so the list bar itself is the readiness signal.
|
||||
await boot(page, '/models', async () => {
|
||||
await expect(search).toBeVisible({ timeout: 30_000 })
|
||||
})
|
||||
await search.fill('zen')
|
||||
|
||||
// The placeholder/typed text must actually be legible against what is behind it.
|
||||
const contrast = await search.evaluate((el) => {
|
||||
const lum = (c: string) => {
|
||||
const [r, g, b] = (c.match(/[\d.]+/g) ?? ['0', '0', '0']).slice(0, 3).map(Number)
|
||||
const f = (v: number) => {
|
||||
const s = v / 255
|
||||
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4
|
||||
}
|
||||
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b)
|
||||
}
|
||||
// Walk up for the first non-transparent background actually painted behind it.
|
||||
let node: HTMLElement | null = el as HTMLElement
|
||||
let bg = 'rgb(0, 0, 0)'
|
||||
while (node) {
|
||||
const c = getComputedStyle(node).backgroundColor
|
||||
if (c && !c.includes('rgba(0, 0, 0, 0)')) {
|
||||
bg = c
|
||||
break
|
||||
}
|
||||
node = node.parentElement
|
||||
}
|
||||
const fg = getComputedStyle(el as HTMLElement).color
|
||||
const a = lum(fg)
|
||||
const b = lum(bg)
|
||||
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05)
|
||||
})
|
||||
expect(contrast).toBeGreaterThanOrEqual(4.5)
|
||||
|
||||
// The body must never scroll sideways at 390px.
|
||||
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
|
||||
expect(overflow).toBeLessThanOrEqual(0)
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'list-bar-mobile.png') })
|
||||
await ctx.close()
|
||||
})
|
||||
@@ -183,7 +183,7 @@ test.describe('Insights renders on admin.hanzo.ai for the SuperAdmin (authentica
|
||||
{ id: 'service-map', label: 'Service Map', expect: /Service Map|Rate|Errors|Duration|p99|dependency|Observability|no telemetry|not enabled|initializing/i },
|
||||
{ id: 'logs', label: 'Logs', expect: /Logs|Application logs|Request activity|Severity|Message|no application logs|Observability|initializing/i },
|
||||
{ id: 'o11y', label: 'Traces', expect: /Traces|Trace|Latency|Tokens|Cost|Observability|No traces|initializing|not enabled/i },
|
||||
{ id: 'fleet-o11y', label: 'Fleet Observability', expect: /Fleet Observability|Requests|Tokens|Latency|Top organizations|operator access|not authorized/i },
|
||||
{ id: 'fleet-o11y', label: 'Fleet Observability', expect: /Fleet Observability|Requests|Tokens|Latency|Top organizations|superadmin access|not authorized/i },
|
||||
]
|
||||
for (const m of modules) {
|
||||
await page.goto(`${surface}/${m.id}`, { waitUntil: 'domcontentloaded' })
|
||||
|
||||
@@ -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('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* e2e: LEADING — no rendered text may have a line-height smaller than its font-size.
|
||||
*
|
||||
* This exists because of a trap that is invisible to every unit test and to every
|
||||
* type-check: react-native-web's style compiler appends `px` to a numeric style value
|
||||
* unless the property is on its unitless allow-list — and `lineHeight` is NOT on it
|
||||
* (`react-native-web/dist/exports/StyleSheet/compiler/unitlessNumbers.js`). React DOM's
|
||||
* own allow-list DOES include `lineHeight`, so `style={{ lineHeight: 1.12 }}` is a
|
||||
* correct, idiomatic RATIO in plain React and silently becomes the absurd
|
||||
* `line-height: 1.12px` under @hanzo/gui (Tamagui/RNW).
|
||||
*
|
||||
* The failure mode is not subtle once rendered: the line box collapses to ~1px, the
|
||||
* heading's descenders fall into whatever sits beneath it, and the element above is
|
||||
* clipped. It shipped on every product landing (the guide PitchHero headline).
|
||||
*
|
||||
* So this asserts the INVARIANT rather than the one call site — every visible text node
|
||||
* on the surface must have `line-height >= font-size` — which catches the next numeric
|
||||
* lineHeight anyone writes, anywhere, without them having to know about RNW's list.
|
||||
*
|
||||
* Run: BASE_URL=http://localhost:4000 npx playwright test leading
|
||||
*/
|
||||
import { test, expect, type Page, type Route } from '@playwright/test'
|
||||
import { requireFixtureServer } from './_fixture'
|
||||
import { primeSession } from './_session'
|
||||
|
||||
requireFixtureServer()
|
||||
|
||||
const API_RE = /\/(v1|cloud|ai|auth|billing|commerce|telemetry|vm|superbase|admin|paas|integrations)(\/|$|\?)/
|
||||
|
||||
/** Everything the shell asks for answers an empty-ok envelope — this spec measures TYPE, not data. */
|
||||
async function stub(route: Route) {
|
||||
const req = route.request()
|
||||
if (req.resourceType() === 'document') return route.continue()
|
||||
if (!API_RE.test(new URL(req.url()).pathname)) return route.continue()
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: '{"ok":true,"items":[],"data":[]}' })
|
||||
}
|
||||
|
||||
/** Every visible text node whose computed line-height is smaller than its own font-size. */
|
||||
async function collapsedLines(page: Page) {
|
||||
return page.evaluate(() => {
|
||||
const bad: { text: string; fontSize: string; lineHeight: string; height: number }[] = []
|
||||
document.querySelectorAll('*').forEach((el) => {
|
||||
if (el.children.length) return
|
||||
const text = (el as HTMLElement).innerText?.trim()
|
||||
if (!text) return
|
||||
const cs = getComputedStyle(el)
|
||||
const size = parseFloat(cs.fontSize)
|
||||
const lead = parseFloat(cs.lineHeight) // `normal` → NaN, which is never a defect
|
||||
if (!Number.isFinite(lead) || !Number.isFinite(size) || lead >= size) return
|
||||
bad.push({
|
||||
text: text.slice(0, 60),
|
||||
fontSize: cs.fontSize,
|
||||
lineHeight: cs.lineHeight,
|
||||
height: Math.round(el.getBoundingClientRect().height),
|
||||
})
|
||||
})
|
||||
return bad
|
||||
})
|
||||
}
|
||||
|
||||
for (const path of ['/models', '/agents', '/playground']) {
|
||||
test(`no collapsed line box on ${path}`, async ({ page }) => {
|
||||
await page.route('**/*', stub)
|
||||
await primeSession(page)
|
||||
await page.goto(path, { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForTimeout(2500)
|
||||
expect(await collapsedLines(page)).toEqual([])
|
||||
})
|
||||
}
|
||||
|
||||
test('the product guide headline leads its own subhead', async ({ page }) => {
|
||||
await page.route('**/*', stub)
|
||||
await primeSession(page)
|
||||
await page.goto('/models', { waitUntil: 'domcontentloaded' })
|
||||
const guide = page.getByTestId('product-guide')
|
||||
await expect(guide).toBeVisible({ timeout: 20_000 })
|
||||
|
||||
const box = await guide.evaluate((g) => {
|
||||
const texts = [...g.querySelectorAll('*')].filter(
|
||||
(e) => !e.children.length && (e as HTMLElement).innerText?.trim(),
|
||||
) as HTMLElement[]
|
||||
const headline = texts.reduce((a, b) =>
|
||||
parseFloat(getComputedStyle(b).fontSize) > parseFloat(getComputedStyle(a).fontSize) ? b : a,
|
||||
)
|
||||
const cs = getComputedStyle(headline)
|
||||
return {
|
||||
fontSize: parseFloat(cs.fontSize),
|
||||
lineHeight: parseFloat(cs.lineHeight),
|
||||
height: headline.getBoundingClientRect().height,
|
||||
}
|
||||
})
|
||||
|
||||
// A display headline leads between 1.0 and 1.5 — and its box is at least one line tall.
|
||||
expect(box.lineHeight).toBeGreaterThanOrEqual(box.fontSize)
|
||||
expect(box.lineHeight).toBeLessThanOrEqual(box.fontSize * 1.5)
|
||||
expect(box.height).toBeGreaterThanOrEqual(box.fontSize)
|
||||
})
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* e2e: ONE level-2 nav.
|
||||
*
|
||||
* Clicking into a product must reveal ITS options rather than replacing the screen,
|
||||
* and there must be exactly ONE such nav on screen — not the sidebar's level 2 AND a
|
||||
* competing tab strip in the content, which is what `/models` used to do (eight items
|
||||
* in the rail, four in the content, disagreeing on the index's own name).
|
||||
*
|
||||
* "Rather than replacing the screen" is now literal on both axes: the product's
|
||||
* sub-pages expand BENEATH its row and the rest of the catalog stays put. The rail
|
||||
* used to swap itself for the product's sub-nav behind a "Back to all products"
|
||||
* button, so these specs assert the other products are still there — that is the
|
||||
* whole point of the change, and the part a future drill would silently undo.
|
||||
*
|
||||
* These are assertions only a browser can make. They read COMPUTED style and
|
||||
* GEOMETRY, not source: a strip hidden by a `$lg` media style prop is still in the
|
||||
* DOM, so `toBeVisible()` — which resolves to `display`/`visibility`/box-size — is
|
||||
* the only honest test of "is there a second nav on screen".
|
||||
*
|
||||
* Run: BASE_URL=http://localhost:4111 npx playwright test level-2-nav
|
||||
*/
|
||||
import { test, expect, type Route, type Page } from '@playwright/test'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { requireFixtureServer } from './_fixture'
|
||||
import { primeSession } from './_session'
|
||||
|
||||
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4000'
|
||||
|
||||
requireFixtureServer()
|
||||
const SHOTS = join(process.cwd(), 'e2e-shots')
|
||||
|
||||
const ACCOUNT = {
|
||||
owner: 'hanzo',
|
||||
name: 'z',
|
||||
email: 'z@hanzo.ai',
|
||||
displayName: 'Z Admin',
|
||||
isAdmin: true,
|
||||
}
|
||||
|
||||
const API_RE = /\/(v1|cloud|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations|auth\/refresh)(\/|$|\?)/
|
||||
const json = (route: Route, body: unknown, status = 200) =>
|
||||
route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
|
||||
|
||||
/**
|
||||
* Every backend answers 401 — this spec is about NAV, not data, and an unauthorized
|
||||
* read is the state every module already handles honestly. (A fabricated empty
|
||||
* envelope is NOT interchangeable: a module that expects an object and is handed
|
||||
* `[]` throws into its error boundary, which would make this spec a data test.)
|
||||
*/
|
||||
async function mock(route: Route) {
|
||||
const req = route.request()
|
||||
if (req.resourceType() === 'document') return route.continue()
|
||||
const url = new URL(req.url())
|
||||
if (url.pathname.startsWith('/auth/')) return json(route, { ok: true })
|
||||
const sameOrigin = url.origin === new URL(BASE_URL).origin
|
||||
if (sameOrigin && !API_RE.test(url.pathname)) return route.continue()
|
||||
return json(route, { error: 'Sign in to use Hanzo Cloud.' }, 401)
|
||||
}
|
||||
|
||||
async function open(page: Page, path: string) {
|
||||
await page.route('**/*', mock)
|
||||
await primeSession(page, ACCOUNT)
|
||||
await page.goto(`${BASE_URL}${path}`, { waitUntil: 'domcontentloaded' })
|
||||
await page.locator('[data-testid="product-content"]').first().waitFor({ state: 'attached', timeout: 30_000 })
|
||||
await page.waitForTimeout(1200)
|
||||
}
|
||||
|
||||
/** The level-2 nav rendered in the CONTENT column (`SubNav`). Located by test id,
|
||||
* not by role: a `display: none` element leaves the accessibility tree, and this
|
||||
* spec must be able to find it precisely when it is hidden. */
|
||||
const strip = (page: Page, id: string) => page.locator(`[data-testid="subnav-${id}"]`)
|
||||
|
||||
/** A level-2 row/tab by its label, anywhere on screen, VISIBLE only. */
|
||||
const visibleTab = (page: Page, label: string) =>
|
||||
page.getByRole('button', { name: label, exact: true }).filter({ visible: true })
|
||||
|
||||
test.beforeAll(() => mkdirSync(SHOTS, { recursive: true }))
|
||||
|
||||
test('desktop: the sidebar owns level 2 — the content strip is not a second nav', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
await open(page, '/models')
|
||||
|
||||
// The rail expanded Models in place — and did NOT swap itself for it.
|
||||
await expect(page.getByRole('button', { name: 'Back to all products' })).toHaveCount(0)
|
||||
// The rest of the catalog is still there — "All products" sits at the FOOT of the
|
||||
// product list, so its presence proves the list was never swapped away. This is the
|
||||
// assertion the drill could not have passed.
|
||||
await expect(page.getByRole('button', { name: 'All products' }).first()).toBeVisible()
|
||||
|
||||
// The index is named what the PRODUCT calls it — Models' index is the Catalog,
|
||||
// not a generic "Overview". This is the registry's `indexLabel`, read by the nav.
|
||||
await expect(visibleTab(page, 'Catalog').first()).toBeVisible()
|
||||
await expect(visibleTab(page, 'Leaderboard').first()).toBeVisible()
|
||||
await expect(visibleTab(page, 'Blend').first()).toBeVisible()
|
||||
|
||||
// Exactly ONE of each — a duplicate would mean two navs painting at once.
|
||||
for (const label of ['Catalog', 'Leaderboard', 'Blend']) {
|
||||
expect(await visibleTab(page, label).count(), `${label} appears once`).toBe(1)
|
||||
}
|
||||
|
||||
// The content strip is in the DOM but PAINTS NOTHING at lg+ (computed display).
|
||||
await expect(strip(page, 'models')).toBeAttached()
|
||||
await expect(strip(page, 'models')).not.toBeVisible()
|
||||
expect(await strip(page, 'models').evaluate((el) => getComputedStyle(el).display)).toBe('none')
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'level2-desktop-models.png'), fullPage: false })
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('phone: the strip carries level 2 where the sidebar is a drawer', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 390, height: 844 } })
|
||||
const page = await ctx.newPage()
|
||||
await open(page, '/models')
|
||||
|
||||
// The rail is off-canvas, so the strip is the ONE nav — and it is the SAME list.
|
||||
await expect(strip(page, 'models')).toBeVisible()
|
||||
const labels = await strip(page, 'models').getByRole('button').allInnerTexts()
|
||||
// Routing is admin-only and this account is an ORG admin, not a global one — the
|
||||
// one nav gates it, so a customer is never offered a surface they cannot open.
|
||||
expect(labels).toEqual(['Catalog', 'Leaderboard', 'Blend', 'Settings', 'Status', 'Logs', 'Metrics'])
|
||||
|
||||
// The strip wraps rather than pushing the page sideways.
|
||||
const scrolls = await page.evaluate(
|
||||
() => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
|
||||
)
|
||||
expect(scrolls, 'body must not scroll horizontally').toBe(false)
|
||||
|
||||
// Every tab is a real hit target — measured, not assumed.
|
||||
const boxes = await strip(page, 'models').getByRole('button').all()
|
||||
for (const b of boxes) {
|
||||
const box = await b.boundingBox()
|
||||
expect(box, 'a tab must have a painted box').not.toBeNull()
|
||||
expect(box!.height, 'a tab must be tall enough to tap').toBeGreaterThanOrEqual(28)
|
||||
expect(box!.x + box!.width, 'a tab must not paint past the right edge').toBeLessThanOrEqual(391)
|
||||
}
|
||||
|
||||
await strip(page, 'models').scrollIntoViewIfNeeded()
|
||||
await page.screenshot({ path: join(SHOTS, 'level2-mobile-models.png'), fullPage: false })
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('the URL carries the level — a deep link and a reload land on the same tab', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 390, height: 844 } })
|
||||
const page = await ctx.newPage()
|
||||
await open(page, '/models/blend')
|
||||
|
||||
const current = async () =>
|
||||
strip(page, 'models').locator('[aria-current="page"]').first().innerText()
|
||||
|
||||
expect(await current()).toBe('Blend')
|
||||
|
||||
await page.reload({ waitUntil: 'domcontentloaded' })
|
||||
await page.locator('[data-testid="product-content"]').first().waitFor({ state: 'attached', timeout: 30_000 })
|
||||
await page.waitForTimeout(1200)
|
||||
expect(await current(), 'reload keeps the level').toBe('Blend')
|
||||
expect(new URL(page.url()).pathname).toBe('/models/blend')
|
||||
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('back returns a level without losing pinned state', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
await open(page, '/models')
|
||||
|
||||
// Pin state is account-backed, not view state — it must survive a drill + back.
|
||||
const pinsBefore = await page.evaluate(() => localStorage.getItem('hanzo.preferences.cache'))
|
||||
|
||||
await visibleTab(page, 'Blend').first().click()
|
||||
await page.waitForTimeout(900)
|
||||
expect(new URL(page.url()).pathname).toBe('/models/blend')
|
||||
|
||||
await page.goBack({ waitUntil: 'domcontentloaded' })
|
||||
await page.waitForTimeout(900)
|
||||
expect(new URL(page.url()).pathname).toBe('/models')
|
||||
|
||||
// Models is still expanded with the same options — browser Back moved the ROUTE,
|
||||
// and the rail followed it without collapsing what the user was looking at.
|
||||
await expect(visibleTab(page, 'Catalog').first()).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Back to all products' })).toHaveCount(0)
|
||||
|
||||
expect(await page.evaluate(() => localStorage.getItem('hanzo.preferences.cache'))).toBe(pinsBefore)
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
/**
|
||||
* Every product that used to carry its own `const TABS` — the whole conversion, in
|
||||
* one sweep. For each: the page renders, the rail expands it in place, and the
|
||||
* content strip is present but PAINTS NOTHING at lg+. That is the "no second nav"
|
||||
* invariant, and it is the thing that regresses the moment someone adds a tab bar
|
||||
* back.
|
||||
*/
|
||||
const CONVERTED = [
|
||||
'models', 'evals', 'ai-accounts', 'containers', 'analytics', 'finetuning', 'team',
|
||||
'automations', 'embeddings', 'tasks', 'functions', 'profile', 'router', 'settings',
|
||||
'zero-trust', 'billing', 'captable', 'crm',
|
||||
] as const
|
||||
|
||||
test('no product paints a second level-2 nav at lg+', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
|
||||
for (const id of CONVERTED) {
|
||||
await open(page, `/${id}`)
|
||||
await expect(strip(page, id), `${id}: declares one level-2 nav`).toBeAttached()
|
||||
expect(
|
||||
await strip(page, id).evaluate((el) => getComputedStyle(el).display),
|
||||
`${id}: the content strip must not paint while the rail owns level 2`,
|
||||
).toBe('none')
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Back to all products' }),
|
||||
`${id}: the rail expands in place — it must never swap itself for one product`,
|
||||
).toHaveCount(0)
|
||||
}
|
||||
|
||||
await ctx.close()
|
||||
})
|
||||
@@ -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' })
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Onboarding — Continue never moves, and Skip is always reachable.
|
||||
*
|
||||
* The complaint this pins: the Continue button landed at a different height on
|
||||
* every step, so a user clicking through had to re-aim each time. StepActions was
|
||||
* the LAST CHILD of a flex column, so its y was whatever the step's content
|
||||
* happened to add up to. It is now a SLOT on StepShell above a content area with
|
||||
* a reserved height — one placement, decided in one place.
|
||||
*
|
||||
* This is a GEOMETRY assertion on purpose. The JSX move is invisible to a unit
|
||||
* test (both shapes render the same button with the same label); only the painted
|
||||
* box says whether the thing the user complained about is fixed.
|
||||
*/
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import { primeSession } from './_session'
|
||||
|
||||
/** Every step whose footer must line up, in flow order. */
|
||||
const STEPS = ['Secure your account', 'Data & consent', 'Your organization', 'Free trial credits', 'AI access']
|
||||
|
||||
/** The y of the actions row, in page coordinates. */
|
||||
async function actionsY(page: Page): Promise<number> {
|
||||
const row = page.getByTestId('onboarding-actions')
|
||||
await expect(row).toBeVisible()
|
||||
const box = await row.boundingBox()
|
||||
if (!box) throw new Error('actions row has no box')
|
||||
return Math.round(box.y)
|
||||
}
|
||||
|
||||
/** Advance past the current step, preferring Skip so the flow stays clickable. */
|
||||
async function advance(page: Page): Promise<void> {
|
||||
const row = page.getByTestId('onboarding-actions')
|
||||
const skip = row.getByRole('button', { name: /^(Skip|Keep the default)/ })
|
||||
if (await skip.count()) {
|
||||
await skip.first().click()
|
||||
return
|
||||
}
|
||||
// Consent has no Skip by design (accepting Terms is not optional), so tick the
|
||||
// agreement and use Continue. Tick only if Continue is still disabled — a caller
|
||||
// may already have ticked it, and toggling twice turns it back OFF.
|
||||
const cont = row.getByRole('button', { name: /Continue/ })
|
||||
if (await cont.isDisabled()) {
|
||||
const agree = page.locator('[role="switch"], input[type="checkbox"]').first()
|
||||
if (await agree.count()) await agree.click()
|
||||
}
|
||||
await cont.click()
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Anything the steps reach for answers empty — they are best-effort and must
|
||||
// still render. Registered BEFORE primeSession so its handlers win.
|
||||
await page.route('**/v1/**', (r) => r.fulfill({ status: 200, contentType: 'application/json', body: '{}' }))
|
||||
await primeSession(page)
|
||||
// primeSession marks onboarding DONE so other specs can reach the app. This
|
||||
// spec is about the wizard, so un-mark it (the tour gate stays seeded).
|
||||
await page.addInitScript(() => {
|
||||
for (const k of Object.keys(localStorage)) if (k.startsWith('hz_onboarding_done:')) localStorage.removeItem(k)
|
||||
})
|
||||
})
|
||||
|
||||
test('Continue lands at the same height on every step', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
|
||||
const seen: { step: string; y: number }[] = []
|
||||
for (const step of STEPS) {
|
||||
await expect(page.getByTestId('onboarding-step-title')).toHaveText(step, { timeout: 15_000 })
|
||||
seen.push({ step, y: await actionsY(page) })
|
||||
await advance(page)
|
||||
}
|
||||
|
||||
const ys = seen.map((s) => s.y)
|
||||
const spread = Math.max(...ys) - Math.min(...ys)
|
||||
expect(
|
||||
spread,
|
||||
`Continue moved ${spread}px across steps — ${seen.map((s) => `${s.step}:${s.y}`).join(' ')}`,
|
||||
).toBeLessThanOrEqual(2)
|
||||
})
|
||||
|
||||
test('every step always offers an enabled way forward', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
|
||||
// The real invariant behind "skip so easy to click through": on every step there
|
||||
// is ALWAYS at least one enabled control that advances you — Skip where there is
|
||||
// something to decline, Continue where there is not. Credits swaps between the
|
||||
// two on purpose (Skip appears only when a card could be added; otherwise
|
||||
// Continue carries you), so asserting a literal "Skip" everywhere would be
|
||||
// asserting the wrong thing. Being STUCK is the defect.
|
||||
for (const step of STEPS) {
|
||||
await expect(page.getByTestId('onboarding-step-title')).toHaveText(step, { timeout: 15_000 })
|
||||
|
||||
// Consent gates Continue on accepting the Terms — not optional, so tick it
|
||||
// first and then assert the way forward exists.
|
||||
if (step === 'Data & consent') {
|
||||
const agree = page.locator('[role="switch"], input[type="checkbox"]').first()
|
||||
if (await agree.count()) await agree.click()
|
||||
}
|
||||
|
||||
const row = page.getByTestId('onboarding-actions')
|
||||
const forward = row.getByRole('button', { name: /^(Skip|Keep the default|Continue)/ })
|
||||
const n = await forward.count()
|
||||
expect(n, `${step} renders no forward control`).toBeGreaterThan(0)
|
||||
|
||||
let usable = 0
|
||||
for (let i = 0; i < n; i++) {
|
||||
const b = forward.nth(i)
|
||||
if (await b.isDisabled()) continue
|
||||
const box = await b.boundingBox()
|
||||
if (!box || box.height < 24) continue
|
||||
const hit = await b.evaluate((el) => {
|
||||
const r = el.getBoundingClientRect()
|
||||
return el.contains(document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2))
|
||||
})
|
||||
if (hit) usable++
|
||||
}
|
||||
expect(usable, `${step} has no enabled, clickable way forward`).toBeGreaterThan(0)
|
||||
|
||||
await advance(page)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* e2e: the console's ORG identity in the chrome — mocked-network render proof.
|
||||
*
|
||||
* Two things this pins, both of which the shipped console got wrong:
|
||||
*
|
||||
* 1. The top-left mark is the ORG's, never the house glyph. With a logo it is
|
||||
* that logo; with none it is the org's MONOGRAM — the treatment the account
|
||||
* widget gives a person — and NOT the brand mark, and NOT the org's name set
|
||||
* as running text.
|
||||
* 2. The org switcher is the PEER of the account control: same height, same
|
||||
* mark size, same type, same hit area, same left edge.
|
||||
*
|
||||
* Both are measured off the RENDERED boxes, not off class names, so a styling
|
||||
* regression fails here.
|
||||
*
|
||||
* Run: BASE_URL=http://localhost:4000 npx playwright test org-identity
|
||||
*/
|
||||
import { test, expect, type Page, type Route } from '@playwright/test'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { requireFixtureServer } from './_fixture'
|
||||
import { primeSession } from './_session'
|
||||
|
||||
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4000'
|
||||
const SHOTS = join(process.cwd(), 'e2e-shots')
|
||||
|
||||
requireFixtureServer()
|
||||
test.beforeAll(() => mkdirSync(SHOTS, { recursive: true }))
|
||||
|
||||
/** A tenant whose id carries a separator — its monogram must read AL, not A. */
|
||||
const ORG = 'acme-labs'
|
||||
const LOGO = 'https://cdn.example.test/acme-labs.png'
|
||||
const API_RE = /\/(v1|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations|auth\/refresh)(\/|$|\?)/
|
||||
|
||||
const envelope = (data: unknown) => JSON.stringify({ status: 'ok', msg: '', data, data2: 0 })
|
||||
|
||||
/** Mount the shell as a member of `acme-labs`; `logo` decides which mark shows. */
|
||||
async function openShell(page: Page, logo: string | null) {
|
||||
await page.route('**/*', async (route: Route) => {
|
||||
const req = route.request()
|
||||
if (req.resourceType() === 'document') return route.continue()
|
||||
const url = new URL(req.url())
|
||||
|
||||
// The ONE org read the chrome makes (`useOrgIdentity` → get-organization).
|
||||
if (url.pathname.endsWith('/v1/iam/get-organization')) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: envelope({ owner: 'admin', name: ORG, displayName: 'Acme Labs', logo: logo ?? '' }),
|
||||
})
|
||||
}
|
||||
// The logo bytes — a 1x1 PNG, so the <img> genuinely paints.
|
||||
if (url.href === LOGO) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'image/png',
|
||||
body: Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
),
|
||||
})
|
||||
}
|
||||
if (url.origin === new URL(BASE_URL).origin && !API_RE.test(url.pathname)) return route.continue()
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: envelope([]) })
|
||||
})
|
||||
|
||||
// A plain tenant member (owner !== 'admin'), i.e. NOT a super admin — the case
|
||||
// that has no cross-tenant org list to draw its own row from.
|
||||
await primeSession(page, { owner: ORG, name: 'dave', email: 'dave@acme.test', displayName: 'Dave Lorenzini', isAdmin: false })
|
||||
await page.goto(`${BASE_URL}/`, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page.getByRole('button', { name: /account menu/i }).first()).toBeVisible({ timeout: 30_000 })
|
||||
}
|
||||
|
||||
const orgMark = (page: Page) => page.getByRole('link', { name: /— home/ }).first()
|
||||
const orgTrigger = (page: Page) => page.getByRole('button', { name: /switch organization/i }).first()
|
||||
const accountTrigger = (page: Page) => page.getByRole('button', { name: /account menu/i }).first()
|
||||
|
||||
test('the top-left mark is the org monogram — never the house mark, never the name as text', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
await openShell(page, null)
|
||||
|
||||
const mark = orgMark(page)
|
||||
await expect(mark).toBeVisible()
|
||||
|
||||
// The monogram of the org's DISPLAY name, by the account widget's own rule.
|
||||
await expect(mark).toHaveText('AL')
|
||||
|
||||
// Not the house glyph: the slot paints no SVG at all.
|
||||
expect(await mark.locator('svg').count()).toBe(0)
|
||||
// Not the org name as running text.
|
||||
await expect(mark).not.toContainText('Acme Labs')
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'org-identity-monogram.png') })
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('the org’s OWN logo replaces the mark when IAM carries one', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
await openShell(page, LOGO)
|
||||
|
||||
const logo = orgMark(page).locator('img')
|
||||
await expect(logo).toHaveAttribute('src', LOGO)
|
||||
// The logo REPLACES the monogram — one mark, not both.
|
||||
await expect(orgMark(page)).toHaveText('')
|
||||
expect(await orgMark(page).locator('svg').count()).toBe(0)
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'org-identity-logo.png') })
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('the org switcher reads as the peer of the account control', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
await openShell(page, null)
|
||||
|
||||
const org = orgTrigger(page)
|
||||
const account = accountTrigger(page)
|
||||
await expect(org).toBeVisible()
|
||||
await expect(account).toBeVisible()
|
||||
|
||||
const [orgBox, accountBox] = [await org.boundingBox(), await account.boundingBox()]
|
||||
if (!orgBox || !accountBox) throw new Error('a switcher did not lay out')
|
||||
|
||||
// Same height, same width, same left edge — one hit area, one column.
|
||||
expect(Math.round(orgBox.height)).toBe(Math.round(accountBox.height))
|
||||
expect(Math.abs(orgBox.width - accountBox.width)).toBeLessThanOrEqual(1)
|
||||
expect(Math.abs(orgBox.x - accountBox.x)).toBeLessThanOrEqual(1)
|
||||
// A real target, not a caption.
|
||||
expect(orgBox.height).toBeGreaterThanOrEqual(44)
|
||||
|
||||
// Same type: the org name and the account name are set identically.
|
||||
const type = (root: typeof org, name: string) =>
|
||||
root.locator(`text=${name}`).first().evaluate((el) => {
|
||||
const s = getComputedStyle(el)
|
||||
return { size: s.fontSize, weight: s.fontWeight }
|
||||
})
|
||||
expect(await type(org, 'Acme Labs')).toEqual(await type(account, 'Dave Lorenzini'))
|
||||
|
||||
// Same mark size — the org monogram tile matches the account avatar tile.
|
||||
const tile = async (root: typeof org) => {
|
||||
const b = await root.locator('div,span').filter({ hasText: /^(AL|DL)$/ }).last().boundingBox()
|
||||
return b ? { w: Math.round(b.width), h: Math.round(b.height) } : null
|
||||
}
|
||||
expect(await tile(org)).toEqual(await tile(account))
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'org-identity-peers.png') })
|
||||
// The sidebar column alone — the two controls, top and bottom, side by side.
|
||||
await page.screenshot({ path: join(SHOTS, 'org-identity-sidebar.png'), clip: { x: 0, y: 0, width: 300, height: 900 } })
|
||||
await ctx.close()
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Playground layout: the Response panel sits UNDER the surface tabs — above
|
||||
* the composer — at every width. Render-proven on the local dev server with a
|
||||
* fully mocked network (no gateway, no billing, no catalog): what is asserted
|
||||
* is GEOMETRY, which mocks cannot fake.
|
||||
*
|
||||
* Run: BASE_URL=http://localhost:4000 npx playwright test playground-responsive
|
||||
*/
|
||||
import { test, expect, type Route } from '@playwright/test'
|
||||
import { requireFixtureServer } from './_fixture'
|
||||
import { primeSession } from './_session'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4000'
|
||||
|
||||
// The module shell resolves the product registry from the local fixture server,
|
||||
// like every other module render spec; skip cleanly when it is down.
|
||||
requireFixtureServer()
|
||||
const SHOTS = join(process.cwd(), 'e2e-shots')
|
||||
|
||||
const WIDTHS = [
|
||||
{ name: 'phone', width: 390, height: 844 },
|
||||
{ name: 'tablet', width: 834, height: 1112 },
|
||||
{ name: 'laptop', width: 1440, height: 900 },
|
||||
{ name: 'desktop', width: 1920, height: 1080 },
|
||||
]
|
||||
|
||||
// Minimal honest bodies for everything the page asks the backend.
|
||||
const mock = async (route: Route) => {
|
||||
const url = route.request().url()
|
||||
const json = (body: unknown) =>
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) })
|
||||
if (url.includes('/pricing/models')) return json({ models: [] })
|
||||
if (url.includes('/v1/models'))
|
||||
return json({ object: 'list', data: [{ id: 'zen5-flash', owned_by: 'Hanzo' }] })
|
||||
if (url.includes('/billing/subscriptions')) return json({ subscriptions: [] })
|
||||
if (url.includes(':4000') || url.startsWith(BASE_URL)) return route.continue()
|
||||
return json({})
|
||||
}
|
||||
|
||||
for (const vp of WIDTHS) {
|
||||
test(`response renders under the tabs at ${vp.name} (${vp.width}px)`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: vp.width, height: vp.height })
|
||||
await page.route('**/*', mock)
|
||||
await primeSession(page)
|
||||
await page.goto(`${BASE_URL}/ai/playground`, { waitUntil: 'domcontentloaded' })
|
||||
|
||||
// The three landmarks: the surface tabs, the Response panel, the composer.
|
||||
const tabs = page.getByRole('button', { name: 'Completions' }).first()
|
||||
const response = page.getByText('Response', { exact: true }).first()
|
||||
const composer = page.getByText('System prompt', { exact: true }).first()
|
||||
await expect(tabs).toBeVisible({ timeout: 20000 })
|
||||
await expect(response).toBeVisible()
|
||||
await expect(composer).toBeVisible()
|
||||
|
||||
const [tabsBox, respBox, compBox] = await Promise.all([
|
||||
tabs.boundingBox(),
|
||||
response.boundingBox(),
|
||||
composer.boundingBox(),
|
||||
])
|
||||
if (!tabsBox || !respBox || !compBox) throw new Error('a landmark has no box')
|
||||
|
||||
// ORDER: tabs, then Response, then the composer — at every width.
|
||||
expect(respBox.y, 'Response sits below the tabs').toBeGreaterThan(tabsBox.y)
|
||||
expect(compBox.y, 'the composer sits below the Response panel top').toBeGreaterThan(respBox.y)
|
||||
|
||||
// RESPONSIVE: nothing forces a horizontal scroll.
|
||||
const scrollW = await page.evaluate(() => document.documentElement.scrollWidth)
|
||||
expect(scrollW, 'no horizontal overflow').toBeLessThanOrEqual(vp.width + 1)
|
||||
|
||||
mkdirSync(SHOTS, { recursive: true })
|
||||
await page.screenshot({ path: join(SHOTS, `playground-${vp.name}-${vp.width}.png`) })
|
||||
})
|
||||
}
|
||||
@@ -188,7 +188,7 @@ test.describe('(B) LIVE — admin gate + real DO data', () => {
|
||||
await expect(page).not.toHaveURL(/\/signin/, { timeout: 15_000 })
|
||||
// The board (not the operator-access gate) rendered for the SuperAdmin.
|
||||
await expect(page.locator('text=/Provider credit|Credit vs paid/i').first()).toBeVisible({ timeout: 30_000 })
|
||||
await expect(page.locator('text=/Operator access required/i')).toHaveCount(0)
|
||||
await expect(page.locator('text=/SuperAdmin access required/i')).toHaveCount(0)
|
||||
// Real DO data: the $26k grant / a do-ai card / glm-5.2 usage.
|
||||
await expect(page.locator('text=/do-ai|digitalocean/i').first()).toBeVisible({ timeout: 30_000 })
|
||||
await page.screenshot({ path: join(SHOTS, 'provider-billing-live-do.png'), fullPage: true })
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* The research dashboard — every benchmark run and its verdict.
|
||||
*
|
||||
* What this file can and cannot prove, stated plainly, because the difference is the
|
||||
* whole value:
|
||||
*
|
||||
* The console is a SPA behind a catch-all, so EVERY path returns HTTP 200 — including
|
||||
* `/definitely-not-a-page`. A test asserting "200" or "the page loaded" passes even when
|
||||
* the route is deleted. And `/research` is behind `AuthGate`, so an anonymous visitor is
|
||||
* redirected to sign-in and sees NEITHER the dashboard NOR the SuperAdmin gate. Measured,
|
||||
* not assumed: the body reads "Sign in to your account".
|
||||
*
|
||||
* So the two tests that RUN here prove security and routing, not rendering:
|
||||
* - anonymous callers see no corpus data (the gate genuinely holds)
|
||||
* - a nonsense path renders no dashboard (the assertion above can fail)
|
||||
* Proving the dashboard PAINTS needs a SuperAdmin session, so that test is staged behind
|
||||
* HANZO_PASSWORD rather than faked — the same staging `insights-o11y.spec.ts` uses.
|
||||
*
|
||||
* Run: BASE_URL=https://cloud.hanzo.ai npx playwright test research-dashboard
|
||||
*/
|
||||
|
||||
const BASE_URL = process.env.BASE_URL ?? 'https://cloud.hanzo.ai'
|
||||
|
||||
/** Copy rendered ONLY by ResearchModule. */
|
||||
const DASHBOARD = /Falsifiable R&D experiments/i
|
||||
/** Corpus numbers. Never visible to a caller who is not a SuperAdmin. */
|
||||
const CORPUS = [/\bProven\b/, /\bRefuted\b/, /\bAttempts\b/]
|
||||
|
||||
async function settle(page: import('@playwright/test').Page) {
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {})
|
||||
}
|
||||
|
||||
test.describe('research dashboard', () => {
|
||||
test('an anonymous caller sees no corpus data', async ({ page }) => {
|
||||
// The security assertion. The module gates on useIsSuperAdmin and the `research`
|
||||
// head is org-scoped server-side by the Bearer owner — so an unauthenticated
|
||||
// visitor must reach sign-in with zero experiment counts painted. A client-only
|
||||
// gate that rendered the KPI band behind a card would fail here.
|
||||
await page.goto(`${BASE_URL}/research`)
|
||||
await settle(page)
|
||||
const body = page.locator('body')
|
||||
|
||||
// ANCHOR FIRST. Every assertion below is an absence, and an absence is satisfied by
|
||||
// a blank page, a 502, or a dead host — so prove the app actually rendered before
|
||||
// claiming the gate held. Without this the test passes while the site is down.
|
||||
await expect(
|
||||
body.getByText(/Sign in|Log in/i).first(),
|
||||
'the console shell did not render — the absence assertions below would be vacuous',
|
||||
).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
for (const label of CORPUS) {
|
||||
await expect(
|
||||
body.getByText(label),
|
||||
`"${label}" rendered to an anonymous caller — corpus data leaked past the gate`,
|
||||
).toHaveCount(0)
|
||||
}
|
||||
await expect(
|
||||
body.getByText(DASHBOARD),
|
||||
'the dashboard rendered to an anonymous caller — the auth gate is not holding',
|
||||
).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('is not a catch-all — a nonsense path renders no dashboard', async ({ page }) => {
|
||||
// The control that gives the authenticated test (below) its meaning: the SPA
|
||||
// answers 200 here too, so if this path could show the research surface, matching
|
||||
// on that copy would prove nothing about routing.
|
||||
await page.goto(`${BASE_URL}/definitely-not-a-page-9137`)
|
||||
await settle(page)
|
||||
|
||||
// Same anchor: the app must have rendered for "no dashboard here" to mean anything.
|
||||
await expect(
|
||||
page.locator('body').getByText(/Sign in|Log in/i).first(),
|
||||
'the console shell did not render — the absence assertion below would be vacuous',
|
||||
).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
await expect(
|
||||
page.locator('body').getByText(DASHBOARD),
|
||||
'a nonsense path rendered the research dashboard — matching on that copy proves nothing',
|
||||
).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('renders the corpus for a SuperAdmin', async ({ page }) => {
|
||||
// STAGED, not skipped-and-forgotten: needs the reserved-admin SuperAdmin
|
||||
// credential, which lives in KMS and not on a dev host. With it, this is the
|
||||
// assertion that actually proves the dashboard paints real evidence.
|
||||
const password = process.env.HANZO_PASSWORD
|
||||
test.skip(!password, 'set HANZO_PASSWORD (reserved-admin SuperAdmin) to run the render proof')
|
||||
|
||||
await page.goto(`${BASE_URL}/signin`)
|
||||
await settle(page)
|
||||
await page.getByLabel(/email|username/i).first().fill(process.env.HANZO_USER ?? 'z@hanzo.ai')
|
||||
await page.getByLabel(/password/i).first().fill(password!)
|
||||
await page.getByRole('button', { name: /sign in|log in/i }).first().click()
|
||||
await page.waitForURL((u) => !u.pathname.startsWith('/signin'), { timeout: 30_000 })
|
||||
|
||||
await page.goto(`${BASE_URL}/research`)
|
||||
await settle(page)
|
||||
|
||||
await expect(
|
||||
page.locator('body').getByText(DASHBOARD),
|
||||
'a SuperAdmin did not get the research dashboard',
|
||||
).toHaveCount(1)
|
||||
await expect(page.locator('body').getByText(/\bExperiments\b/)).not.toHaveCount(0)
|
||||
})
|
||||
})
|
||||
@@ -18,7 +18,6 @@
|
||||
"agents",
|
||||
"inference",
|
||||
"finetuning",
|
||||
"ml-pipelines",
|
||||
"embeddings",
|
||||
"evals",
|
||||
"gpus",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -30,10 +30,10 @@ const SNAPSHOT = {
|
||||
datastore: { name: 'datastore-data-datastore-0', mount: '/var/lib/hanzo-datastore', sizeGiB: 200, usedGiB: 13.5, pct: 7 },
|
||||
volumes: [
|
||||
{ id: 'vol-1', name: 'pvc-datastore', region: 'sfo3', sizeGiB: 200, usedGiB: 13.5, pct: 7, attached: true, service: 'datastore-0' },
|
||||
{ id: 'vol-2', name: 'pvc-signoz', region: 'sfo3', sizeGiB: 100, usedGiB: 91, pct: 91, attached: true, service: 'signoz' },
|
||||
{ id: 'vol-2', name: 'pvc-o11y', region: 'sfo3', sizeGiB: 100, usedGiB: 91, pct: 91, attached: true, service: 'o11y' },
|
||||
{ id: 'vol-3', name: 'pvc-detached', region: 'sfo3', sizeGiB: 50, usedGiB: null, pct: null, attached: false, service: null },
|
||||
],
|
||||
alerts: [{ volume: 'signoz', pct: 91, level: 'critical' }],
|
||||
alerts: [{ volume: 'o11y', pct: 91, level: 'critical' }],
|
||||
}
|
||||
|
||||
const API_RE = /\/(v1|cloud|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations|auth\/refresh)(\/|$|\?)/
|
||||
@@ -43,7 +43,7 @@ async function mock(route: Route) {
|
||||
const req = route.request()
|
||||
if (req.resourceType() === 'document') return route.continue()
|
||||
const url = new URL(req.url())
|
||||
if (/\/v1\/admin\/block-storage(\/|$|\?)/.test(url.pathname)) {
|
||||
if (/\/v1\/admin\/volumes(\/|$|\?)/.test(url.pathname)) {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(SNAPSHOT) })
|
||||
}
|
||||
const sameOrigin = url.origin === new URL(BASE_URL).origin
|
||||
|
||||
+10
-22
@@ -1,23 +1,11 @@
|
||||
import { defaultConfig } from '@hanzogui/config/v5'
|
||||
import { createGui } from '@hanzo/gui'
|
||||
/**
|
||||
* The console's GUI config IS the shared one. The type/radius/space scale used to be
|
||||
* declared here; it now ships with the components it scales (`@hanzo/ui/gui-config`),
|
||||
* because the dedicated Hanzo Social app renders the same @hanzo/ui/product set and a
|
||||
* second copy of the ladder would fork silently — same components, different sizes.
|
||||
*
|
||||
* Kept as a file so `~/gui.config` stays the console's one import path.
|
||||
*/
|
||||
export { config, default } from '@hanzo/ui/gui-config'
|
||||
|
||||
// Canonical Hanzo UI face: Geist Sans (loaded via the CDN @import in app/globals.css,
|
||||
// parallel to Geist Mono for code/data). Override the @hanzo/gui (Tamagui) v5 default
|
||||
// system-font family on the body + heading fonts so every <Text>/<Paragraph>/<H*>
|
||||
// renders Geist — one place, whole product (DRY). Size/line-height/weight scales are
|
||||
// inherited from the default config; only the family swaps. Falls back to system-ui
|
||||
// if the Geist face is unavailable, so the UI degrades gracefully.
|
||||
const GEIST = "'Geist', system-ui, -apple-system, sans-serif"
|
||||
|
||||
export const config = createGui({
|
||||
...defaultConfig,
|
||||
fonts: {
|
||||
...defaultConfig.fonts,
|
||||
body: { ...defaultConfig.fonts.body, family: GEIST },
|
||||
heading: { ...defaultConfig.fonts.heading, family: GEIST },
|
||||
},
|
||||
})
|
||||
|
||||
export default config
|
||||
|
||||
export type Conf = typeof config
|
||||
export type Conf = typeof import('@hanzo/ui/gui-config').config
|
||||
|
||||
@@ -1,34 +1,42 @@
|
||||
# Canonical CI config for hanzoai/console — read by the hanzoai/ci reusable
|
||||
# (.hanzo/workflows/cicd.yml) and platform.hanzo.ai.
|
||||
# (.hanzo/workflows/cicd.yml) and platform.hanzo.ai. hanzoai/ci pushes to `repo:`
|
||||
# (GHCR) and server-side-mirrors to registry.hanzo.ai automatically.
|
||||
#
|
||||
# Publishes the console STATIC EMBED artifact (SPA static export at /dist) as a
|
||||
# versioned immutable image. hanzoai/cloud consumes it via `FROM ... AS console`
|
||||
# + `COPY --from=console /dist/`, so it never rebuilds npm+Next on a cloud release.
|
||||
# hanzoai/ci pushes to `repo:` (GHCR) and server-side-mirrors to registry.hanzo.ai
|
||||
# automatically.
|
||||
# TWO artifacts, one bundle. The console is a static SPA export; what differs is
|
||||
# only who serves it:
|
||||
#
|
||||
# BOTH console images are declared here — one config, read by whichever runner
|
||||
# executes it. The Next.js SERVER image (admin.hanzo.ai, operator CR
|
||||
# universe:crs/console.yaml) was built by .github/workflows/build-image.yml until
|
||||
# that file was neutralized on 2026-07-24 in favour of a native pipeline that
|
||||
# could not run: hanzoai/console had the forge Actions unit DISABLED
|
||||
# (`has_actions: false`, zero runs), so nothing built it — v8.5.23 and 8.5.24
|
||||
# shipped no image, and the CR still pins the last one built, v8.5.22. Declaring
|
||||
# both images here puts them on the ONE pipeline, wherever it executes.
|
||||
# console-embed the bundle alone at /dist. hanzoai/cloud does
|
||||
# `COPY --from=console /dist/` so a cloud release never rebuilds
|
||||
# npm+Next. Needed only while cloud go:embeds the console.
|
||||
# console the bundle behind hanzoai/static, serving itself. This is how
|
||||
# a console change ships WITHOUT a cloud release: move image.tag
|
||||
# in a universe values file and cd rolls it.
|
||||
#
|
||||
# Tag shape changes with the builder, deliberately: the shared builder publishes
|
||||
# the immutable `sha-<sha7>-amd64` per main push (plus the bare semver on a v*
|
||||
# tag), not the `:v<X.Y.Z>` receipt the old bespoke workflow minted. Pin the CR to
|
||||
# the sha tag — that is what hanzoai/cloud does, and an immutable digest-shaped
|
||||
# tag cannot be re-pushed to different bytes the way `:v8.4.118` once was.
|
||||
# The Next.js SERVER image that used to be the second entry is gone. It was
|
||||
# already doing nothing a file server could not — every host it served sent /v1
|
||||
# and /zap to cloud-api at the ingress, so its BFF was never reached — and its own
|
||||
# auth routes stopped mattering when identity became a client-held IAM token.
|
||||
#
|
||||
# TAGS: the shared builder publishes `sha-<sha7>-amd64` on every main push AND the
|
||||
# bare semver on a cut v* tag. PIN THE SEMVER — it says which console RELEASE a
|
||||
# deployment carries, which a sha cannot. The discipline that keeps that honest is
|
||||
# that a cut tag is never re-pointed (`:v8.4.118` once was): cut the next patch
|
||||
# instead.
|
||||
images:
|
||||
- name: console-embed
|
||||
context: .
|
||||
dockerfile: Dockerfile.embed
|
||||
repo: ghcr.io/hanzoai/console-embed
|
||||
# The brand-agnostic Next.js server image: brand resolves at RUNTIME from the
|
||||
# request hostname, so no NEXT_PUBLIC_* may be baked (baking pins the image to
|
||||
# one brand). SOURCE_COMMIT is the only build arg it ever took.
|
||||
# The console. It is static — that is not a variant, it is what the console IS,
|
||||
# so the image is `console` and there is no adjective in the name. Dockerfile
|
||||
# builds the SPA export and puts hanzoai/static in front of it.
|
||||
#
|
||||
# This REPLACES the Next.js server image that used to be published here. It was
|
||||
# already doing nothing a file server could not: every host it serves
|
||||
# (admin.lux.cloud, admin.lux.network, admin.zoo.cloud) sends /v1 and /zap to
|
||||
# cloud-api at the ingress, so the server's BFF at /v1/* was never reached on any
|
||||
# of them. Its own auth routes went the same way when identity became a
|
||||
# client-held IAM token. One console, one image.
|
||||
- name: console
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
|
||||
+50
-10
@@ -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',
|
||||
|
||||
Generated
+1463
-3986
File diff suppressed because it is too large
Load Diff
+25
-23
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"name": "@hanzo/console",
|
||||
"version": "8.5.30",
|
||||
"version": "8.5.62",
|
||||
"packageManager": "pnpm@11.17.0",
|
||||
"private": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"author": "Hanzo AI <dev@hanzo.ai>",
|
||||
"description": "Hanzo Cloud Console — unified admin console for Hanzo Cloud and all cloud products.",
|
||||
"scripts": {
|
||||
@@ -13,25 +14,27 @@
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"e2e": "playwright test",
|
||||
"e2e:headed": "playwright test --headed",
|
||||
"postinstall": "patch-package"
|
||||
"e2e:headed": "playwright test --headed"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hanzo/brand": "^1.4.0",
|
||||
"@hanzo/canvas": "^0.1.0",
|
||||
"@hanzo/dash": "0.3.0",
|
||||
"@hanzo/data": "^1.2.0",
|
||||
"@hanzo/event": "^0.3.1",
|
||||
"@hanzo/finance-ui": "0.1.1",
|
||||
"@hanzo/gui": "7.3.0",
|
||||
"@hanzo/iam": "^0.13.6",
|
||||
"@hanzo/logo": "^1.0.13",
|
||||
"@hanzo/ui": "^8.0.6",
|
||||
"@hanzo/brand": "^1.4.5",
|
||||
"@hanzo/canvas": "^0.2.1",
|
||||
"@hanzo/dash": "^0.3.0",
|
||||
"@hanzo/data": "^1.2.2",
|
||||
"@hanzo/design": "^0.4.6",
|
||||
"@hanzo/event": "^0.3.8",
|
||||
"@hanzo/finance-ui": "~0.1.1",
|
||||
"@hanzo/gui": "^8.0.0",
|
||||
"@hanzo/iam": "^0.21.6",
|
||||
"@hanzo/logo": "^1.0.14",
|
||||
"@hanzo/ui": "^8.0.56",
|
||||
"@hanzo/usage": "^0.1.6",
|
||||
"@hanzogui/config": "7.3.0",
|
||||
"@hanzogui/core": "7.3.0",
|
||||
"@hanzogui/lucide-icons-2": "7.3.0",
|
||||
"@hanzogui/next-theme": "7.3.0",
|
||||
"@hanzogui/config": "^8.0.0",
|
||||
"@hanzogui/core": "^8.0.0",
|
||||
"@hanzogui/lucide-icons-2": "^8.0.0",
|
||||
"@hanzogui/next-theme": "^8.0.0",
|
||||
"@hanzogui/telemetry": "^8.0.0",
|
||||
"@hanzogui/shell": "^8.1.1",
|
||||
"@lexical/html": "0.46.0",
|
||||
"@lexical/link": "0.46.0",
|
||||
"@lexical/list": "0.46.0",
|
||||
@@ -51,9 +54,9 @@
|
||||
"qrcode.react": "4.2.0",
|
||||
"react": "19.2.7",
|
||||
"react-dom": "19.2.7",
|
||||
"react-native-svg": "15.15.5",
|
||||
"react-native-web": "0.21.2",
|
||||
"superjson": "2.2.2",
|
||||
"@hanzogui/shell": "^7.6.3"
|
||||
"superjson": "2.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.61.1",
|
||||
@@ -61,8 +64,7 @@
|
||||
"@types/react": "19.2.17",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"react-native": "0.83.9",
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "3.2.4",
|
||||
"patch-package": "^8.0.0"
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
diff --git a/node_modules/@hanzo/iam/dist/browser.cjs b/node_modules/@hanzo/iam/dist/browser.cjs
|
||||
index fe3a04e..41c367e 100644
|
||||
--- a/node_modules/@hanzo/iam/dist/browser.cjs
|
||||
+++ b/node_modules/@hanzo/iam/dist/browser.cjs
|
||||
@@ -785,6 +785,17 @@ var IAM = class {
|
||||
if (tokens.expires_in) {
|
||||
const expiresAt = Date.now() + tokens.expires_in * 1e3;
|
||||
this.storage.setItem(KEY_EXPIRES_AT, String(expiresAt));
|
||||
+ } else {
|
||||
+ try {
|
||||
+ const _p = tokens.access_token.split(".");
|
||||
+ if (_p.length === 3) {
|
||||
+ let _b = _p[1].replace(/-/g, "+").replace(/_/g, "/");
|
||||
+ while (_b.length % 4) _b += "=";
|
||||
+ const _j = typeof atob === "function" ? atob(_b) : Buffer.from(_b, "base64").toString("binary");
|
||||
+ const _e = JSON.parse(_j).exp;
|
||||
+ if (typeof _e === "number") this.storage.setItem(KEY_EXPIRES_AT, String(_e * 1e3));
|
||||
+ }
|
||||
+ } catch (_) {}
|
||||
}
|
||||
}
|
||||
/** Get the stored access token (may be expired). */
|
||||
diff --git a/node_modules/@hanzo/iam/dist/browser.js b/node_modules/@hanzo/iam/dist/browser.js
|
||||
index 4228603..1b9db27 100644
|
||||
--- a/node_modules/@hanzo/iam/dist/browser.js
|
||||
+++ b/node_modules/@hanzo/iam/dist/browser.js
|
||||
@@ -783,6 +783,17 @@ var IAM = class {
|
||||
if (tokens.expires_in) {
|
||||
const expiresAt = Date.now() + tokens.expires_in * 1e3;
|
||||
this.storage.setItem(KEY_EXPIRES_AT, String(expiresAt));
|
||||
+ } else {
|
||||
+ try {
|
||||
+ const _p = tokens.access_token.split(".");
|
||||
+ if (_p.length === 3) {
|
||||
+ let _b = _p[1].replace(/-/g, "+").replace(/_/g, "/");
|
||||
+ while (_b.length % 4) _b += "=";
|
||||
+ const _j = typeof atob === "function" ? atob(_b) : Buffer.from(_b, "base64").toString("binary");
|
||||
+ const _e = JSON.parse(_j).exp;
|
||||
+ if (typeof _e === "number") this.storage.setItem(KEY_EXPIRES_AT, String(_e * 1e3));
|
||||
+ }
|
||||
+ } catch (_) {}
|
||||
}
|
||||
}
|
||||
/** Get the stored access token (may be expired). */
|
||||
diff --git a/node_modules/@hanzo/iam/dist/index.cjs b/node_modules/@hanzo/iam/dist/index.cjs
|
||||
index d49c5d4..81cfb85 100644
|
||||
--- a/node_modules/@hanzo/iam/dist/index.cjs
|
||||
+++ b/node_modules/@hanzo/iam/dist/index.cjs
|
||||
@@ -1172,6 +1172,17 @@ var IAM = class {
|
||||
if (tokens.expires_in) {
|
||||
const expiresAt = Date.now() + tokens.expires_in * 1e3;
|
||||
this.storage.setItem(KEY_EXPIRES_AT, String(expiresAt));
|
||||
+ } else {
|
||||
+ try {
|
||||
+ const _p = tokens.access_token.split(".");
|
||||
+ if (_p.length === 3) {
|
||||
+ let _b = _p[1].replace(/-/g, "+").replace(/_/g, "/");
|
||||
+ while (_b.length % 4) _b += "=";
|
||||
+ const _j = typeof atob === "function" ? atob(_b) : Buffer.from(_b, "base64").toString("binary");
|
||||
+ const _e = JSON.parse(_j).exp;
|
||||
+ if (typeof _e === "number") this.storage.setItem(KEY_EXPIRES_AT, String(_e * 1e3));
|
||||
+ }
|
||||
+ } catch (_) {}
|
||||
}
|
||||
}
|
||||
/** Get the stored access token (may be expired). */
|
||||
diff --git a/node_modules/@hanzo/iam/dist/index.js b/node_modules/@hanzo/iam/dist/index.js
|
||||
index 48c5699..7a85d23 100644
|
||||
--- a/node_modules/@hanzo/iam/dist/index.js
|
||||
+++ b/node_modules/@hanzo/iam/dist/index.js
|
||||
@@ -1170,6 +1170,17 @@ var IAM = class {
|
||||
if (tokens.expires_in) {
|
||||
const expiresAt = Date.now() + tokens.expires_in * 1e3;
|
||||
this.storage.setItem(KEY_EXPIRES_AT, String(expiresAt));
|
||||
+ } else {
|
||||
+ try {
|
||||
+ const _p = tokens.access_token.split(".");
|
||||
+ if (_p.length === 3) {
|
||||
+ let _b = _p[1].replace(/-/g, "+").replace(/_/g, "/");
|
||||
+ while (_b.length % 4) _b += "=";
|
||||
+ const _j = typeof atob === "function" ? atob(_b) : Buffer.from(_b, "base64").toString("binary");
|
||||
+ const _e = JSON.parse(_j).exp;
|
||||
+ if (typeof _e === "number") this.storage.setItem(KEY_EXPIRES_AT, String(_e * 1e3));
|
||||
+ }
|
||||
+ } catch (_) {}
|
||||
}
|
||||
}
|
||||
/** Get the stored access token (may be expired). */
|
||||
diff --git a/node_modules/@hanzo/iam/dist/react.cjs b/node_modules/@hanzo/iam/dist/react.cjs
|
||||
index 8642b04..66da7d3 100644
|
||||
--- a/node_modules/@hanzo/iam/dist/react.cjs
|
||||
+++ b/node_modules/@hanzo/iam/dist/react.cjs
|
||||
@@ -719,6 +719,17 @@ var IAM = class {
|
||||
if (tokens.expires_in) {
|
||||
const expiresAt = Date.now() + tokens.expires_in * 1e3;
|
||||
this.storage.setItem(KEY_EXPIRES_AT, String(expiresAt));
|
||||
+ } else {
|
||||
+ try {
|
||||
+ const _p = tokens.access_token.split(".");
|
||||
+ if (_p.length === 3) {
|
||||
+ let _b = _p[1].replace(/-/g, "+").replace(/_/g, "/");
|
||||
+ while (_b.length % 4) _b += "=";
|
||||
+ const _j = typeof atob === "function" ? atob(_b) : Buffer.from(_b, "base64").toString("binary");
|
||||
+ const _e = JSON.parse(_j).exp;
|
||||
+ if (typeof _e === "number") this.storage.setItem(KEY_EXPIRES_AT, String(_e * 1e3));
|
||||
+ }
|
||||
+ } catch (_) {}
|
||||
}
|
||||
}
|
||||
/** Get the stored access token (may be expired). */
|
||||
diff --git a/node_modules/@hanzo/iam/dist/react.js b/node_modules/@hanzo/iam/dist/react.js
|
||||
index 8f4927a..81bef42 100644
|
||||
--- a/node_modules/@hanzo/iam/dist/react.js
|
||||
+++ b/node_modules/@hanzo/iam/dist/react.js
|
||||
@@ -717,6 +717,17 @@ var IAM = class {
|
||||
if (tokens.expires_in) {
|
||||
const expiresAt = Date.now() + tokens.expires_in * 1e3;
|
||||
this.storage.setItem(KEY_EXPIRES_AT, String(expiresAt));
|
||||
+ } else {
|
||||
+ try {
|
||||
+ const _p = tokens.access_token.split(".");
|
||||
+ if (_p.length === 3) {
|
||||
+ let _b = _p[1].replace(/-/g, "+").replace(/_/g, "/");
|
||||
+ while (_b.length % 4) _b += "=";
|
||||
+ const _j = typeof atob === "function" ? atob(_b) : Buffer.from(_b, "base64").toString("binary");
|
||||
+ const _e = JSON.parse(_j).exp;
|
||||
+ if (typeof _e === "number") this.storage.setItem(KEY_EXPIRES_AT, String(_e * 1e3));
|
||||
+ }
|
||||
+ } catch (_) {}
|
||||
}
|
||||
}
|
||||
/** Get the stored access token (may be expired). */
|
||||
Generated
+9243
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
# Install scripts run only for packages listed here.
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
sharp: true
|
||||
|
||||
# Our own scopes skip pnpm's new-release quarantine. Scope glob, not a version
|
||||
# pin: pnpm rewrites version entries on every bump and breaks --frozen-lockfile.
|
||||
minimumReleaseAgeExclude:
|
||||
- '@hanzo/*'
|
||||
- '@hanzogui/*'
|
||||
@@ -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' },
|
||||
})
|
||||
|
||||
|
||||
@@ -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')
|
||||
@@ -0,0 +1,84 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* 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 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
|
||||
* own theme hook beside it — one theme system, not two.
|
||||
*
|
||||
* - 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 { useMemo } from 'react'
|
||||
import { UserMenu, type UserTheme } from '@hanzo/iam/react'
|
||||
import { useThemeSetting } from '@hanzogui/next-theme'
|
||||
|
||||
import { config } from '~/config'
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
import { useCloudBalance, spendableCents } from '~/lib/billing/live-balance'
|
||||
|
||||
export function AccountMenu() {
|
||||
const { account, signOut } = useSession()
|
||||
const { balance } = useCloudBalance()
|
||||
const { current, resolvedTheme, set } = useThemeSetting()
|
||||
|
||||
// `system` is a real choice, and the console's provider already understands it.
|
||||
const theme: UserTheme = useMemo(
|
||||
() => ({
|
||||
mode: (current === 'light' || current === 'dark' ? current : 'system') as UserTheme['mode'],
|
||||
resolved: (resolvedTheme ?? current) === 'light' ? 'light' : 'dark',
|
||||
setMode: (mode) => set(mode),
|
||||
}),
|
||||
[current, resolvedTheme, set],
|
||||
)
|
||||
|
||||
if (!account) return null
|
||||
|
||||
const cents = spendableCents(balance)
|
||||
const name = account.displayName?.trim() || account.name
|
||||
|
||||
return (
|
||||
<UserMenu
|
||||
align="up"
|
||||
identity={{
|
||||
name,
|
||||
email: account.email ?? null,
|
||||
initials: (name || '?').slice(0, 1).toUpperCase(),
|
||||
avatarUrl: account.avatar || null,
|
||||
}}
|
||||
isAuthenticated
|
||||
isLoading={false}
|
||||
onSignOut={() => void signOut()}
|
||||
theme={theme}
|
||||
settingsUrl="/profile"
|
||||
usageUrl="/billing"
|
||||
usageLabel="Billing & usage"
|
||||
// Only shown when the backend actually reported a balance — never a fabricated $0.
|
||||
balance={cents === null ? undefined : { amountUsd: cents / 100, topUpUrl: config.payUrl }}
|
||||
items={[
|
||||
// Your people, beside your own settings — the other half of "who am I".
|
||||
// Choosing a DIFFERENT tenant is a different question and lives in the
|
||||
// top-left context switcher, so this menu never re-scopes the console.
|
||||
{ label: 'Members', href: '/team' },
|
||||
{ label: 'Documentation', href: config.docsUrl, external: true, separatorBefore: true },
|
||||
]}
|
||||
brand={{ name: config.brandName }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* All products — the directory where you curate your sidebar. Every Hanzo product
|
||||
* is always available on demand; this panel lists the FULL catalog the viewer may
|
||||
* see (brand-scoped, admin surfaces gated), grouped by category, each row with a
|
||||
* PIN toggle that promotes/removes it from the sidebar's Pinned quick-access section
|
||||
* (via `usePins`). Rendered in the shared DetailPane (opened from the sidebar's
|
||||
* "All products" row).
|
||||
* All products — the directory of every Hanzo app, where you OPEN one and where you
|
||||
* curate your sidebar. Every product is always available on demand; this panel lists
|
||||
* the FULL catalog the viewer may see (brand-scoped, admin surfaces gated), grouped
|
||||
* by category. A row OPENS its app; the pin toggle beside it promotes/removes it from
|
||||
* the sidebar's Pinned quick-access section (via `usePins`). Rendered in the shared
|
||||
* DetailPane (opened from the sidebar's "All products" row).
|
||||
*
|
||||
* Honest by construction: pinning is instant + optimistic (persisted through the
|
||||
* account preferences store — no async error state). Products with REAL org usage
|
||||
@@ -15,16 +15,19 @@
|
||||
* the badges/filter degrade away — never a fabricated "in use".
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button, Input, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Activity, Plus, Search, Star } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { visibleCatalogByCategory, type CatalogEntry, type ProductIcon } from '~/lib/products/registry'
|
||||
import { useAppsBeta } from '~/lib/products/beta'
|
||||
import { useIsSuperAdmin } from '~/lib/auth/admin'
|
||||
import { usePins, useProductColors } from '~/lib/products/pins'
|
||||
import { openProduct } from '~/lib/products/open'
|
||||
import { useDetailPane } from '~/components/DetailPane'
|
||||
import { fetchUsageRecords } from '~/lib/api/aimetrics'
|
||||
import { inUseProductIds } from '~/lib/products/product-usage'
|
||||
import { asColor } from '~/components/ui/color'
|
||||
import { EmptyState } from '~/components/ui/EmptyState'
|
||||
import { EmptyState, asColor } from '@hanzo/ui/product'
|
||||
|
||||
/** The list narrowing controls at the top. */
|
||||
type Filter = 'all' | 'inuse' | 'pinned'
|
||||
@@ -41,23 +44,51 @@ function InUseBadge() {
|
||||
)
|
||||
}
|
||||
|
||||
/** One catalog row: icon · label/description (+ In-use badge) · pin toggle. */
|
||||
/**
|
||||
* One catalog row: icon · label/description (+ In-use badge) · pin toggle.
|
||||
*
|
||||
* The row itself OPENS the product. It used to be an inert `XStack` — a plain div
|
||||
* with `cursor: auto`, no role and no handler — so the one place in the console that
|
||||
* lists every app was a directory you could not walk: the only live control in the
|
||||
* row was the pin. Opening is delegated to the shared `openProduct`, the ONE opener
|
||||
* every other surface (sidebar, ⌘K, category page) already routes through.
|
||||
*
|
||||
* Pin stays a SEPARATE control on the same row, so curating never navigates and
|
||||
* navigating never curates. It stops the press from bubbling into the row for the
|
||||
* same reason.
|
||||
*/
|
||||
function ProductRow({
|
||||
entry,
|
||||
color,
|
||||
pinned,
|
||||
inUse,
|
||||
onOpen,
|
||||
onToggle,
|
||||
}: {
|
||||
entry: CatalogEntry
|
||||
color: string
|
||||
pinned: boolean
|
||||
inUse: boolean
|
||||
onOpen: () => void
|
||||
onToggle: () => void
|
||||
}) {
|
||||
const Icon = entry.icon
|
||||
return (
|
||||
<XStack items="center" gap="$3" py="$2" px="$2" rounded="$3" minH={44} hoverStyle={{ bg: '$color2' }}>
|
||||
<XStack
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onPress={onOpen}
|
||||
cursor="pointer"
|
||||
items="center"
|
||||
gap="$3"
|
||||
py="$2"
|
||||
px="$2"
|
||||
rounded="$3"
|
||||
minH={44}
|
||||
hoverStyle={{ bg: '$color2' }}
|
||||
focusStyle={{ bg: '$color2' }}
|
||||
aria-label={`Open ${entry.label}`}
|
||||
>
|
||||
<YStack width={32} height={32} rounded="$3" bg="$color3" items="center" justify="center">
|
||||
<Icon size={16} color={asColor(color)} />
|
||||
</YStack>
|
||||
@@ -73,7 +104,10 @@ function ProductRow({
|
||||
<Button
|
||||
size="$2"
|
||||
icon={pinned ? <Star size={15} /> : <Plus size={15} />}
|
||||
onPress={onToggle}
|
||||
onPress={(e?: { stopPropagation?: () => void }) => {
|
||||
e?.stopPropagation?.()
|
||||
onToggle()
|
||||
}}
|
||||
bg={pinned ? '$color5' : 'transparent'}
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
@@ -121,6 +155,15 @@ export function AddProductPanel() {
|
||||
const showAdmin = useIsSuperAdmin()
|
||||
const { isPinned, toggle } = usePins()
|
||||
const { colorOf } = useProductColors()
|
||||
const router = useRouter()
|
||||
const detail = useDetailPane()
|
||||
// Opening an app takes you to it: navigate, then close the pane you launched from
|
||||
// (it is a directory, not a destination — leaving it open would cover the page it
|
||||
// just opened).
|
||||
const openEntry = (entry: CatalogEntry) => {
|
||||
openProduct(entry, (path) => router.push(path))
|
||||
detail.close()
|
||||
}
|
||||
const [query, setQuery] = useState('')
|
||||
const [filter, setFilter] = useState<Filter>('all')
|
||||
// Real org usage signal. `null` = not (yet) known; a Set (even empty) = a real
|
||||
@@ -147,7 +190,8 @@ export function AddProductPanel() {
|
||||
|
||||
// Source = the FULL catalog the viewer may see (ungated → both pinned and unpinned
|
||||
// appear), grouped by category.
|
||||
const groups = useMemo(() => visibleCatalogByCategory(showAdmin, null), [showAdmin])
|
||||
const showBeta = useAppsBeta(showAdmin)
|
||||
const groups = useMemo(() => visibleCatalogByCategory(showAdmin, null, showBeta), [showAdmin, showBeta])
|
||||
|
||||
// Literal, case-insensitive substring match over label/description/id — NOT a
|
||||
// compiled RegExp of user input.
|
||||
@@ -226,7 +270,7 @@ export function AddProductPanel() {
|
||||
) : (
|
||||
shown.map((group) => (
|
||||
<YStack key={group.category} gap="$1">
|
||||
<Text fontSize="$1" color="$color10" fontWeight="700" textTransform="uppercase" px="$2">
|
||||
<Text fontSize="$1" color="$color10" fontWeight="500" px="$2">
|
||||
{group.category}
|
||||
</Text>
|
||||
{group.entries.map((entry) => (
|
||||
@@ -236,6 +280,7 @@ export function AddProductPanel() {
|
||||
color={colorOf(entry.id)}
|
||||
pinned={isPinned(entry.id)}
|
||||
inUse={inUse?.has(entry.id) ?? false}
|
||||
onOpen={() => openEntry(entry)}
|
||||
onToggle={() => toggle(entry.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,14 +15,20 @@
|
||||
* settings, switch organization, ask AI / search docs, sign out — ranked by the
|
||||
* same query, so ⌘K is ONE surface for "go somewhere" and "do something".
|
||||
*
|
||||
* PINS are first-class here, not just in the sidebar. A user's pinned products
|
||||
* lead the results (their own pin order, via `pinnedFirst`), and every result
|
||||
* carries a pin at its right edge — quiet until you reach the row, lit while it is
|
||||
* pinned. `⌥↵` pins the selected result WITHOUT leaving the palette, so curating
|
||||
* what you keep at hand is the same gesture as finding it: type, ⌥↵, keep going.
|
||||
*
|
||||
* Everything composes existing pieces: the catalog registry (`searchCatalog` +
|
||||
* `openProduct`), the AI client (`AiApi`), the chrome hooks (theme/launcher/
|
||||
* session/org-scope), and the honest backend-state mapper. Nothing is fabricated —
|
||||
* `openProduct`), the AI client (`AiApi`), the chrome hooks (theme/session/
|
||||
* org-scope), and the honest backend-state mapper. Nothing is fabricated —
|
||||
* AI/RAG failures degrade to a truthful state card.
|
||||
*
|
||||
* Keyboard is handled on `window`: ⌘K toggles from anywhere; while open, ↑/↓ move
|
||||
* the selection (over actions then products), ↵ activates, Esc closes. The header
|
||||
* search box and Apps buttons open it; type `>` for AI, `?` for docs.
|
||||
* the selection (over actions then products), ↵ activates, ⌥↵ pins/unpins, Esc
|
||||
* closes. The header search box and Apps buttons open it; `>` for AI, `?` for docs.
|
||||
*/
|
||||
import {
|
||||
createContext,
|
||||
@@ -57,6 +63,7 @@ import {
|
||||
Lock,
|
||||
LogOut,
|
||||
Moon,
|
||||
Pin,
|
||||
Search,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
@@ -66,16 +73,17 @@ import {
|
||||
|
||||
import { AiApi, IamAdminApi, type Organization } from '~/lib/api'
|
||||
import { findEntry, type CatalogEntry } from '~/lib/products/registry'
|
||||
import { commandBarSystemPrompt, hanzoAssistantSystemPrompt } from '~/lib/assistant'
|
||||
import { assistantState, commandBarSystemPrompt, hanzoAssistantSystemPrompt } from '~/lib/assistant'
|
||||
import { searchDestinations, type Destination } from '~/lib/products/search'
|
||||
import { useProductColors } from '~/lib/products/pins'
|
||||
import { asColor } from '~/components/ui/color'
|
||||
import { DEFAULT_GROUP_LABEL, pinnedFirst } from '~/lib/products/pins-core'
|
||||
import { usePins, useProductColors } from '~/lib/products/pins'
|
||||
import { useAppsBeta } from '~/lib/products/beta'
|
||||
import { ProductIcon } from '~/components/ui/ProductIcon'
|
||||
import { openProduct } from '~/lib/products/open'
|
||||
import { currentOrg, switchOrg } from '~/lib/org-scope'
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
import { useIsSuperAdmin } from '~/lib/auth/admin'
|
||||
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
|
||||
import { BackendStateCard, asColor, type BackendState } from '@hanzo/ui/product'
|
||||
|
||||
const titleCase = (s: string) => (s ? s[0].toUpperCase() + s.slice(1) : s)
|
||||
|
||||
@@ -138,7 +146,7 @@ function Answer({ text }: { text: string }) {
|
||||
</YStack>
|
||||
{urls.length > 0 ? (
|
||||
<YStack gap="$1" borderTopWidth={1} borderColor="$borderColor" pt="$2">
|
||||
<Text fontSize="$1" color="$color10" fontWeight="700" textTransform="uppercase">
|
||||
<Text fontSize="$1" color="$color10" fontWeight="500">
|
||||
Links
|
||||
</Text>
|
||||
{urls.map((u) => (
|
||||
@@ -152,20 +160,60 @@ function Answer({ text }: { text: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The pin at a result row's right edge. Quiet by construction: invisible until the
|
||||
* row is reached (hovered via `.hz-pin` + `.hz-row-pin:hover`, or selected by the
|
||||
* keyboard), and steady once the product is pinned — so the control appears where
|
||||
* the eye already is and the lit ones read as state, not decoration.
|
||||
*/
|
||||
function PinToggle({ label, pinned, active, onPress }: { label: string; pinned: boolean; active: boolean; onPress: () => void }) {
|
||||
return (
|
||||
<XStack
|
||||
className={pinned || active ? undefined : 'hz-pin'}
|
||||
onPress={(e: { stopPropagation?: () => void }) => {
|
||||
// The row itself navigates; pinning must not also open the product.
|
||||
e?.stopPropagation?.()
|
||||
onPress()
|
||||
}}
|
||||
cursor="pointer"
|
||||
items="center"
|
||||
justify="center"
|
||||
width={26}
|
||||
height={26}
|
||||
rounded="$2"
|
||||
// NO inline `opacity` prop: Gui compiles one to an atomic rule it injects at
|
||||
// `:root ._ops-…` (specificity 0,2,0), which would outrank the `.hz-pin`
|
||||
// utility and paint the resting pin at full strength. Resting visibility is
|
||||
// the CSS utility's job alone; presence/absence of the class is the switch.
|
||||
hoverStyle={{ bg: '$color6' }}
|
||||
role="button"
|
||||
aria-pressed={pinned}
|
||||
aria-label={`${pinned ? 'Unpin' : 'Pin'} ${label}`}
|
||||
>
|
||||
<Pin size={13} color={pinned ? '$color12' : '$color10'} fill={pinned ? 'currentColor' : 'none'} />
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
function CatalogRow({
|
||||
entry,
|
||||
active,
|
||||
color,
|
||||
pinned,
|
||||
onPin,
|
||||
onPress,
|
||||
}: {
|
||||
entry: CatalogEntry
|
||||
active: boolean
|
||||
color?: string
|
||||
pinned?: boolean
|
||||
onPin?: () => void
|
||||
onPress: () => void
|
||||
}) {
|
||||
const Icon = entry.icon
|
||||
return (
|
||||
<XStack
|
||||
className="hz-row-pin"
|
||||
onPress={onPress}
|
||||
cursor="pointer"
|
||||
items="center"
|
||||
@@ -188,25 +236,45 @@ function CatalogRow({
|
||||
</Text>
|
||||
</YStack>
|
||||
{entry.admin ? <Lock size={13} opacity={0.45} /> : null}
|
||||
{onPin ? <PinToggle label={entry.label} pinned={pinned === true} active={active} onPress={onPin} /> : null}
|
||||
<ArrowRight size={13} opacity={active ? 0.8 : 0.3} />
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
/** A ⌘K result — a product (via CatalogRow) or a deep sub-page jump. */
|
||||
/**
|
||||
* A ⌘K result — a product (via CatalogRow) or a deep sub-page jump.
|
||||
*
|
||||
* Only PRODUCTS carry a pin: the pin model keys on a product id, so pinning a
|
||||
* sub-page would either silently pin its parent (a lie about what you clicked) or
|
||||
* need a second pin model. One model, so sub-pages simply don't offer the control.
|
||||
*/
|
||||
function DestinationRow({
|
||||
dest,
|
||||
active,
|
||||
colorOf,
|
||||
pinned,
|
||||
onPin,
|
||||
onPress,
|
||||
}: {
|
||||
dest: Destination
|
||||
active: boolean
|
||||
colorOf: (id: string) => string
|
||||
pinned?: boolean
|
||||
onPin?: () => void
|
||||
onPress: () => void
|
||||
}) {
|
||||
if (dest.kind === 'product')
|
||||
return <CatalogRow entry={dest.entry} active={active} color={colorOf(dest.entry.id)} onPress={onPress} />
|
||||
return (
|
||||
<CatalogRow
|
||||
entry={dest.entry}
|
||||
active={active}
|
||||
color={colorOf(dest.entry.id)}
|
||||
pinned={pinned}
|
||||
onPin={onPin}
|
||||
onPress={onPress}
|
||||
/>
|
||||
)
|
||||
const { entry, subpage } = dest
|
||||
const Icon = subpage.icon ?? entry.icon
|
||||
return (
|
||||
@@ -279,7 +347,7 @@ function ActionRow({
|
||||
/** A small uppercase section label inside the palette result list. */
|
||||
function SectionLabel({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<Text px="$3" pt="$2" pb="$1" fontSize="$1" color="$color10" fontWeight="700" textTransform="uppercase">
|
||||
<Text px="$3" pt="$2" pb="$1" fontSize="$1" color="$color10" fontWeight="500">
|
||||
{children}
|
||||
</Text>
|
||||
)
|
||||
@@ -297,7 +365,9 @@ function PaletteDialog({
|
||||
const router = useRouter()
|
||||
const { signOut } = useSession()
|
||||
const showAdmin = useIsSuperAdmin()
|
||||
const showBeta = useAppsBeta(showAdmin)
|
||||
const { colorOf } = useProductColors()
|
||||
const pins = usePins()
|
||||
const { current, resolvedTheme, set: setTheme } = useThemeSetting()
|
||||
const isDark = (resolvedTheme ?? current ?? 'dark') !== 'light'
|
||||
const [query, setQuery] = useState(seed)
|
||||
@@ -329,7 +399,7 @@ function PaletteDialog({
|
||||
}, [open, showAdmin])
|
||||
|
||||
// Every command the palette can RUN (verbs + per-org switches). Composed from the
|
||||
// same pieces the chrome uses (router, launcher, theme, session, org scope) — no
|
||||
// same pieces the chrome uses (router, theme, session, org scope) — no
|
||||
// dead entries: each `run` is wired.
|
||||
const actions = useMemo<PaletteAction[]>(() => {
|
||||
const cur = currentOrg()
|
||||
@@ -359,11 +429,22 @@ function PaletteDialog({
|
||||
// ⌘K is a DISCOVERY surface: it jumps to the WHOLE catalog (admin-gated only), NOT
|
||||
// the org's entitled scope — entitlement governs the sidebar + product use, never
|
||||
// what you can find/jump to. Admin-only operator surfaces stay gated by `showAdmin`.
|
||||
//
|
||||
// PINS ORDER THE DEFAULT VIEW; TYPING ORDERS ITSELF. With no query the list is the
|
||||
// user's own — their pinned products lead, in their own pin order, exactly as the
|
||||
// sidebar's Pinned section does. The moment they type, RELEVANCE decides and pins
|
||||
// step aside.
|
||||
//
|
||||
// That split is not fussiness, it is a bug this cost: floating pins over a ranked
|
||||
// query meant a barely-matching pinned product outranked an exact name match, and
|
||||
// typing "billing" + ↵ opened Models. A search that ignores what you typed is not
|
||||
// a search, so the ranked branch is left strictly alone.
|
||||
const destResults = useMemo(() => {
|
||||
if (mode !== 'catalog') return []
|
||||
const found = searchDestinations(query, showAdmin, null)
|
||||
return sub ? found.slice(0, 50) : found
|
||||
}, [mode, query, sub, showAdmin])
|
||||
const found = searchDestinations(query, showAdmin, null, showBeta)
|
||||
if (sub) return found.slice(0, 50)
|
||||
return pinnedFirst(found, (d) => (d.kind === 'product' ? d.entry.id : ''), pins.pinnedIds)
|
||||
}, [mode, query, sub, showAdmin, pins.pinnedIds])
|
||||
|
||||
const matchedActions = useMemo(
|
||||
() => (mode === 'catalog' && sub ? actions.filter((a) => actionMatches(a, sub.toLowerCase())) : []),
|
||||
@@ -382,17 +463,22 @@ function PaletteDialog({
|
||||
|
||||
// Empty query is the Apps browse state: the same destinations, grouped for scan
|
||||
// speed. Typing switches to one ranked list without opening a second overlay.
|
||||
//
|
||||
// Pinned products already lead `destResults`, so labelling them by "Pinned"
|
||||
// rather than their category collects them into ONE leading section — the same
|
||||
// section, in the same order, under the same word the sidebar uses.
|
||||
const browseGroups = useMemo(() => {
|
||||
if (mode !== 'catalog' || sub) return []
|
||||
const groups: { category: string; rows: { dest: Destination; index: number }[] }[] = []
|
||||
destResults.forEach((dest, index) => {
|
||||
const category = dest.entry.category
|
||||
const category =
|
||||
dest.kind === 'product' && pins.isPinned(dest.entry.id) ? DEFAULT_GROUP_LABEL : dest.entry.category
|
||||
const last = groups[groups.length - 1]
|
||||
if (last?.category === category) last.rows.push({ dest, index })
|
||||
else groups.push({ category, rows: [{ dest, index }] })
|
||||
})
|
||||
return groups
|
||||
}, [mode, sub, destResults])
|
||||
}, [mode, sub, destResults, pins])
|
||||
|
||||
// Seed the query each time the palette opens.
|
||||
useEffect(() => {
|
||||
@@ -423,6 +509,18 @@ function PaletteDialog({
|
||||
[onOpenChange, router],
|
||||
)
|
||||
|
||||
/**
|
||||
* Pin/unpin a destination WITHOUT closing — curating is a repeated act, and an
|
||||
* overlay that dismisses itself after each one would make pinning five products
|
||||
* five round trips. A sub-page has no pin (see `DestinationRow`), so it no-ops.
|
||||
*/
|
||||
const togglePin = useCallback(
|
||||
(dest: Destination) => {
|
||||
if (dest.kind === 'product') pins.toggle(dest.entry.id)
|
||||
},
|
||||
[pins],
|
||||
)
|
||||
|
||||
const submit = useCallback(async () => {
|
||||
if (!sub) return
|
||||
setRun({ status: 'loading' })
|
||||
@@ -445,7 +543,7 @@ function PaletteDialog({
|
||||
setRun({ status: 'text', text: ans })
|
||||
}
|
||||
} catch (e) {
|
||||
setRun({ status: 'error', state: classifyBackend(e) })
|
||||
setRun({ status: 'error', state: assistantState(e) })
|
||||
}
|
||||
}, [mode, sub, showAdmin])
|
||||
|
||||
@@ -467,10 +565,16 @@ function PaletteDialog({
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
const it = items[sel]
|
||||
if (it) {
|
||||
if (it.kind === 'action') it.action.run()
|
||||
else activateDest(it.dest)
|
||||
if (!it) return
|
||||
// ⌥↵ is the secondary verb on the selection: keep this, stay here.
|
||||
// ↵ is the primary: go there. Same key, one modifier apart, so the
|
||||
// pair is learnable from the legend without a second shortcut to know.
|
||||
if (e.altKey) {
|
||||
if (it.kind === 'dest') togglePin(it.dest)
|
||||
return
|
||||
}
|
||||
if (it.kind === 'action') it.action.run()
|
||||
else activateDest(it.dest)
|
||||
}
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
@@ -481,7 +585,7 @@ function PaletteDialog({
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [open, mode, items, sel, run, submit, activate, activateDest, onOpenChange])
|
||||
}, [open, mode, items, sel, run, submit, activate, activateDest, togglePin, onOpenChange])
|
||||
|
||||
// Keep the ↑/↓-selected row visible: as selection moves past the fold, scroll
|
||||
// the active row into view (the list can hold 50 results — well beyond 560px).
|
||||
@@ -536,6 +640,10 @@ function PaletteDialog({
|
||||
placeholder={placeholder}
|
||||
fontSize="$4"
|
||||
color="$color12"
|
||||
// `unstyled` still leaves Gui's own 3px base radius, which belongs to
|
||||
// no scale and is visible as a faint rounding inside the palette's
|
||||
// own 12px panel. A field that fills its container is flat.
|
||||
rounded="$0"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
@@ -569,6 +677,8 @@ function PaletteDialog({
|
||||
dest={dest}
|
||||
active={index === sel}
|
||||
colorOf={colorOf}
|
||||
pinned={dest.kind === 'product' && pins.isPinned(dest.entry.id)}
|
||||
onPin={dest.kind === 'product' ? () => togglePin(dest) : undefined}
|
||||
onPress={() => activateDest(dest)}
|
||||
/>
|
||||
</YStack>
|
||||
@@ -592,6 +702,8 @@ function PaletteDialog({
|
||||
dest={dest}
|
||||
active={i === sel}
|
||||
colorOf={colorOf}
|
||||
pinned={dest.kind === 'product' && pins.isPinned(dest.entry.id)}
|
||||
onPin={dest.kind === 'product' ? () => togglePin(dest) : undefined}
|
||||
onPress={() => activateDest(dest)}
|
||||
/>
|
||||
)
|
||||
@@ -641,6 +753,7 @@ function PaletteDialog({
|
||||
>
|
||||
<Legend keys="↑↓" label="navigate" />
|
||||
<Legend keys="↵" label="open" />
|
||||
<Legend keys="⌥↵" label="pin" />
|
||||
<Legend keys=">" label="AI" />
|
||||
<Legend keys="?" label="docs" />
|
||||
<XStack flex={1} />
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Context switcher — WHERE you are: the organization and the project, in ONE
|
||||
* control, at the TOP-LEFT where the tenant's mark already sits.
|
||||
*
|
||||
* The console used to answer "who and where am I" from three different corners:
|
||||
* the org at the top of the rail, the account (which also switched org) at its
|
||||
* foot, and the project chip in the top-right beside the network. Org and
|
||||
* project are one question — which tenant, and which slice of it — so they are
|
||||
* one control, and it sits with the org mark that already anchors the top-left.
|
||||
*
|
||||
* The ACCOUNT keeps the other question ("who am I": identity, team, personal
|
||||
* settings, the way out) at the foot of the rail. The NETWORK stays its own
|
||||
* control in the top-right, because it is a global MODE rather than a place —
|
||||
* and its tier dot is a destructive-environment guard, not decoration.
|
||||
*
|
||||
* There is still exactly ONE org switch. `switchOrg` is passed by reference from
|
||||
* `~/lib/org-scope` (the seam that persists the scope and reloads so every
|
||||
* module refetches under the new `X-Org-Id`, which is where tenant scoping and
|
||||
* its billing attribution already live). This control does not mint a second
|
||||
* one, add a header of its own, or make a billing call — `org-state.test.ts`
|
||||
* pins that identity. Cross-tenant reach is the SAME admin-gated, server-paged
|
||||
* list the full-page picker uses; a regular user never fires it and sees only
|
||||
* their own org.
|
||||
*/
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button, Popover, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { ChevronsUpDown, FolderGit2, Plus } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { useScope } from '~/lib/scope-context'
|
||||
import { useOrgIdentity } from '~/components/ui/BrandLogo'
|
||||
import { useIsSuperAdmin } from '~/lib/auth/admin'
|
||||
import { IamAdminApi, type Organization } from '~/lib/api'
|
||||
import { ORG_PAGE_SIZE, orgQuery } from '~/lib/org-list'
|
||||
import { currentOrg, leaveOrg, switchOrg } from '~/lib/org-scope'
|
||||
import { contextLabel, scopedOrgRow, titleCase } from '~/lib/account/org-state'
|
||||
import { MenuRow } from '~/components/ui/MenuRow'
|
||||
import { paper } from '~/components/ui/paper'
|
||||
import { SearchInput } from '@hanzo/ui/product'
|
||||
|
||||
export function ContextSwitcher() {
|
||||
const router = useRouter()
|
||||
const org = useOrgIdentity()
|
||||
const scoped = currentOrg()
|
||||
const isSuperAdmin = useIsSuperAdmin()
|
||||
const { scope, projects, loadingProjects, selectProject } = useScope()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [orgs, setOrgs] = useState<Organization[] | null>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
// IAM's display name when it has one; otherwise the slug, titled the same way
|
||||
// `scopedOrgRow` titles it — one rule, so the trigger and the list agree.
|
||||
const orgLabel = org.displayName || titleCase(org.name || scoped)
|
||||
|
||||
// The cross-tenant list is admin-gated at the proxy; a regular user would 403
|
||||
// it, so they are never asked to — their own org is the honest answer. An admin
|
||||
// searches the SERVER (the list is paged and far longer than one page), which is
|
||||
// the only way to reach a tenant nobody is a member of.
|
||||
const loadOrgs = useCallback(
|
||||
async (q: string) => {
|
||||
if (!isSuperAdmin) return setOrgs(scopedOrgRow(scoped) as Organization[])
|
||||
const res = await IamAdminApi.organizations(orgQuery(0, q, ORG_PAGE_SIZE))
|
||||
setOrgs(res.rows ?? [])
|
||||
},
|
||||
[isSuperAdmin, scoped],
|
||||
)
|
||||
|
||||
const onOpenChange = useCallback(
|
||||
(next: boolean) => {
|
||||
setOpen(next)
|
||||
if (next && orgs === null) void loadOrgs('')
|
||||
},
|
||||
[orgs, loadOrgs],
|
||||
)
|
||||
|
||||
const search = useCallback(
|
||||
(q: string) => {
|
||||
setQuery(q)
|
||||
void loadOrgs(q)
|
||||
},
|
||||
[loadOrgs],
|
||||
)
|
||||
|
||||
const pick = useCallback(
|
||||
(fn: () => void) => () => {
|
||||
setOpen(false)
|
||||
fn()
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const orgRows = useMemo(() => orgs ?? [], [orgs])
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={onOpenChange} placement="bottom-start">
|
||||
<Popover.Trigger asChild>
|
||||
<Button
|
||||
size="$3"
|
||||
chromeless
|
||||
justify="flex-start"
|
||||
px="$2"
|
||||
data-testid="switcher-context"
|
||||
iconAfter={<ChevronsUpDown size={13} opacity={0.6} />}
|
||||
aria-label={`Organization and project — ${contextLabel(orgLabel, scope.project)}`}
|
||||
>
|
||||
{org.logo ? (
|
||||
// The org's own logo IS the label — the uploaded mark takes the
|
||||
// slot the name held, height-capped to the row so any aspect fits.
|
||||
// A scoped project keeps its text beside it; the full text stays
|
||||
// in the aria-label either way. Arbitrary tenant URL/data URL, so
|
||||
// a raw <img> (next/image would need a per-tenant remote
|
||||
// allow-list) — same call BrandLogo makes.
|
||||
<XStack items="center" gap="$2" flex={1} minW={0}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={org.logo}
|
||||
alt={orgLabel}
|
||||
style={{ height: 22, width: 'auto', maxWidth: 140, objectFit: 'contain', display: 'block' }}
|
||||
/>
|
||||
{scope.project ? (
|
||||
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1} flex={1}>
|
||||
/ {scope.project}
|
||||
</Text>
|
||||
) : null}
|
||||
</XStack>
|
||||
) : (
|
||||
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1} flex={1}>
|
||||
{contextLabel(orgLabel, scope.project)}
|
||||
</Text>
|
||||
)}
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content {...paper} p="$2" width={280}>
|
||||
<YStack gap="$0.5">
|
||||
<Text px="$2" py="$1" fontSize="$1" color="$color10" fontWeight="500">
|
||||
Organization
|
||||
</Text>
|
||||
|
||||
{/* Admins only: the cross-tenant list is server-paged and longer than
|
||||
one page, so reaching a tenant nobody is a member of means SEARCHING
|
||||
it, not scrolling. A regular user has one org and no field. */}
|
||||
{isSuperAdmin ? (
|
||||
<YStack px="$1" pb="$1">
|
||||
{/* A search landmark names the control for assistive tech — the shared
|
||||
SearchInput has no accessible-name prop of its own. */}
|
||||
<div role="search" aria-label="Find an organization">
|
||||
<SearchInput value={query} onChange={search} placeholder="Find an organization" name="org" />
|
||||
</div>
|
||||
</YStack>
|
||||
) : null}
|
||||
|
||||
<YStack role="radiogroup" aria-label="Organizations" gap="$0.5">
|
||||
{orgRows.map((o) => (
|
||||
<MenuRow
|
||||
key={o.name}
|
||||
label={o.displayName || o.name}
|
||||
active={scoped === o.name}
|
||||
onPress={pick(() => {
|
||||
if (o.name !== scoped) switchOrg(o.name)
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</YStack>
|
||||
|
||||
{orgRows.length === 0 ? (
|
||||
<Text px="$2" py="$1.5" fontSize="$2" color="$color10">
|
||||
{orgs === null ? 'Loading…' : 'No organization matches that.'}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<MenuRow label="All organizations" icon={<Plus size={14} />} onPress={pick(leaveOrg)} />
|
||||
|
||||
<XStack height={1} bg="$borderColor" my="$1" />
|
||||
|
||||
<Text px="$2" py="$1" fontSize="$1" color="$color10" fontWeight="500">
|
||||
Project
|
||||
</Text>
|
||||
|
||||
<YStack role="radiogroup" aria-label="Projects" gap="$0.5">
|
||||
{/* Org-level scope — no X-Project-Id sent. */}
|
||||
<MenuRow
|
||||
label="All projects"
|
||||
sub="Org-level"
|
||||
active={!scope.project}
|
||||
onPress={pick(() => selectProject(undefined))}
|
||||
/>
|
||||
|
||||
{projects.map((p) => (
|
||||
<MenuRow
|
||||
key={p.name}
|
||||
label={p.displayName || p.name}
|
||||
active={scope.project === p.name}
|
||||
onPress={pick(() => selectProject(p.name))}
|
||||
/>
|
||||
))}
|
||||
</YStack>
|
||||
|
||||
{projects.length === 0 && !loadingProjects ? (
|
||||
<Text px="$2" py="$1.5" fontSize="$2" color="$color10">
|
||||
No projects yet.
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<MenuRow
|
||||
label="New project"
|
||||
icon={<FolderGit2 size={14} />}
|
||||
onPress={pick(() => router.push('/projects'))}
|
||||
/>
|
||||
</YStack>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -28,7 +28,8 @@ import { Button, ScrollView, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { X } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { SlideOver } from '~/components/ui/SlideOver'
|
||||
import { asColor, type IconLike } from '~/components/ui/color'
|
||||
import { Z } from '~/lib/z'
|
||||
import { asColor, type IconLike } from '@hanzo/ui/product'
|
||||
|
||||
export type DetailDescriptor = {
|
||||
/** Pane title (the item's name). */
|
||||
@@ -82,7 +83,7 @@ export function DetailPane({ children }: { children: ReactNode }) {
|
||||
side="right"
|
||||
size={desc?.size ?? 460}
|
||||
ariaLabel={typeof desc?.title === 'string' ? desc?.title : 'Details'}
|
||||
zIndex={1100}
|
||||
zIndex={Z.popover}
|
||||
>
|
||||
{/* Bare-mode layout: fixed header · scroll body · optional sticky footer. */}
|
||||
<XStack items="center" gap="$2.5" px="$4" height={56} borderBottomWidth={1} borderColor="$borderColor">
|
||||
|
||||
+120
-59
@@ -15,7 +15,23 @@
|
||||
*
|
||||
* Docking is a desktop concern (a phone has no room for a permanent column), so on
|
||||
* `<lg` the assistant is ALWAYS the floating bubble/sheet regardless of the dock
|
||||
* choice; `docked` only reserves the right column at `lg+`.
|
||||
* choice. That fact — the persisted CHOICE against a viewport that can honor it —
|
||||
* meets in exactly one place, `column`, and it is what every shape is chosen by.
|
||||
*
|
||||
* It is not a detail. The sheet is a MODAL dialog, so leaving it open behind a hidden
|
||||
* column put a full-viewport dialog over the page: the column painted, and every click
|
||||
* on it landed on the dialog instead — an assistant you could read and could not type
|
||||
* into. Hiding one of two open surfaces with CSS cannot fix that, because `display:
|
||||
* none` on the dialog's own content does not make the dialog stop being modal. So only
|
||||
* one is ever open, and `column` is the one fact that says which.
|
||||
*
|
||||
* The assistant has ONE entry point and it lives HERE: `AssistantFab`, a floating
|
||||
* control fixed bottom-right over every dashboard page. It used to be two small
|
||||
* buttons in the topbar (a brand-H and a mic), which put the assistant in a third
|
||||
* place — beside the search box, competing with the org/theme/alert chrome — while
|
||||
* this module owned every other shape it can take. Chat and voice are the same
|
||||
* surface opened two ways, so they sit together, in the corner the assistant
|
||||
* actually appears in.
|
||||
*
|
||||
* Every shape REUSES the one working chat surface (`ChatConversation` → `AiApi.chat`
|
||||
* → the keyless `/ai` proxy → /v1/chat/completions). Nothing about AI is rebuilt
|
||||
@@ -25,27 +41,33 @@
|
||||
*/
|
||||
import { createContext, useCallback, useContext, useState, type ReactNode } from 'react'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { Button, Dialog, Text, VisuallyHidden, XStack, YStack } from '@hanzo/gui'
|
||||
import { PanelRight, PanelRightClose, Sparkles, X } from '@hanzogui/lucide-icons-2'
|
||||
import { Button, Dialog, Text, VisuallyHidden, XStack, YStack, useMedia } from '@hanzo/gui'
|
||||
import { Mic, PanelRight, PanelRightClose, Sparkles, X } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { ChatConversation } from '~/components/products/chat/ChatConversation'
|
||||
// NB: the old `BrandMark` bubble import was removed with the floating circle — the
|
||||
// assistant now opens from the topbar's small brand-H + mic controls.
|
||||
import { BrandMark } from '~/components/ui/BrandLogo'
|
||||
import { usePreferences } from '~/lib/products/preferences'
|
||||
import { voiceSupported } from '~/lib/voice'
|
||||
import { Z } from '~/lib/z'
|
||||
|
||||
type FloatingChatApi = {
|
||||
isOpen: boolean
|
||||
open: () => void
|
||||
close: () => void
|
||||
toggle: () => void
|
||||
/** True when the assistant is docked as a permanent right column (persisted). */
|
||||
docked: boolean
|
||||
/**
|
||||
* True when the permanent right column IS the assistant right now: the dock choice,
|
||||
* a viewport wide enough to hold it, and a page that is not already a composer.
|
||||
* Every surface reads this rather than the raw choice, so exactly one composer is
|
||||
* on screen at any width.
|
||||
*/
|
||||
column: boolean
|
||||
setDocked: (v: boolean) => void
|
||||
/**
|
||||
* Open the assistant with a PRE-FILLED prompt (e.g. "Ask AI about this code" from the
|
||||
* Code hub). The composer is seeded and focused; the user reviews and sends (never an
|
||||
* auto-send — no surprise billing), matching the suggested-prompt UX. Opens the floating
|
||||
* sheet when floating; when docked, the permanent column receives the seed.
|
||||
* auto-send — no surprise billing), matching the suggested-prompt UX. The column takes
|
||||
* the seed when it is the assistant; otherwise the sheet opens with it.
|
||||
*/
|
||||
ask: (prompt: string) => void
|
||||
/** The current pending seed for the composer (consumed once by the active conversation). */
|
||||
@@ -114,17 +136,14 @@ function ChatSheet({
|
||||
onOpenChange,
|
||||
onHistory,
|
||||
onDock,
|
||||
docked,
|
||||
seed,
|
||||
voiceSignal,
|
||||
}: {
|
||||
/** Open ONLY when the sheet is the assistant — never alongside the column. */
|
||||
open: boolean
|
||||
onOpenChange: (o: boolean) => void
|
||||
onHistory: () => void
|
||||
onDock: () => void
|
||||
/** When docked (desktop), the permanent column is the surface — so the floating
|
||||
* sheet is suppressed at lg+ (it still serves phones, which have no column). */
|
||||
docked: boolean
|
||||
/** Pre-fill seed for the composer (from `useFloatingChat().ask`). */
|
||||
seed?: string | null
|
||||
/** Voice-start signal forwarded to the conversation ("talk to Hanzo"). */
|
||||
@@ -136,12 +155,7 @@ function ChatSheet({
|
||||
return (
|
||||
<Dialog modal open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay
|
||||
key="chat-overlay"
|
||||
className="hz-scrim-in"
|
||||
bg="rgba(0,0,0,0.5)"
|
||||
$lg={{ bg: 'transparent', display: docked ? 'none' : undefined }}
|
||||
/>
|
||||
<Dialog.Overlay key="chat-overlay" className="hz-scrim-in" bg="rgba(0,0,0,0.5)" $lg={{ bg: 'transparent' }} />
|
||||
<Dialog.Content
|
||||
key="chat-content"
|
||||
className="hz-paper hz-pop-in"
|
||||
@@ -158,18 +172,8 @@ function ChatSheet({
|
||||
width="100vw"
|
||||
height="100dvh"
|
||||
rounded="$0"
|
||||
// Desktop (≥lg): a compact popover bottom-right, above the bubble. Docked →
|
||||
// hidden at lg+ (the permanent right column replaces it).
|
||||
$lg={{
|
||||
t: 'auto',
|
||||
l: 'auto',
|
||||
b: 88,
|
||||
r: 24,
|
||||
width: 380,
|
||||
height: 560,
|
||||
rounded: '$6',
|
||||
display: docked ? 'none' : undefined,
|
||||
}}
|
||||
// Desktop (≥lg): a compact popover bottom-right, above the bubble.
|
||||
$lg={{ t: 'auto', l: 'auto', b: 88, r: 24, width: 380, height: 560, rounded: '$6' }}
|
||||
>
|
||||
<VisuallyHidden>
|
||||
<Dialog.Title>Assistant</Dialog.Title>
|
||||
@@ -204,6 +208,64 @@ function ChatSheet({
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The floating assistant control — bottom-right, on every dashboard page.
|
||||
*
|
||||
* Two ways into one surface, side by side: the brand mark opens the assistant (the
|
||||
* docked right column on a laptop, the full sheet on a phone) and the mic opens it
|
||||
* listening. The mic renders only where the browser can actually listen, so there is
|
||||
* never a dead control.
|
||||
*
|
||||
* It sits ABOVE the Developers dock at `lg+` (that dock's collapsed bar is 44px and
|
||||
* exists only there), and its caller suppresses it exactly where the assistant is
|
||||
* already on screen: while the sheet is open, on the pages that ARE a composer
|
||||
* (`/chat`, `/playground`), and while the assistant IS the column.
|
||||
*/
|
||||
function AssistantFab({ onOpen, onVoice }: { onOpen: () => void; onVoice: () => void }) {
|
||||
const [voiceOk] = useState(() => voiceSupported())
|
||||
return (
|
||||
<XStack
|
||||
testID="assistant-fab"
|
||||
position="fixed"
|
||||
r={20}
|
||||
b={20}
|
||||
$lg={{ b: 64 }}
|
||||
items="center"
|
||||
gap="$2"
|
||||
style={{ zIndex: Z.raised }}
|
||||
>
|
||||
{voiceOk ? (
|
||||
<Button
|
||||
className="hz-paper"
|
||||
size="$3"
|
||||
circular
|
||||
width={44}
|
||||
height={44}
|
||||
bg="$color2"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
icon={<Mic size={18} />}
|
||||
onPress={onVoice}
|
||||
aria-label="Talk to Hanzo"
|
||||
/>
|
||||
) : null}
|
||||
<Button
|
||||
className="hz-paper"
|
||||
size="$4"
|
||||
circular
|
||||
width={52}
|
||||
height={52}
|
||||
bg="$color2"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
icon={<BrandMark size={22} />}
|
||||
onPress={onOpen}
|
||||
aria-label="Ask Hanzo"
|
||||
/>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The DOCKED assistant — a permanent right column. Rendered by `Dashboard`
|
||||
* inside the layout's reserved right rail (lg+ only), so it reserves space beside
|
||||
@@ -229,30 +291,36 @@ export function Chat({ children }: { children: ReactNode }) {
|
||||
const { get, set } = usePreferences()
|
||||
const docked = get<boolean>('chatDocked', false)
|
||||
const setDocked = useCallback((v: boolean) => set('chatDocked', v), [set])
|
||||
// The bubble is redundant — and OVERLAPS the composer's send control — on the
|
||||
// pages that ARE a full chat/composer surface. Suppress it there (the assistant
|
||||
// is still openable programmatically via `useFloatingChat`); every other page
|
||||
// keeps the one-tap bubble.
|
||||
const media = useMedia()
|
||||
// The pages that ARE a full composer already show the assistant, so no other shape
|
||||
// of it belongs on them — the bubble would overlap the page's own send control, and
|
||||
// the column would be a second composer beside the first.
|
||||
const onChatSurface =
|
||||
pathname === '/chat' ||
|
||||
pathname.startsWith('/chat/') ||
|
||||
pathname === '/playground' ||
|
||||
pathname.startsWith('/playground/')
|
||||
// Every fact about which shape the assistant takes meets here and nowhere else: the
|
||||
// persisted choice, a viewport wide enough to honor it, and whether this page is
|
||||
// already a composer. A JS fact, not a media prop, because it decides what MOUNTS —
|
||||
// a modal dialog that is merely hidden is still modal, and still eats every click.
|
||||
const column = docked && media.lg && !onChatSurface
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const open = useCallback(() => setIsOpen(true), [])
|
||||
const close = useCallback(() => setIsOpen(false), [])
|
||||
const toggle = useCallback(() => setIsOpen((v) => !v), [])
|
||||
|
||||
// Seed the composer from anywhere (e.g. the Code hub's "Ask AI about this code").
|
||||
// Open the floating sheet when floating; when docked, the permanent column is already
|
||||
// on screen and receives the seed, so opening the (hidden) sheet is skipped.
|
||||
// The column is already on screen and receives the seed; otherwise open the sheet —
|
||||
// including on a phone whose owner once docked on a laptop, where the choice is
|
||||
// remembered but no column exists to deliver the prompt.
|
||||
const [seed, setSeed] = useState<string | null>(null)
|
||||
const ask = useCallback(
|
||||
(prompt: string) => {
|
||||
setSeed(prompt)
|
||||
if (!docked) setIsOpen(true)
|
||||
if (!column) setIsOpen(true)
|
||||
},
|
||||
[docked],
|
||||
[column],
|
||||
)
|
||||
|
||||
const onHistory = useCallback(() => {
|
||||
@@ -270,10 +338,9 @@ export function Chat({ children }: { children: ReactNode }) {
|
||||
// conversation opens the mic on change.
|
||||
const [voiceSignal, setVoiceSignal] = useState(0)
|
||||
|
||||
// The topbar brand-H entry: TOGGLE the assistant. Desktop → the docked right
|
||||
// sidebar column; phones (no column) → the full sheet. Setting both in tandem is
|
||||
// correct because at lg+ the sheet is suppressed while docked, and below lg the
|
||||
// dock column is display:none — so one control opens the right surface per viewport.
|
||||
// The topbar brand-H entry: TOGGLE the assistant. Desktop → the right column;
|
||||
// phones (no column) → the full sheet. Both are set because `column` then admits
|
||||
// exactly one of them per viewport.
|
||||
const openChat = useCallback(() => {
|
||||
const next = !docked
|
||||
setDocked(next)
|
||||
@@ -289,29 +356,23 @@ export function Chat({ children }: { children: ReactNode }) {
|
||||
}, [setDocked])
|
||||
|
||||
return (
|
||||
<Ctx.Provider value={{ isOpen, open, close, toggle, docked, setDocked, ask, seed, openChat, startVoice, voiceSignal }}>
|
||||
<Ctx.Provider value={{ isOpen, open, close, toggle, column, setDocked, ask, seed, openChat, startVoice, voiceSignal }}>
|
||||
{children}
|
||||
|
||||
{/* The bubble — fixed bottom-right over every page. Hidden while open (the
|
||||
sheet's own close is the single dismiss). Hidden on the chat/playground
|
||||
surfaces (would overlap the page composer). At lg+ it is ALSO hidden when
|
||||
docked (the permanent column is the surface); on phones it always shows,
|
||||
since docking has no room there. */}
|
||||
{/* NO floating bubble — the big circle that covered page content is gone. The
|
||||
assistant is opened from the topbar (the small brand-H "chat" control + the
|
||||
"talk to Hanzo" mic), so the user's OWN brand leads the chrome and AI help is
|
||||
one small press away. `open`/`toggle`/`ask` still drive it programmatically
|
||||
(e.g. the Code hub's "Ask AI"). */}
|
||||
{/* The assistant's ONE entry point — bottom-right, over every page. Hidden
|
||||
while the sheet is open (its own close is the single dismiss), on the pages
|
||||
that ARE a composer, and while the column is the surface. `open`/`toggle`/
|
||||
`ask` still drive the assistant programmatically (e.g. "Ask AI"). */}
|
||||
{isOpen || onChatSurface || column ? null : <AssistantFab onOpen={openChat} onVoice={startVoice} />}
|
||||
|
||||
{/* The floating sheet. Suppressed on the full chat/playground surfaces (the page
|
||||
IS the composer) and, at lg+, while docked (the right column is the surface);
|
||||
on phones it's the assistant even when docked. */}
|
||||
{/* The floating sheet — the assistant wherever the column is not: every width on
|
||||
a phone or tablet, and on a laptop until the user docks it. Never open at the
|
||||
same time as the column, so there is one composer and it takes the click. */}
|
||||
<ChatSheet
|
||||
open={isOpen && !onChatSurface}
|
||||
open={isOpen && !onChatSurface && !column}
|
||||
onOpenChange={setIsOpen}
|
||||
onHistory={onHistory}
|
||||
onDock={dock}
|
||||
docked={docked}
|
||||
seed={seed}
|
||||
voiceSignal={voiceSignal}
|
||||
/>
|
||||
|
||||
@@ -20,11 +20,27 @@ import { Button, Card, Input, Spinner, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Building2, ArrowRight, Sparkles } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
import { slugifyOrg, validateOrgName } from '~/lib/server/onboarding'
|
||||
import { readOnboardRefusal, slugifyOrg, validateOrgName } from '~/lib/server/onboarding'
|
||||
import { v1Url } from '~/lib/api/client'
|
||||
import { FadeIn } from '~/components/ui/FadeIn'
|
||||
import { FadeIn } from '@hanzo/ui/product'
|
||||
|
||||
type Phase = 'form' | 'done'
|
||||
type Phase = 'form' | 'done' | 'exists'
|
||||
|
||||
// currentOwner asks the server which organization this account is in. IAM answers
|
||||
// the legacy envelope ({status,msg,data}) on this address, so read through `data`
|
||||
// and fall back to a bare body. Any failure returns null and the caller falls back
|
||||
// to showing the server's own message — a recovery that guesses is worse than none.
|
||||
async function currentOwner(): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch(v1Url('iam/account'), { credentials: 'include' })
|
||||
if (!res.ok) return null
|
||||
const body = (await res.json()) as { data?: { owner?: string }; owner?: string } | null
|
||||
const owner = body?.data?.owner ?? body?.owner
|
||||
return owner && owner !== 'admin' ? owner : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function OrgOnboarding() {
|
||||
const { signIn, signOut } = useSession()
|
||||
@@ -32,6 +48,9 @@ export function OrgOnboarding() {
|
||||
const [busy, setBusy] = useState<false | 'create' | 'personal'>(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [phase, setPhase] = useState<Phase>('form')
|
||||
// The org the SERVER says this account already admins, discovered only after a
|
||||
// refused create. See the 409 branch in onboard().
|
||||
const [existingOrg, setExistingOrg] = useState<string | null>(null)
|
||||
|
||||
const slug = slugifyOrg(name)
|
||||
const named = validateOrgName(name)
|
||||
@@ -55,7 +74,28 @@ export function OrgOnboarding() {
|
||||
}
|
||||
const json = (await res.json().catch(() => null)) as { org?: string; error?: string } | null
|
||||
if (!res.ok || !json?.org) {
|
||||
setError(json?.error || `Could not create the organization (HTTP ${res.status}).`)
|
||||
// 409 = the FIRST-RUN GATE, not a name collision. Onboarding MOVES the caller
|
||||
// into the org it founds, so founding a second one would orphan the org this
|
||||
// account already admins — IAM refuses (provision.go: "you already have an
|
||||
// organization"). This screen is only ever rendered when the client resolved
|
||||
// an EMPTY owner, and an owner that is empty because a read failed looks
|
||||
// exactly like a brand-new account. So the refusal is the first reliable
|
||||
// signal that the session was wrong, and the only honest thing to do with it
|
||||
// is recover: ask the server which org this account is actually in and offer
|
||||
// the way in. Leaving the customer on a form that can never submit — with a
|
||||
// message about organizations when they just typed a name — is how "it said
|
||||
// the name was taken" happens.
|
||||
const refusal = readOnboardRefusal(
|
||||
res.status,
|
||||
json?.error,
|
||||
res.status === 409 ? await currentOwner() : null,
|
||||
)
|
||||
if (refusal.action === 'recover') {
|
||||
setExistingOrg(refusal.org)
|
||||
setPhase('exists')
|
||||
} else {
|
||||
setError(refusal.error)
|
||||
}
|
||||
setBusy(false)
|
||||
return
|
||||
}
|
||||
@@ -69,6 +109,32 @@ export function OrgOnboarding() {
|
||||
signIn()
|
||||
}
|
||||
|
||||
if (phase === 'exists' && existingOrg) {
|
||||
return (
|
||||
<Center>
|
||||
<FadeIn style={CENTER_STYLE}>
|
||||
<Card p="$5" gap="$4" width={440} borderWidth={1} borderColor="$borderColor" bg="$color1" items="center">
|
||||
<Building2 size={20} />
|
||||
<YStack gap="$1" items="center">
|
||||
<Text fontSize="$6" fontWeight="800">
|
||||
You{'\u2019'}re already in {existingOrg}
|
||||
</Text>
|
||||
<Text fontSize="$3" color="$color11" text="center">
|
||||
This account already belongs to an organization, so there was nothing to
|
||||
create. Sign in again to continue there.
|
||||
</Text>
|
||||
</YStack>
|
||||
{/* Explicit, never automatic. Re-authenticating on our own would loop
|
||||
forever against whatever left the session without an owner. */}
|
||||
<Button size="$3" onPress={() => signIn()}>
|
||||
Continue to {existingOrg}
|
||||
</Button>
|
||||
</Card>
|
||||
</FadeIn>
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
if (phase === 'done') {
|
||||
return (
|
||||
<Center>
|
||||
|
||||
@@ -27,10 +27,9 @@ import { useIsSuperAdmin } from '~/lib/auth/admin'
|
||||
import { enterOrg } from '~/lib/org-scope'
|
||||
import { IamAdminApi, type Organization } from '~/lib/api'
|
||||
import { BrandMark } from '~/components/ui/BrandLogo'
|
||||
import { EmptyState } from '~/components/ui/EmptyState'
|
||||
import { FadeIn } from '~/components/ui/FadeIn'
|
||||
import { OrgOnboarding } from '~/components/OrgOnboarding'
|
||||
import { PAGE_SIZE, pickerView, type OrgCard, type PickerContext } from '~/components/org-picker/logic'
|
||||
import { EmptyState, FadeIn } from '@hanzo/ui/product'
|
||||
|
||||
const titleCase = (s: string) => (s ? s[0].toUpperCase() + s.slice(1) : s)
|
||||
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Org switcher — the hoisted `@hanzo/ui` shared-shell switcher (hanzoai/ui#36;
|
||||
* this console's switcher was its source), wired to THIS host's contract:
|
||||
*
|
||||
* - scope — `~/lib/org-scope` (the same orgScope contract, console keys).
|
||||
* - orgs — the lazy, server-paged cross-tenant IAM list
|
||||
* (`IamAdminApi.organizations` via the gated `/admin/iam` proxy),
|
||||
* injected ONLY for a super admin; a regular user sees their own
|
||||
* org synthesized from the scope — never fabricated.
|
||||
* - create — the same-origin `/v1/iam/onboard` route (zero-org user →
|
||||
* created + joined as admin; existing user → an additional org
|
||||
* they're scope-switched into).
|
||||
* - picker — the "All organizations" de-scope row (`leaveOrg`).
|
||||
*
|
||||
* Selecting re-scopes IN PLACE (`switchOrg` persists + reloads so every module
|
||||
* refetches under the new `X-Org-Id`); the super-admin masquerade is unchanged.
|
||||
*/
|
||||
import { OrgSwitcher as Switcher, type Org, type OrgScope } from '@hanzo/ui/product'
|
||||
|
||||
import {
|
||||
currentOrg,
|
||||
enterOrg,
|
||||
hasSelectedOrg,
|
||||
isScopedAway,
|
||||
leaveOrg,
|
||||
setCurrentOrg,
|
||||
switchOrg,
|
||||
} from '~/lib/org-scope'
|
||||
import { ORG_PAGE_SIZE, orgQuery } from '~/lib/org-list'
|
||||
import { IamAdminApi } from '~/lib/api'
|
||||
import { v1Url } from '~/lib/api/client'
|
||||
import { useIsSuperAdmin } from '~/lib/auth/admin'
|
||||
|
||||
/** The console's active-org contract, in the hoisted shape. */
|
||||
const scope: OrgScope = {
|
||||
currentOrg,
|
||||
setCurrentOrg,
|
||||
isScopedAway,
|
||||
hasSelectedOrg,
|
||||
enterOrg,
|
||||
leaveOrg,
|
||||
switchOrg,
|
||||
}
|
||||
|
||||
/** One page of the cross-tenant org list, mapped to the shared `Org` shape. */
|
||||
async function orgPage(page: number, query: string): Promise<Org[]> {
|
||||
const res = await IamAdminApi.organizations(orgQuery(page, query))
|
||||
return (res.rows ?? []).map((o) => ({ name: o.name, displayName: o.displayName, logo: o.logo }))
|
||||
}
|
||||
|
||||
/** Create an org through the same-origin onboard route → the new org's id. */
|
||||
async function createOrg(name: string): Promise<string> {
|
||||
const res = await fetch(v1Url('iam/onboard'), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
})
|
||||
const data = (await res.json().catch(() => ({}))) as { org?: string; error?: string }
|
||||
if (!res.ok || !data.org) throw new Error(data.error || `Could not create organization (${res.status}).`)
|
||||
return data.org
|
||||
}
|
||||
|
||||
export function OrgSwitcher() {
|
||||
// The admin-gated cross-tenant list fires ONLY for a super admin — a regular
|
||||
// user (who would 403 it) gets no loader and sees their current org alone.
|
||||
const isSuperAdmin = useIsSuperAdmin()
|
||||
return (
|
||||
<Switcher
|
||||
scope={scope}
|
||||
orgs={isSuperAdmin ? orgPage : undefined}
|
||||
pageSize={ORG_PAGE_SIZE}
|
||||
create={createOrg}
|
||||
picker
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -62,18 +60,17 @@ export function Provider({ children }: { children: ReactNode }) {
|
||||
<SessionProvider>
|
||||
<OrgAccentProvider />
|
||||
{/* Entitlements live inside the session (they read the signed-in account +
|
||||
active org scope) so the sidebar/launcher/palette gate from ONE fetch. */}
|
||||
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>
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* PublicLanding — what an UNAUTHENTICATED visitor sees at `/` (the marketing face
|
||||
* of the console). The SAME one `cloud` binary that serves the signed-in console
|
||||
* serves this to anon — one binary, one way, no separate marketing service.
|
||||
*
|
||||
* Content is DERIVED from the real product taxonomy (categoriesForBrand +
|
||||
* CATEGORY_SUMMARY), brand-scoped via getBrand — so it is honest (every tile is a
|
||||
* real category the platform ships) and white-labels for free. The primary CTA is
|
||||
* the ONE sign-in surface (/signin); "Learn more" goes to the brand's own site.
|
||||
*/
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { HanzoHeader, HANZO_PRODUCT_CATEGORIES, findSurfaceByHost, type HanzoSurface } from '@hanzogui/shell'
|
||||
|
||||
import { config } from '~/config'
|
||||
import { getBrand } from '~/lib/branding/brands'
|
||||
import { categoriesForBrand, CATEGORY_SUMMARY } from '~/lib/products/brand-scope'
|
||||
import { landingSurface } from '~/lib/products/landing-surface'
|
||||
import { ConsoleFooter } from '~/components/ConsoleFooter'
|
||||
|
||||
/** The shared header, with its CTAs re-pointed at this landing's own sign-in
|
||||
* (the canonical surface aims them AT the console — a self-link from here). */
|
||||
const LANDING_SURFACE: HanzoSurface = landingSurface(findSurfaceByHost('cloud.hanzo.ai'))
|
||||
|
||||
/**
|
||||
* Decline the shell's account control. `HanzoHeader` renders its OWN text "Sign in"
|
||||
* link whenever `account` is nullish (`account ?? <DefaultAccount/>`), so omitting it
|
||||
* put a second sign-in beside the primary CTA that IS the sign-in — the desktop
|
||||
* logged-out header read `[Get API key] [Sign in] [Sign in]`. `false` is how a caller
|
||||
* says "no account node": it is not nullish, so the default never renders, and React
|
||||
* renders nothing for it — including in the mobile sheet, which would otherwise draw
|
||||
* an empty bordered identity row around it. Exactly ONE sign-in affordance.
|
||||
*/
|
||||
const NO_ACCOUNT = false
|
||||
|
||||
/**
|
||||
* The house hero buttons — the same pill pair every Hanzo landing wears: a white
|
||||
* primary carrying the weight, and a hairline secondary that is still visibly a
|
||||
* button. Tamagui's default Button is a grey chip that reads as DISABLED next to
|
||||
* the white sign-in pill in the header, and `chromeless` has no edge at all.
|
||||
*/
|
||||
const CTA_PRIMARY = {
|
||||
rounded: 999,
|
||||
bg: '$color12',
|
||||
color: '$color1',
|
||||
borderWidth: 0,
|
||||
hoverStyle: { bg: '$color12', opacity: 0.85 },
|
||||
pressStyle: { bg: '$color12', opacity: 0.7 },
|
||||
} as const
|
||||
|
||||
const CTA_SECONDARY = {
|
||||
rounded: 999,
|
||||
bg: 'transparent',
|
||||
color: '$color12',
|
||||
borderWidth: 1,
|
||||
borderColor: '$color6',
|
||||
hoverStyle: { bg: '$color3', borderColor: '$color8' },
|
||||
pressStyle: { bg: '$color4' },
|
||||
} as const
|
||||
|
||||
export function PublicLanding() {
|
||||
const router = useRouter()
|
||||
const brand = getBrand()
|
||||
const categories = categoriesForBrand(brand.id).filter((c) => c !== 'Settings')
|
||||
|
||||
const signIn = (
|
||||
<Button size="$3" onPress={() => router.push('/signin')}>
|
||||
Sign in
|
||||
</Button>
|
||||
)
|
||||
|
||||
return (
|
||||
<YStack minH="100vh" bg="$color1">
|
||||
{/*
|
||||
Top bar. On the Hanzo brand this is the UNIFIED @hanzogui/shell HanzoHeader
|
||||
(Meet Hanzo + the rich ten-category Products mega-menu + brand tokens) — the
|
||||
SAME header every Hanzo surface wears. White-label brands (lux/zoo/…) keep the
|
||||
brand-neutral bar so no Hanzo ecosystem URL leaks onto their console.
|
||||
*/}
|
||||
{brand.id === 'hanzo' ? (
|
||||
// The header's own primary CTA IS the sign-in (see LANDING_SURFACE), so the
|
||||
// account control is declined explicitly (see NO_ACCOUNT) — one way in, not
|
||||
// two competing sign-ins.
|
||||
<HanzoHeader
|
||||
surface={LANDING_SURFACE}
|
||||
productsTaxonomy={HANZO_PRODUCT_CATEGORIES}
|
||||
account={NO_ACCOUNT}
|
||||
/>
|
||||
) : (
|
||||
<XStack
|
||||
items="center"
|
||||
justify="space-between"
|
||||
px="$4"
|
||||
py="$3"
|
||||
borderBottomWidth={1}
|
||||
borderColor="$borderColor"
|
||||
$md={{ px: '$6' }}
|
||||
>
|
||||
<Text fontSize="$6" fontWeight="700" color="$color12">
|
||||
{config.brandName}
|
||||
</Text>
|
||||
{signIn}
|
||||
</XStack>
|
||||
)}
|
||||
|
||||
{/* Hero */}
|
||||
<YStack items="center" gap="$4" px="$4" py="$10" $md={{ py: '$12' }}>
|
||||
<YStack items="center" gap="$3" maxW={760}>
|
||||
{/* `hz-display` gives the headline a unitless line-height (see globals.css):
|
||||
the size token's own line-height is tuned for ONE line, so on a phone —
|
||||
where this always wraps — the two lines overprint without it. */}
|
||||
<Text
|
||||
render="h1"
|
||||
className="hz-display"
|
||||
fontSize="$11"
|
||||
$md={{ fontSize: '$13' }}
|
||||
fontWeight="800"
|
||||
color="$color12"
|
||||
style={{ textAlign: 'center' }}
|
||||
>
|
||||
The AI cloud, one platform
|
||||
</Text>
|
||||
<Text fontSize="$5" color="$color10" style={{ textAlign: 'center' }}>
|
||||
Models, compute, training, data, and the tools to ship — with usage-based billing and a single API.
|
||||
</Text>
|
||||
</YStack>
|
||||
<XStack gap="$3" mt="$2" flexWrap="wrap" justify="center">
|
||||
<Button
|
||||
size="$4"
|
||||
{...CTA_PRIMARY}
|
||||
onPress={() => router.push('/signin')}
|
||||
>
|
||||
Get started
|
||||
</Button>
|
||||
<Button
|
||||
size="$4"
|
||||
{...CTA_SECONDARY}
|
||||
onPress={() => typeof window !== 'undefined' && window.open(brand.websiteUrl, '_blank', 'noopener')}
|
||||
>
|
||||
Learn more
|
||||
</Button>
|
||||
</XStack>
|
||||
</YStack>
|
||||
|
||||
{/* Real product categories — derived from the live taxonomy */}
|
||||
<XStack justify="center" px="$4" pb="$10" $md={{ px: '$6' }}>
|
||||
<YStack width="100%" maxW={1080} gap="$4">
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gap: 16,
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
|
||||
}}
|
||||
>
|
||||
{categories.map((c) => (
|
||||
<YStack
|
||||
key={c}
|
||||
gap="$1.5"
|
||||
p="$4"
|
||||
rounded="$4"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
bg="$color2"
|
||||
>
|
||||
<Text fontSize="$5" fontWeight="700" color="$color12">
|
||||
{c}
|
||||
</Text>
|
||||
<Text fontSize="$3" color="$color10">
|
||||
{CATEGORY_SUMMARY[c]}
|
||||
</Text>
|
||||
</YStack>
|
||||
))}
|
||||
</div>
|
||||
</YStack>
|
||||
</XStack>
|
||||
|
||||
<XStack justify="center" px="$4" $md={{ px: '$6' }}>
|
||||
<YStack width="100%" maxW={1080}>
|
||||
<ConsoleFooter />
|
||||
</YStack>
|
||||
</XStack>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -1,31 +1,35 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Scope switcher — the project + network pickers that scope every module.
|
||||
* Scope switcher — the NETWORK picker, and only that.
|
||||
*
|
||||
* Two chips next to the org switcher: the active PROJECT (or "All projects" for
|
||||
* org-level scope) and the active NETWORK. The network picker offers the stock
|
||||
* tiers (Mainnet/Testnet/Devnet — the live Hanzo networks), a Local option for a
|
||||
* self-hosted cloud binary, and any custom networks the user adds (their own
|
||||
* networkID / EVM chainID / RPC / API). Selecting a network writes through
|
||||
* `useScope`, which updates the module-level scope the API client reads — so it
|
||||
* re-scopes every module (via `X-Environment`) AND retargets chain/RPC/API at once.
|
||||
* A "New project" affordance routes to the Projects module; we never fabricate one.
|
||||
* The network is a global MODE, not a place, so it stays its own control in the
|
||||
* top-right while org and project condense into the top-left `ContextSwitcher`.
|
||||
* Its tier dot is a destructive-environment guard (mainnet live green, testnet
|
||||
* caution amber), which is why it keeps a distinct, always-visible chip rather
|
||||
* than folding into a menu you have to open to read.
|
||||
*
|
||||
* The picker offers the stock tiers (Mainnet/Testnet/Devnet — the live Hanzo
|
||||
* networks), a Local option for a self-hosted cloud binary, and any custom
|
||||
* networks the user adds (their own networkID / EVM chainID / RPC / API).
|
||||
* Selecting a network writes through `useScope`, which updates the module-level
|
||||
* scope the API client reads — so it re-scopes every module (via
|
||||
* `X-Environment`) AND retargets chain/RPC/API at once.
|
||||
*/
|
||||
import { useMemo, useState, type ReactNode } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Button, Popover, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Check, ChevronsUpDown, FolderGit2, Layers, Plus, Trash } from '@hanzogui/lucide-icons-2'
|
||||
import { Check, ChevronsUpDown, Layers, Plus, Trash } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { useScope } from '~/lib/scope-context'
|
||||
import { STOCK_ENVIRONMENTS } from '~/lib/scope'
|
||||
import { isStockNetwork, parseCustomNetwork, type Network } from '~/lib/network'
|
||||
import { FieldText } from '~/components/ui/Field'
|
||||
import { MenuRow, type DotColor } from '~/components/ui/MenuRow'
|
||||
import { paper } from '~/components/ui/paper'
|
||||
import { FieldText } from '@hanzo/ui/product'
|
||||
|
||||
/** A small dot keyed to the network tier. Monochrome by default; only the genuine
|
||||
* states carry a hue — mainnet is live (green), testnet is a caution (amber). Every
|
||||
* other tier is a neutral off the design ladder (Tamagui $colorN). */
|
||||
type DotColor = '$green10' | '$yellow10' | '$color10' | '$color9' | '$color8'
|
||||
const NET_DOT: Record<string, DotColor> = {
|
||||
mainnet: '$green10',
|
||||
testnet: '$yellow10',
|
||||
@@ -40,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 bordered elevate p="$2" width={260} bg="$color2" borderColor="$borderColor">
|
||||
<YStack gap="$0.5">
|
||||
<Text px="$2" py="$1" fontSize="$1" color="$color10" fontWeight="700" textTransform="uppercase">
|
||||
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({
|
||||
@@ -169,16 +120,16 @@ function NetworkPicker() {
|
||||
<Popover.Trigger asChild>
|
||||
<Button size="$2" chromeless icon={<Layers size={14} />} iconAfter={<ChevronsUpDown size={13} />}>
|
||||
<XStack items="center" gap="$2">
|
||||
<YStack width={8} height={8} rounded={999} bg={netDot(activeNetwork)} />
|
||||
<YStack width={8} height={8} rounded="$10" bg={netDot(activeNetwork)} />
|
||||
<Text fontSize="$2" color="$color12">
|
||||
{activeNetwork.label}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content bordered elevate p="$2" width={300} bg="$color2" borderColor="$borderColor">
|
||||
<Popover.Content {...paper} p="$2" width={300}>
|
||||
<YStack gap="$0.5">
|
||||
<Text px="$2" py="$1" fontSize="$1" color="$color10" fontWeight="700" textTransform="uppercase">
|
||||
<Text px="$2" py="$1" fontSize="$1" color="$color10" fontWeight="500">
|
||||
Network
|
||||
</Text>
|
||||
|
||||
@@ -195,11 +146,11 @@ function NetworkPicker() {
|
||||
{extraEnvs.length ? (
|
||||
<>
|
||||
<XStack height={1} bg="$borderColor" my="$1" />
|
||||
<Text px="$2" py="$1" fontSize="$1" color="$color10" fontWeight="700" textTransform="uppercase">
|
||||
<Text px="$2" py="$1" fontSize="$1" color="$color10" fontWeight="500">
|
||||
Project environments
|
||||
</Text>
|
||||
{extraEnvs.map((env) => (
|
||||
<Row
|
||||
<MenuRow
|
||||
key={env}
|
||||
label={titleCase(env)}
|
||||
sub="Custom"
|
||||
@@ -215,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>
|
||||
@@ -238,7 +189,7 @@ function NetworkRow({
|
||||
return (
|
||||
<XStack items="center" gap="$1" rounded="$3" hoverStyle={{ bg: '$color4' }}>
|
||||
<XStack flex={1} onPress={onPress} cursor="pointer" items="center" gap="$2" px="$2" py="$2">
|
||||
<YStack width={8} height={8} rounded={999} bg={netDot(net)} />
|
||||
<YStack width={8} height={8} rounded="$10" bg={netDot(net)} />
|
||||
<YStack flex={1}>
|
||||
<Text fontSize="$2" color="$color12" numberOfLines={1}>
|
||||
{net.label}
|
||||
@@ -262,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={999} bg={dot} /> : icon}
|
||||
<YStack flex={1}>
|
||||
<Text fontSize="$2" color="$color12" numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
{sub ? (
|
||||
<Text fontSize="$1" color="$color10">
|
||||
{sub}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
{active ? <Check size={14} /> : null}
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
/** Project + network pickers as a unit (topbar). */
|
||||
/** The network picker (topbar). Org + project live in `ContextSwitcher`, top-left. */
|
||||
export function ScopeSwitcher() {
|
||||
return (
|
||||
<XStack items="center" gap="$1">
|
||||
<ProjectPicker />
|
||||
<XStack items="center" gap="$1" data-testid="switcher-network">
|
||||
<NetworkPicker />
|
||||
</XStack>
|
||||
)
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Top-left brand logomark — the ONE brand glyph in the console chrome, matching
|
||||
* the unified Hanzo app-shell (hanzo.app + hanzo.chat): the real geometric mark
|
||||
* ALONE (no wordmark, no product name, no letter-H text), white-labeled by host.
|
||||
* Top-left mark — the identity of the ORGANIZATION the console is scoped to.
|
||||
*
|
||||
* The mark is the host-derived `BrandMark` (Hanzo H / Lux / Zoo / Pars per host,
|
||||
* `currentColor` so it inherits the calm chrome foreground and adapts to the
|
||||
* theme) — never a hardcoded Hanzo asset. On a lux/zoo/pars host it renders THAT
|
||||
* brand's mark. Left-click → product home (`/`); RIGHT-CLICK → a small brand
|
||||
* context menu (Settings · Brand · Docs · About), the same affordance the shared
|
||||
* shell's HanzoMark exposes.
|
||||
* White-label is the point: a customer's console must show the customer's mark,
|
||||
* never ours. So this renders the ONE shared `OrgMark` (@hanzo/ui) — the org's
|
||||
* own logo when IAM carries one, else the org's MONOGRAM, the same treatment the
|
||||
* account widget gives a person. It is never the house glyph and never the org
|
||||
* name set as running text.
|
||||
*
|
||||
* The house `BrandMark` still exists, but for the PRODUCT's own moments (the
|
||||
* assistant trigger, the org picker, onboarding) — not for the tenant's slot.
|
||||
*
|
||||
* Left-click → product home (`/`); RIGHT-CLICK → a small brand context menu
|
||||
* (Settings · Brand · Docs · About), the same affordance the shared shell's
|
||||
* HanzoMark exposes.
|
||||
*
|
||||
* The interactive surface is a plain `<div>` (the console's own escape hatch, as
|
||||
* used for `<div onScroll>` in OrgSwitcher) so the native `contextmenu` event is
|
||||
* guaranteed to fire and the mark still inherits the chrome color via
|
||||
* `currentColor`. The menu reuses the console's one menu surface (a `$color2`
|
||||
* guaranteed to fire. The menu reuses the console's one menu surface (a `$color2`
|
||||
* paper sheet with the shared `hz-paper hz-menu-in` styling) — no new menu
|
||||
* system; it is a cursor-anchored overlay (a right-click has no trigger rect to
|
||||
* anchor a Popover to).
|
||||
@@ -27,7 +30,9 @@ import { BookOpen, Globe, Info, SlidersHorizontal } from '@hanzogui/lucide-icons
|
||||
|
||||
import { config } from '~/config'
|
||||
import { getBrand } from '~/lib/branding/brands'
|
||||
import { BrandMark, useOrgLogo } from '~/components/ui/BrandLogo'
|
||||
import { useOrgIdentity } from '~/components/ui/BrandLogo'
|
||||
import { Z } from '~/lib/z'
|
||||
import { OrgMark } from '@hanzo/ui/product'
|
||||
|
||||
type MenuItem = {
|
||||
icon: typeof SlidersHorizontal
|
||||
@@ -60,7 +65,7 @@ function BrandMenu({ x, y, items, onClose }: { x: number; y: number; items: Menu
|
||||
const top = typeof window !== 'undefined' ? Math.min(y, window.innerHeight - 200) : y
|
||||
|
||||
return (
|
||||
<div ref={ref} role="menu" style={{ position: 'fixed', left, top, zIndex: 9999 }}>
|
||||
<div ref={ref} role="menu" style={{ position: 'fixed', left, top, zIndex: Z.dropdown }}>
|
||||
<YStack
|
||||
className="hz-paper hz-menu-in"
|
||||
minW={184}
|
||||
@@ -99,17 +104,16 @@ function BrandMenu({ x, y, items, onClose }: { x: number; y: number; items: Menu
|
||||
}
|
||||
|
||||
/**
|
||||
* The top-left brand mark. `collapsed` centers it in the icon rail. Left-click →
|
||||
* The top-left org mark. `collapsed` centers it in the icon rail. Left-click →
|
||||
* home; right-click → the brand context menu.
|
||||
*/
|
||||
export function SidebarBrand({ collapsed, onNavigate }: { collapsed: boolean; onNavigate?: () => void }) {
|
||||
const router = useRouter()
|
||||
const brand = getBrand()
|
||||
// White-label: the selected org's OWN logo is the primary chrome identity (the
|
||||
// tenant's brand, front and center). The host BrandMark is only the fallback when
|
||||
// the org has set no logo — the `hanzo` org's logo is the Hanzo mark, so it stays
|
||||
// on-brand. `useOrgLogo` is the ONE cached org-logo source, shared with BrandLogo.
|
||||
const orgLogo = useOrgLogo()
|
||||
// The tenant leads the chrome: its own logo when set, else its monogram — never
|
||||
// the house mark. `useOrgIdentity` is the ONE cached org-identity source.
|
||||
const org = useOrgIdentity()
|
||||
const orgLabel = org.displayName || org.name
|
||||
const [menu, setMenu] = useState<{ x: number; y: number } | null>(null)
|
||||
|
||||
const go = useCallback(
|
||||
@@ -145,8 +149,8 @@ export function SidebarBrand({ collapsed, onNavigate }: { collapsed: boolean; on
|
||||
onClick={() => go('/')}
|
||||
onContextMenu={onContextMenu}
|
||||
role="link"
|
||||
aria-label={`${brand.brandName} — home (right-click for brand menu)`}
|
||||
title={brand.brandName}
|
||||
aria-label={`${orgLabel} — home (right-click for brand menu)`}
|
||||
title={orgLabel}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -157,18 +161,17 @@ export function SidebarBrand({ collapsed, onNavigate }: { collapsed: boolean; on
|
||||
color: 'var(--color12)',
|
||||
}}
|
||||
>
|
||||
{orgLogo ? (
|
||||
// The org's own IAM logo, at the brand-mark size — the tenant's brand leads
|
||||
// the chrome; the Hanzo H is hidden whenever the org has its own.
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={orgLogo}
|
||||
alt={brand.brandName}
|
||||
style={{ height: 24, width: 'auto', maxWidth: 140, objectFit: 'contain', display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
<BrandMark size={24} />
|
||||
)}
|
||||
{/* A logo may be a wordmark, so it is allowed to run wide; the monogram
|
||||
stays the square tile the account avatar wears.
|
||||
|
||||
`data-monogram`: OrgMark is a DISTRIBUTED component that sizes its
|
||||
monogram glyph proportionally to its tile, so it paints text off the
|
||||
app type scale by design — correct for a mark, wrong to police. The
|
||||
marker declares that here, at our call site, rather than teaching the
|
||||
design gate a library's class names. */}
|
||||
<span data-monogram style={{ display: 'contents' }}>
|
||||
<OrgMark org={org} size={24} maxW={140} />
|
||||
</span>
|
||||
</div>
|
||||
{menu ? <BrandMenu x={menu.x} y={menu.y} items={items} onClose={() => setMenu(null)} /> : null}
|
||||
</>
|
||||
|
||||
@@ -22,9 +22,8 @@ import { COLOR_SWATCHES } from '~/lib/products/colors'
|
||||
import { DEFAULT_GROUP, DEFAULT_GROUP_LABEL, type PinGroupView } from '~/lib/products/pins-core'
|
||||
import { usePins, useProductColors } from '~/lib/products/pins'
|
||||
import { findEntry } from '~/lib/products/registry'
|
||||
import { Reorder } from '~/components/ui/Reorder'
|
||||
import { asColor } from '~/components/ui/color'
|
||||
import { contrastText } from '~/lib/theme/accent'
|
||||
import { Reorder, asColor } from '@hanzo/ui/product'
|
||||
|
||||
/** A round color swatch button; ringed + checked when selected. */
|
||||
function SwatchButton({ hex, selected, onPress }: { hex: string; selected: boolean; onPress: () => void }) {
|
||||
@@ -130,7 +129,7 @@ export function ProductCustomize({ id }: { id: string }) {
|
||||
{/* Color */}
|
||||
<YStack gap="$2.5">
|
||||
<XStack items="center" justify="space-between">
|
||||
<Text fontSize="$2" color="$color11" fontWeight="700" textTransform="uppercase">
|
||||
<Text fontSize="$2" color="$color11" fontWeight="500">
|
||||
Icon color
|
||||
</Text>
|
||||
<Button size="$1" chromeless onPress={() => resetColor(id)}>
|
||||
@@ -148,7 +147,7 @@ export function ProductCustomize({ id }: { id: string }) {
|
||||
|
||||
{/* Pin */}
|
||||
<YStack gap="$2.5">
|
||||
<Text fontSize="$2" color="$color11" fontWeight="700" textTransform="uppercase">
|
||||
<Text fontSize="$2" color="$color11" fontWeight="500">
|
||||
Pin
|
||||
</Text>
|
||||
<Button
|
||||
@@ -166,7 +165,7 @@ export function ProductCustomize({ id }: { id: string }) {
|
||||
{/* Group (only meaningful once pinned) */}
|
||||
{pinned ? (
|
||||
<YStack gap="$2.5">
|
||||
<Text fontSize="$2" color="$color11" fontWeight="700" textTransform="uppercase">
|
||||
<Text fontSize="$2" color="$color11" fontWeight="500">
|
||||
Group
|
||||
</Text>
|
||||
<XStack flexWrap="wrap" gap="$2">
|
||||
@@ -292,7 +291,7 @@ function ManageGroup({
|
||||
/>
|
||||
</XStack>
|
||||
) : (
|
||||
<Text flex={1} fontSize="$1" color="$color10" fontWeight="700" textTransform="uppercase">
|
||||
<Text flex={1} fontSize="$1" color="$color10" fontWeight="500">
|
||||
{group.label}
|
||||
</Text>
|
||||
)}
|
||||
@@ -357,7 +356,7 @@ export function ManagePins() {
|
||||
))}
|
||||
|
||||
<YStack gap="$2">
|
||||
<Text fontSize="$1" color="$color10" fontWeight="700" textTransform="uppercase">
|
||||
<Text fontSize="$1" color="$color10" fontWeight="500">
|
||||
New group
|
||||
</Text>
|
||||
<NewGroup onAdd={addGroup} />
|
||||
|
||||
@@ -9,14 +9,17 @@
|
||||
* invoices — the org's own data, scoped to the active org.
|
||||
* - **Top up** → the brand's hosted payment page (`config.payUrl`, e.g. pay.hanzo.ai)
|
||||
* in a NEW TAB. Display + link only: the wallet never hosts a card form or mints
|
||||
* credit — the hosted page owns payment. **Sign out** beneath.
|
||||
* credit — the hosted page owns payment.
|
||||
*
|
||||
* Sign-out is NOT here. It belongs to the ONE account control at the foot of the
|
||||
* rail (`AccountMenu`); this used to be one of four places that offered it.
|
||||
*
|
||||
* The balance comes from the per-tenant `/billing/*` server proxy, scoped to the
|
||||
* caller's OWN org — the exact credit the gateway debits.
|
||||
*/
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { ChevronRight, ExternalLink, LogOut, Wallet } from '@hanzogui/lucide-icons-2'
|
||||
import { ChevronRight, ExternalLink, Wallet } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { config } from '~/config'
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
@@ -25,7 +28,7 @@ import { useCloudBalance, spendableCents, balanceSplitLabel } from '~/lib/billin
|
||||
const fmtUsd = (cents: number): string => `$${(cents / 100).toFixed(2)}`
|
||||
|
||||
export function SidebarWallet({ collapsed }: { collapsed: boolean }) {
|
||||
const { account, signOut } = useSession()
|
||||
const { account } = useSession()
|
||||
const router = useRouter()
|
||||
const owner = account?.owner ?? ''
|
||||
// ONE shared live balance (same value the Wallet/Cost pages show): refetches on
|
||||
@@ -48,11 +51,10 @@ export function SidebarWallet({ collapsed }: { collapsed: boolean }) {
|
||||
}
|
||||
|
||||
if (collapsed) {
|
||||
// Two stacked affordances: wallet/top-up + sign out (the identity is the top avatar).
|
||||
// One affordance: the wallet. Identity and sign-out are the account control's.
|
||||
return (
|
||||
<YStack items="center" gap="$2">
|
||||
<Button size="$2" chromeless onPress={openTopUp} icon={<Wallet size={18} />} aria-label={`Wallet ${balanceText} — top up`} />
|
||||
<Button size="$2" chromeless onPress={() => void signOut()} icon={<LogOut size={18} />} aria-label="Sign out" />
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -79,9 +81,6 @@ export function SidebarWallet({ collapsed }: { collapsed: boolean }) {
|
||||
</YStack>
|
||||
|
||||
<Button size="$2" onPress={openTopUp} iconAfter={<ExternalLink size={13} />} aria-label="Top up — opens the payment page in a new tab">Top up</Button>
|
||||
<Button size="$2" chromeless icon={<LogOut size={15} />} onPress={() => void signOut()} justify="center">
|
||||
Sign out
|
||||
</Button>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,9 +24,9 @@ import { useIam } from '@hanzo/iam/react'
|
||||
import { Text, YStack } from '@hanzo/gui'
|
||||
|
||||
import { Loader } from '~/components/ui/Loader'
|
||||
import { PrimaryButton } from '~/components/ui/PrimaryButton'
|
||||
import { config } from '~/config'
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
import { PrimaryButton } from '@hanzo/ui/product'
|
||||
|
||||
export function SignIn() {
|
||||
const { account, loading } = useSession()
|
||||
|
||||
@@ -34,9 +34,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Button, Card, Spinner, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Bot, Plus, Terminal, X } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { ComboBox, type ComboOption } from '~/components/ui/ComboBox'
|
||||
import { FieldRow, FieldSelect, FieldSlider, FieldSwitch, FieldText, FieldTextArea } from '~/components/ui/Field'
|
||||
import { SelectMenu, type SelectOption } from '~/components/ui/SelectMenu'
|
||||
import {
|
||||
canSubmit,
|
||||
classifyBuilderError,
|
||||
@@ -48,6 +45,7 @@ import {
|
||||
toCreateBody,
|
||||
} from './logic'
|
||||
import type { AgentBuilderLoaders, AgentConfig, AgentSpec, BuilderOption, BuilderPrompt, ReasoningEffort } from './types'
|
||||
import { ComboBox, FieldRow, FieldSelect, FieldSlider, FieldSwitch, FieldText, FieldTextArea, SelectMenu, type ComboOption, type SelectOption } from '@hanzo/ui/product'
|
||||
|
||||
/** Async option-list state for the live pickers (model/tool). */
|
||||
type OptState =
|
||||
@@ -60,18 +58,32 @@ const CUSTOM = '__custom__'
|
||||
|
||||
export function AgentBuilder({
|
||||
loaders,
|
||||
initial,
|
||||
onCreated,
|
||||
onCancel,
|
||||
submitLabel = 'Create agent',
|
||||
}: {
|
||||
loaders: AgentBuilderLoaders
|
||||
/** Called after a successful create (the host reloads its list + closes the form). */
|
||||
onCreated: () => void
|
||||
/**
|
||||
* A spec to start from — a template's preset, or what a description drafted.
|
||||
* Read ONCE, at mount: the form is the user's from that point on, so a seed can
|
||||
* never overwrite something they have already typed. A host that swaps seeds
|
||||
* (the quickstart, when a different template is picked) remounts with a `key`,
|
||||
* which states the intent — a new starting point — instead of hiding it in an
|
||||
* effect that races the user's keystrokes.
|
||||
*/
|
||||
initial?: Partial<AgentSpec>
|
||||
/**
|
||||
* Called after a successful create, with the NAME the agent was created under
|
||||
* (the handle every `/v1/agents/:ref` route is keyed by) so the host can go
|
||||
* straight to running it rather than looking it back up.
|
||||
*/
|
||||
onCreated: (name: string) => void
|
||||
/** Called when the user cancels (optional — omit for an always-open form). */
|
||||
onCancel?: () => void
|
||||
submitLabel?: string
|
||||
}) {
|
||||
const [spec, setSpec] = useState<AgentSpec>(emptySpec)
|
||||
const [spec, setSpec] = useState<AgentSpec>(() => ({ ...emptySpec(), ...initial }))
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [unavailable, setUnavailable] = useState(false)
|
||||
@@ -167,8 +179,9 @@ export function AgentBuilder({
|
||||
setError(null)
|
||||
setUnavailable(false)
|
||||
try {
|
||||
await loaders.createAgent(toCreateBody(spec))
|
||||
onCreated()
|
||||
const body = toCreateBody(spec)
|
||||
await loaders.createAgent(body)
|
||||
onCreated(body.name)
|
||||
} catch (e) {
|
||||
const c = classifyBuilderError(e)
|
||||
if (c.kind === 'unavailable') setUnavailable(true)
|
||||
@@ -204,7 +217,10 @@ export function AgentBuilder({
|
||||
loading={models.phase === 'loading'}
|
||||
error={models.phase === 'error' ? `Model catalog unavailable — type a model id. (${models.message})` : null}
|
||||
onRetry={loadModels}
|
||||
placeholder="zen-omni · gpt-4o-mini · claude-sonnet-4-5"
|
||||
// A placeholder is an example, and an example that does not exist is a lie
|
||||
// the user only discovers at the agent's first run. These are ids the live
|
||||
// catalog actually serves; the field itself offers the real list.
|
||||
placeholder="zen5 · zen5-mini · claude-sonnet-5"
|
||||
/>
|
||||
</FieldRow>
|
||||
|
||||
@@ -241,7 +257,14 @@ export function AgentBuilder({
|
||||
loading={tools.phase === 'loading'}
|
||||
error={tools.phase === 'error' ? `Tool catalog unavailable — type a tool id.` : null}
|
||||
onRetry={loadTools}
|
||||
placeholder="add a tool — e.g. web.search, code.exec"
|
||||
// No invented examples here either: the tool plane is per-org, so nobody
|
||||
// can name a tool that is certain to exist. The field offers what the org
|
||||
// has actually activated, and stays typeable for what it has not.
|
||||
placeholder={
|
||||
tools.phase === 'ready' && tools.options.length === 0
|
||||
? 'No tools activated yet — type one to use it anyway'
|
||||
: 'Search your tools'
|
||||
}
|
||||
emptyText="Press Add to include what you typed."
|
||||
/>
|
||||
<XStack gap="$2">
|
||||
|
||||
@@ -0,0 +1,532 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* AgentQuickstart — the guided way into the ONE builder: describe an agent in your
|
||||
* own words or start from a template, configure it, run it, and take the call away.
|
||||
*
|
||||
* FOUR STEPS, AND EVERY ONE IS A REAL CALL. That is the whole design constraint. A
|
||||
* ladder of steps is a promise about what happens; a step that only draws a checkmark
|
||||
* turns the promise into decoration. So:
|
||||
*
|
||||
* 1 Describe → `POST /v1/chat/completions` drafts a spec from a sentence
|
||||
* (`draftAgent`), or a template fills the form with a preset
|
||||
* 2 Configure → the SAME `AgentBuilder` every other surface uses, seeded
|
||||
* 3 Run → `POST /v1/agents/:ref/run` executes it and shows the recorded run
|
||||
* 4 Integrate → the request that just worked, as code
|
||||
*
|
||||
* Steps 1 and 3 are OPTIONAL by construction: their loaders (`draftAgent`, `runAgent`)
|
||||
* may be absent, and the step then says exactly what is missing instead of miming it.
|
||||
* Step 2 is the only one that cannot be skipped, because creating the agent is the
|
||||
* point and the builder is the one thing that does it.
|
||||
*
|
||||
* Host-agnostic like the rest of the module: everything arrives through
|
||||
* `AgentBuilderLoaders`, so chat, app and bot mount this over the same `/v1/agents`.
|
||||
*/
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Button, Card, Input, ScrollView, Spinner, Text, TextArea, XStack, YStack } from '@hanzo/gui'
|
||||
import { ArrowRight, Bot, Check, Play, Search, Terminal, X } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { AgentBuilder } from './AgentBuilder'
|
||||
import { defaultConfig, emptySpec, proposeName } from './logic'
|
||||
import { AGENT_TEMPLATES, searchTemplates, specFromTemplate, type AgentTemplate } from './templates'
|
||||
import type { AgentBuilderLoaders, AgentRunResult, AgentSpec } from './types'
|
||||
|
||||
/** The four steps, in order. The id is what the component switches on. */
|
||||
const STEPS = [
|
||||
{ id: 'describe', label: 'Describe', endpoint: 'POST /v1/agents' },
|
||||
{ id: 'configure', label: 'Configure', endpoint: '' },
|
||||
{ id: 'run', label: 'Run', endpoint: 'POST /v1/agents/:ref/run' },
|
||||
{ id: 'integrate', label: 'Integrate', endpoint: '' },
|
||||
] as const
|
||||
|
||||
type StepId = (typeof STEPS)[number]['id']
|
||||
|
||||
/**
|
||||
* The step ladder. A step reached earlier is a real link back — going back to change
|
||||
* the prompt is the most common thing a person wants here, and a ladder you cannot
|
||||
* climb down is a worse version of a heading.
|
||||
*/
|
||||
function StepLadder({ current, onGo }: { current: StepId; onGo: (s: StepId) => void }) {
|
||||
const index = STEPS.findIndex((s) => s.id === current)
|
||||
return (
|
||||
<XStack items="center" gap="$2" flexWrap="wrap" role="list" aria-label="Quickstart steps">
|
||||
{STEPS.map((s, i) => {
|
||||
const done = i < index
|
||||
const active = i === index
|
||||
return (
|
||||
<XStack key={s.id} items="center" gap="$2" role="listitem">
|
||||
{i > 0 ? <XStack width={20} height={1} bg="$borderColor" $md={{ width: 32 }} /> : null}
|
||||
<Button
|
||||
size="$2"
|
||||
chromeless
|
||||
px="$2"
|
||||
disabled={i > index}
|
||||
onPress={() => onGo(s.id)}
|
||||
opacity={i > index ? 0.45 : 1}
|
||||
aria-current={active ? 'step' : undefined}
|
||||
aria-label={`Step ${i + 1}: ${s.label}${done ? ' (done)' : ''}`}
|
||||
>
|
||||
<XStack items="center" gap="$2">
|
||||
<XStack
|
||||
width={20}
|
||||
height={20}
|
||||
rounded="$10"
|
||||
items="center"
|
||||
justify="center"
|
||||
bg={done || active ? '$color12' : 'transparent'}
|
||||
borderWidth={done || active ? 0 : 1}
|
||||
borderColor="$borderColor"
|
||||
>
|
||||
{done ? (
|
||||
<Check size={12} color="$color1" />
|
||||
) : (
|
||||
<Text fontSize="$1" fontWeight="700" color={active ? '$color1' : '$color10'}>
|
||||
{i + 1}
|
||||
</Text>
|
||||
)}
|
||||
</XStack>
|
||||
<Text fontSize="$2" fontWeight={active ? '700' : '500'} color={active ? '$color12' : '$color10'}>
|
||||
{s.label}
|
||||
</Text>
|
||||
{active && s.endpoint ? (
|
||||
<Text fontSize="$1" color="$color9" fontFamily="$mono" display="none" $md={{ display: 'flex' }}>
|
||||
{s.endpoint}
|
||||
</Text>
|
||||
) : null}
|
||||
</XStack>
|
||||
</Button>
|
||||
</XStack>
|
||||
)
|
||||
})}
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
/** One template card in the gallery. The whole card is the control. */
|
||||
function TemplateCard({ template, onPick }: { template: AgentTemplate; onPick: () => void }) {
|
||||
return (
|
||||
<YStack
|
||||
onPress={onPick}
|
||||
cursor="pointer"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
focusable
|
||||
onKeyDown={(e: { key?: string; preventDefault?: () => void }) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault?.()
|
||||
onPick()
|
||||
}
|
||||
}}
|
||||
gap="$1.5"
|
||||
p="$3"
|
||||
rounded="$4"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
bg="$color2"
|
||||
hoverStyle={{ bg: '$color3', borderColor: '$color8' }}
|
||||
aria-label={`Start from ${template.title}`}
|
||||
>
|
||||
<Text fontSize="$3" fontWeight="700" color="$color12">
|
||||
{template.title}
|
||||
</Text>
|
||||
<Text fontSize="$2" color="$color11">
|
||||
{template.summary}
|
||||
</Text>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
/** A short, quiet note — used wherever a step has to say what is missing. */
|
||||
function Note({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Text fontSize="$2" color="$color10">
|
||||
{children}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
export function AgentQuickstart({
|
||||
loaders,
|
||||
onFinished,
|
||||
apiBase = 'https://api.hanzo.ai',
|
||||
}: {
|
||||
loaders: AgentBuilderLoaders
|
||||
/** Called when the user leaves the quickstart with an agent created (host reloads). */
|
||||
onFinished?: (name: string) => void
|
||||
/** The API origin the integrate snippet should show. */
|
||||
apiBase?: string
|
||||
}) {
|
||||
const [step, setStep] = useState<StepId>('describe')
|
||||
const [seed, setSeed] = useState<Partial<AgentSpec>>({})
|
||||
// Bumped whenever a NEW starting point is chosen, so the builder remounts on it
|
||||
// rather than an effect racing whatever the user has already typed.
|
||||
const [seedKey, setSeedKey] = useState(0)
|
||||
const [created, setCreated] = useState<string | null>(null)
|
||||
|
||||
// ── Step 1: describe ──────────────────────────────────────────────────────
|
||||
const [description, setDescription] = useState('')
|
||||
const [drafting, setDrafting] = useState(false)
|
||||
const [draftError, setDraftError] = useState<string | null>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
const templates = useMemo(() => searchTemplates(query), [query])
|
||||
|
||||
const start = (next: Partial<AgentSpec>) => {
|
||||
setSeed(next)
|
||||
setSeedKey((k) => k + 1)
|
||||
setStep('configure')
|
||||
}
|
||||
|
||||
const pickTemplate = (t: AgentTemplate) => start(specFromTemplate(t, emptySpec(), defaultConfig()))
|
||||
|
||||
const describe = async () => {
|
||||
const text = description.trim()
|
||||
if (!text || drafting) return
|
||||
// Whatever happens next, the user's own words are already worth something: they
|
||||
// are the description, and they propose the handle. A draft only ever ADDS to
|
||||
// this, so a failed or absent draft still lands them in a part-filled form.
|
||||
const fallback: Partial<AgentSpec> = { description: text, name: proposeName(text) }
|
||||
if (!loaders.draftAgent) {
|
||||
start(fallback)
|
||||
return
|
||||
}
|
||||
setDrafting(true)
|
||||
setDraftError(null)
|
||||
try {
|
||||
const drafted = await loaders.draftAgent(text)
|
||||
start({ ...fallback, ...drafted })
|
||||
} catch (e) {
|
||||
// Say why, and still go — being stranded on a spinner is worse than writing
|
||||
// the prompt yourself.
|
||||
setDraftError(e instanceof Error ? e.message : 'Could not draft this one — write the prompt yourself.')
|
||||
start(fallback)
|
||||
} finally {
|
||||
setDrafting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 3: run ───────────────────────────────────────────────────────────
|
||||
const [input, setInput] = useState('')
|
||||
const [running, setRunning] = useState(false)
|
||||
const [run, setRun] = useState<AgentRunResult | null>(null)
|
||||
const [runError, setRunError] = useState<string | null>(null)
|
||||
|
||||
const doRun = async () => {
|
||||
const text = input.trim()
|
||||
if (!text || !created || !loaders.runAgent || running) return
|
||||
setRunning(true)
|
||||
setRunError(null)
|
||||
setRun(null)
|
||||
try {
|
||||
setRun(await loaders.runAgent(created, text))
|
||||
} catch (e) {
|
||||
// A failed run answers 502 with the RUN as its body, so this message is the
|
||||
// run's own reason — not a generic transport failure.
|
||||
setRunError(e instanceof Error ? e.message : 'The run did not complete.')
|
||||
} finally {
|
||||
setRunning(false)
|
||||
}
|
||||
}
|
||||
|
||||
const snippet = useMemo(
|
||||
() =>
|
||||
[
|
||||
`curl ${apiBase}/v1/agents/${created ?? 'your-agent'}/run \\`,
|
||||
` -H "Authorization: Bearer $HANZO_API_KEY" \\`,
|
||||
` -H "Content-Type: application/json" \\`,
|
||||
` -d '{"input":"${(input.trim() || 'your message here').replace(/'/g, "'\\''").replace(/"/g, '\\"')}"}'`,
|
||||
].join('\n'),
|
||||
[apiBase, created, input],
|
||||
)
|
||||
|
||||
return (
|
||||
<YStack gap="$4">
|
||||
<StepLadder current={step} onGo={setStep} />
|
||||
|
||||
{/* ── 1 · Describe ─────────────────────────────────────────────────── */}
|
||||
{step === 'describe' ? (
|
||||
<XStack gap="$4" items="flex-start" flexWrap="wrap">
|
||||
<YStack flex={2} minW={320} gap="$3" py="$6">
|
||||
<YStack gap="$2" items="center" py="$4">
|
||||
<Text fontSize="$8" fontWeight="800" color="$color12" style={{ textAlign: 'center' }}>
|
||||
What do you want to build?
|
||||
</Text>
|
||||
<Text fontSize="$3" color="$color11" style={{ textAlign: 'center' }}>
|
||||
Describe your agent, or start from a template.
|
||||
</Text>
|
||||
</YStack>
|
||||
|
||||
<YStack
|
||||
bg="$color2"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
rounded="$7"
|
||||
px="$3"
|
||||
py="$2.5"
|
||||
gap="$2"
|
||||
data-field-box
|
||||
>
|
||||
<XStack gap="$2" items="flex-end">
|
||||
<TextArea
|
||||
flex={1}
|
||||
value={description}
|
||||
onChangeText={setDescription}
|
||||
placeholder="Describe your agent…"
|
||||
numberOfLines={3}
|
||||
disabled={drafting}
|
||||
borderWidth={0}
|
||||
bg="transparent"
|
||||
px="$1"
|
||||
py="$1"
|
||||
aria-label="Describe your agent"
|
||||
// Enter sends, Shift+Enter is a newline, and a key mid-IME-composition
|
||||
// is never a send — an open candidate window must not submit the turn.
|
||||
onKeyDown={(e) => {
|
||||
const ev = e as unknown as {
|
||||
key?: string
|
||||
shiftKey?: boolean
|
||||
preventDefault?: () => void
|
||||
nativeEvent?: { isComposing?: boolean }
|
||||
}
|
||||
if (ev.key === 'Enter' && !ev.shiftKey && !ev.nativeEvent?.isComposing) {
|
||||
ev.preventDefault?.()
|
||||
void describe()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="$2"
|
||||
circular
|
||||
theme="light"
|
||||
disabled={!description.trim() || drafting}
|
||||
onPress={() => void describe()}
|
||||
icon={drafting ? undefined : <ArrowRight size={16} />}
|
||||
aria-label="Draft this agent"
|
||||
>
|
||||
{drafting ? <Spinner size="small" /> : undefined}
|
||||
</Button>
|
||||
</XStack>
|
||||
</YStack>
|
||||
|
||||
{!loaders.draftAgent ? (
|
||||
<Note>
|
||||
Drafting isn’t connected here, so your words become the agent’s description and handle and you
|
||||
write the prompt in the next step.
|
||||
</Note>
|
||||
) : null}
|
||||
{draftError ? (
|
||||
<Text fontSize="$2" color="$red10">
|
||||
{draftError}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
|
||||
{/* Templates — a real gallery, searchable, each card a preset the builder
|
||||
can already express. */}
|
||||
<YStack flex={1} minW={280} gap="$2.5" p="$3" rounded="$5" borderWidth={1} borderColor="$borderColor">
|
||||
<Text fontSize="$4" fontWeight="700" color="$color12">
|
||||
Browse templates
|
||||
</Text>
|
||||
<XStack
|
||||
items="center"
|
||||
gap="$2"
|
||||
px="$2.5"
|
||||
height={34}
|
||||
rounded="$3"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
bg="$color2"
|
||||
data-field-box
|
||||
>
|
||||
<Search size={14} opacity={0.6} />
|
||||
<Input
|
||||
flex={1}
|
||||
unstyled
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
placeholder="Search templates"
|
||||
fontSize="$3"
|
||||
color="$color12"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
aria-label="Search templates"
|
||||
/>
|
||||
{query ? (
|
||||
<Button size="$1" chromeless icon={<X size={13} />} onPress={() => setQuery('')} aria-label="Clear search" />
|
||||
) : null}
|
||||
</XStack>
|
||||
<ScrollView maxH={520}>
|
||||
<YStack gap="$2">
|
||||
{templates.map((t) => (
|
||||
<TemplateCard key={t.id} template={t} onPick={() => pickTemplate(t)} />
|
||||
))}
|
||||
{templates.length === 0 ? (
|
||||
<Note>No template matches “{query.trim()}”. Describe it instead — that always works.</Note>
|
||||
) : null}
|
||||
</YStack>
|
||||
</ScrollView>
|
||||
</YStack>
|
||||
</XStack>
|
||||
) : null}
|
||||
|
||||
{/* ── 2 · Configure ────────────────────────────────────────────────── */}
|
||||
{step === 'configure' ? (
|
||||
<YStack gap="$3" maxW={720}>
|
||||
<AgentBuilder
|
||||
key={seedKey}
|
||||
loaders={loaders}
|
||||
initial={seed}
|
||||
onCancel={() => setStep('describe')}
|
||||
onCreated={(name) => {
|
||||
setCreated(name)
|
||||
setStep('run')
|
||||
onFinished?.(name)
|
||||
}}
|
||||
/>
|
||||
</YStack>
|
||||
) : null}
|
||||
|
||||
{/* ── 3 · Run ──────────────────────────────────────────────────────── */}
|
||||
{step === 'run' && created ? (
|
||||
<YStack gap="$3" maxW={720}>
|
||||
<XStack items="center" gap="$2">
|
||||
<Bot size={16} />
|
||||
<Text fontSize="$5" fontWeight="800" color="$color12">
|
||||
{created}
|
||||
</Text>
|
||||
<Text fontSize="$2" color="$color10">
|
||||
is live
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$2" color="$color11">
|
||||
Send it something. This runs the agent for real and bills the run to your organization.
|
||||
</Text>
|
||||
|
||||
{loaders.runAgent ? (
|
||||
<>
|
||||
<YStack
|
||||
bg="$color2"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
rounded="$5"
|
||||
px="$3"
|
||||
py="$2.5"
|
||||
data-field-box
|
||||
>
|
||||
<XStack gap="$2" items="flex-end">
|
||||
<TextArea
|
||||
flex={1}
|
||||
value={input}
|
||||
onChangeText={setInput}
|
||||
placeholder="Your message to the agent…"
|
||||
numberOfLines={3}
|
||||
disabled={running}
|
||||
borderWidth={0}
|
||||
bg="transparent"
|
||||
px="$1"
|
||||
py="$1"
|
||||
aria-label="Message to the agent"
|
||||
onKeyDown={(e) => {
|
||||
const ev = e as unknown as {
|
||||
key?: string
|
||||
shiftKey?: boolean
|
||||
preventDefault?: () => void
|
||||
nativeEvent?: { isComposing?: boolean }
|
||||
}
|
||||
if (ev.key === 'Enter' && !ev.shiftKey && !ev.nativeEvent?.isComposing) {
|
||||
ev.preventDefault?.()
|
||||
void doRun()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="$2"
|
||||
theme="light"
|
||||
disabled={!input.trim() || running}
|
||||
onPress={() => void doRun()}
|
||||
icon={running ? undefined : <Play size={15} />}
|
||||
>
|
||||
{running ? <Spinner size="small" /> : 'Run'}
|
||||
</Button>
|
||||
</XStack>
|
||||
</YStack>
|
||||
|
||||
{runError ? (
|
||||
<Card gap="$1.5" p="$3" rounded="$4" bg="$color2" borderWidth={1} borderColor="$borderColor">
|
||||
<Text fontSize="$3" fontWeight="700" color="$red10">
|
||||
The run failed
|
||||
</Text>
|
||||
<Text fontSize="$2" color="$color11">
|
||||
{runError}
|
||||
</Text>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{run ? (
|
||||
<Card gap="$2" p="$3" rounded="$4" bg="$color2" borderWidth={1} borderColor="$borderColor">
|
||||
<XStack items="center" gap="$2" flexWrap="wrap">
|
||||
<Text fontSize="$2" fontWeight="700" color={run.status === 'ok' ? '$green10' : '$red10'}>
|
||||
{run.status === 'ok' ? 'ok' : run.status || 'error'}
|
||||
</Text>
|
||||
{run.model ? (
|
||||
<Text fontSize="$1" color="$color10">
|
||||
{run.model}
|
||||
</Text>
|
||||
) : null}
|
||||
{run.durationMs != null ? (
|
||||
<Text fontSize="$1" color="$color10">
|
||||
{run.durationMs} ms
|
||||
</Text>
|
||||
) : null}
|
||||
</XStack>
|
||||
<Text fontSize="$3" color="$color12">
|
||||
{run.output || run.error || 'The run recorded no output.'}
|
||||
</Text>
|
||||
</Card>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<Card gap="$1.5" p="$3" rounded="$4" bg="$color2" borderWidth={1} borderColor="$borderColor">
|
||||
<XStack items="center" gap="$2">
|
||||
<Terminal size={14} />
|
||||
<Text fontSize="$3" fontWeight="700" color="$color12">
|
||||
Running from here isn’t connected on this deployment
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$2" color="$color11">
|
||||
The agent exists and `POST /v1/agents/{created}/run` is its endpoint — the next step shows the
|
||||
call.
|
||||
</Text>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<XStack gap="$2">
|
||||
<Button flex={1} theme="light" iconAfter={<ArrowRight size={15} />} onPress={() => setStep('integrate')}>
|
||||
Integrate
|
||||
</Button>
|
||||
</XStack>
|
||||
</YStack>
|
||||
) : null}
|
||||
|
||||
{/* ── 4 · Integrate ────────────────────────────────────────────────── */}
|
||||
{step === 'integrate' && created ? (
|
||||
<YStack gap="$3" maxW={720}>
|
||||
<Text fontSize="$5" fontWeight="800" color="$color12">
|
||||
Call it from your code
|
||||
</Text>
|
||||
<Text fontSize="$2" color="$color11">
|
||||
The same request the Run step just made. Mint a key under API keys and set it as `HANZO_API_KEY`.
|
||||
</Text>
|
||||
<YStack p="$3" rounded="$4" bg="$color2" borderWidth={1} borderColor="$borderColor">
|
||||
<Text fontSize="$2" fontFamily="$mono" color="$color12" style={{ whiteSpace: 'pre-wrap' }}>
|
||||
{snippet}
|
||||
</Text>
|
||||
</YStack>
|
||||
<Note>
|
||||
It answers with the recorded run — its id, status, model, output and duration. A model failure comes
|
||||
back as a run with `status: "error"` and the reason, never as silence.
|
||||
</Note>
|
||||
</YStack>
|
||||
) : null}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -9,10 +9,12 @@
|
||||
* lifts cleanly into a published `@hanzo/agent-builder` package.
|
||||
*/
|
||||
export { AgentBuilder } from './AgentBuilder'
|
||||
export { AgentQuickstart } from './Quickstart'
|
||||
export type {
|
||||
AgentSpec,
|
||||
AgentConfig,
|
||||
AgentCreateBody,
|
||||
AgentRunResult,
|
||||
ReasoningEffort,
|
||||
AgentBuilderLoaders,
|
||||
BuilderOption,
|
||||
@@ -34,4 +36,10 @@ export {
|
||||
promptBodyFromRow,
|
||||
promptOptions,
|
||||
classifyBuilderError,
|
||||
draftInstruction,
|
||||
parseDraft,
|
||||
proposeName,
|
||||
toHandle,
|
||||
} from './logic'
|
||||
export { AGENT_TEMPLATES, matchTemplate, searchTemplates, templateById, specFromTemplate } from './templates'
|
||||
export type { AgentTemplate } from './templates'
|
||||
|
||||
@@ -14,6 +14,9 @@ import {
|
||||
promptBodyFromRow,
|
||||
promptOptions,
|
||||
classifyBuilderError,
|
||||
proposeName,
|
||||
toHandle,
|
||||
parseDraft,
|
||||
} from './logic'
|
||||
import type { AgentConfig, AgentSpec, BuilderOption, BuilderPrompt } from './types'
|
||||
|
||||
@@ -32,13 +35,21 @@ describe('defaultModel', () => {
|
||||
expect(defaultModel([])).toBe('')
|
||||
})
|
||||
|
||||
it('prefers the exact zen-omni default when present', () => {
|
||||
expect(defaultModel([opt('gpt-4o'), opt('zen-omni'), opt('claude')])).toBe('zen-omni')
|
||||
it('prefers the exact zen5 default when present', () => {
|
||||
expect(defaultModel([opt('gpt-4o'), opt('zen5'), opt('claude')])).toBe('zen5')
|
||||
})
|
||||
|
||||
it('falls back to the first Zen-family model (prefix or provider hint)', () => {
|
||||
expect(defaultModel([opt('gpt-4o'), opt('zen-coder')])).toBe('zen-coder')
|
||||
expect(defaultModel([opt('gpt-4o'), opt('some-model', 'Zen')])).toBe('some-model')
|
||||
it('falls back to another Zen TEXT model', () => {
|
||||
expect(defaultModel([opt('gpt-4o'), opt('zen5-coder')])).toBe('zen5-coder')
|
||||
})
|
||||
|
||||
// The defect this rule exists to prevent: a catalog arrives sorted, so a loose
|
||||
// `^zen[-.]` test selected `zen-embedding` — an embeddings SKU that cannot hold a
|
||||
// conversation — as the default model for every new agent.
|
||||
it('never defaults to a modality SKU over a text model', () => {
|
||||
const live = [opt('zen-embedding'), opt('zen-image'), opt('zen-rerank'), opt('zen-vl'), opt('zen5'), opt('zen5-mini')]
|
||||
expect(defaultModel(live)).toBe('zen5')
|
||||
expect(defaultModel(live.filter((o) => o.value !== 'zen5'))).toBe('zen5-mini')
|
||||
})
|
||||
|
||||
it('falls back to the first catalog id when no Zen model exists', () => {
|
||||
@@ -189,3 +200,69 @@ describe('classifyBuilderError', () => {
|
||||
expect(classifyBuilderError('boom')).toEqual({ kind: 'error', message: 'Could not create the agent.' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('proposeName', () => {
|
||||
it('makes a handle out of the words a person actually typed', () => {
|
||||
expect(proposeName('An agent that triages support tickets')).toBe('triages-support-tickets')
|
||||
})
|
||||
it('drops noise words and punctuation', () => {
|
||||
expect(proposeName('The agent for our billing!! questions')).toBe('billing-questions')
|
||||
})
|
||||
it('is empty when there is nothing usable', () => {
|
||||
expect(proposeName(' ')).toBe('')
|
||||
expect(proposeName('a the it')).toBe('')
|
||||
})
|
||||
it('caps the length so the handle stays a handle', () => {
|
||||
expect(proposeName('extraordinarily verbose descriptive nomenclature').length).toBeLessThanOrEqual(32)
|
||||
})
|
||||
})
|
||||
|
||||
describe('toHandle', () => {
|
||||
it('reshapes without re-wording — a handle survives intact', () => {
|
||||
expect(toHandle('support-agent')).toBe('support-agent')
|
||||
expect(toHandle('Support Triage Bot!')).toBe('support-triage-bot')
|
||||
})
|
||||
it('collapses runs and trims the edges', () => {
|
||||
expect(toHandle(' --a // b-- ')).toBe('a-b')
|
||||
})
|
||||
it('caps the length and never ends on a hyphen', () => {
|
||||
const h = toHandle('extraordinarily verbose descriptive nomenclature here')
|
||||
expect(h.length).toBeLessThanOrEqual(32)
|
||||
expect(h.endsWith('-')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseDraft', () => {
|
||||
it('reads the three fields it asked for', () => {
|
||||
const d = parseDraft('{"name":"support-triage","description":"Triages tickets.","systemPrompt":"You triage."}')
|
||||
expect(d).toEqual({ name: 'support-triage', description: 'Triages tickets.', systemPrompt: 'You triage.' })
|
||||
})
|
||||
|
||||
// The two things models actually do to JSON.
|
||||
it('survives a code fence and surrounding prose', () => {
|
||||
const answer = 'Sure! Here you go:\n```json\n{"name":"helper","systemPrompt":"You help."}\n```\nHope that works.'
|
||||
expect(parseDraft(answer)).toEqual({ name: 'helper', systemPrompt: 'You help.' })
|
||||
})
|
||||
|
||||
it('normalizes a handle the backend would refuse', () => {
|
||||
expect(parseDraft('{"name":"Support Triage Bot!"}')?.name).toBe('support-triage-bot')
|
||||
})
|
||||
|
||||
it('accepts the snake_case and bare spellings of the prompt', () => {
|
||||
expect(parseDraft('{"system_prompt":"You help."}')?.systemPrompt).toBe('You help.')
|
||||
expect(parseDraft('{"prompt":"You help."}')?.systemPrompt).toBe('You help.')
|
||||
})
|
||||
|
||||
// A creative answer may only ever produce LESS than asked, never a field the
|
||||
// builder cannot express.
|
||||
it('drops every key it does not recognize', () => {
|
||||
const d = parseDraft('{"name":"a-b","model":"gpt-9","tools":["rm -rf"],"webhook":"http://evil"}')
|
||||
expect(d).toEqual({ name: 'a-b' })
|
||||
})
|
||||
|
||||
it('is null when there is no object, or only empty fields', () => {
|
||||
expect(parseDraft('I could not do that.')).toBeNull()
|
||||
expect(parseDraft('{ not json }')).toBeNull()
|
||||
expect(parseDraft('{"name":" ","description":""}')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -23,23 +23,30 @@ export function defaultConfig(): AgentConfig {
|
||||
return { temperature: 0.7, topP: 1, topK: 0, stream: true, thinking: false, useTools: true, webSearch: false }
|
||||
}
|
||||
|
||||
/** The default Zen model to preselect when the catalog offers one. */
|
||||
const ZEN_DEFAULT = 'zen-omni'
|
||||
/** The Zen text model to preselect when the catalog offers it. */
|
||||
const ZEN_DEFAULT = 'zen5'
|
||||
|
||||
/**
|
||||
* Pick a sensible default model from a live catalog: the Zen default if present,
|
||||
* else the first Zen (`hanzo`-owned / `zen-` prefixed) model, else the first
|
||||
* catalog id, else '' (nothing to default to — the field stays empty/typeable).
|
||||
* PURE. Never invents an id — only returns one the catalog actually lists.
|
||||
* else another model from the Zen TEXT family, else the first catalog id, else ''
|
||||
* (nothing to default to — the field stays empty/typeable). PURE. Never invents an
|
||||
* id — only returns one the catalog actually lists.
|
||||
*
|
||||
* The text-family test is `zen5…`, and that specificity is load-bearing. Zen's naming
|
||||
* splits cleanly: `zen5`, `zen5-mini`, `zen5-flash`, `zen5-coder`, `zen5-pro` are the
|
||||
* text models, while `zen-<noun>` names a MODALITY — zen-embedding, zen-image,
|
||||
* zen-video, zen-rerank, zen-voice, zen-vl. A looser `^zen[-.]` test matched both, and
|
||||
* since a catalog arrives sorted it selected `zen-embedding`: every agent created
|
||||
* without touching the model field was pointed at an embeddings SKU that cannot hold a
|
||||
* conversation. (It went unnoticed because the exact-match arm named `zen-omni`, which
|
||||
* the live catalog does not carry, so the fallback was always the arm that ran.)
|
||||
*/
|
||||
export function defaultModel(options: BuilderOption[]): string {
|
||||
if (options.length === 0) return ''
|
||||
const exact = options.find((o) => o.value === ZEN_DEFAULT)
|
||||
if (exact) return exact.value
|
||||
const zen = options.find(
|
||||
(o) => /^zen[-.]/i.test(o.value) || (o.hint ?? '').toLowerCase().includes('zen'),
|
||||
)
|
||||
return (zen ?? options[0]).value
|
||||
const zenText = options.find((o) => /^zen\d/i.test(o.value))
|
||||
return (zenText ?? options[0]).value
|
||||
}
|
||||
|
||||
/** True iff the spec can be submitted (a non-empty trimmed name is the only requirement). */
|
||||
@@ -150,6 +157,111 @@ export function promptOptions(prompts: BuilderPrompt[]): BuilderOption[] {
|
||||
return prompts.map((p) => ({ value: p.name, label: p.label ?? p.name, hint: p.hint }))
|
||||
}
|
||||
|
||||
// ── Drafting an agent from a sentence ───────────────────────────────────────
|
||||
//
|
||||
// The quickstart lets someone describe an agent in their own words. That is a
|
||||
// model call, so the EFFECT is an injected loader (`draftAgent`) like every other;
|
||||
// what lives here is the pure half — the instruction we send, and the parse of what
|
||||
// comes back. Both are pure so the fragile part (reading a model's JSON) is tested
|
||||
// against real malformed answers rather than trusted.
|
||||
|
||||
/**
|
||||
* The instruction that turns a description into a spec. It asks for the three
|
||||
* fields a person would otherwise type and NOTHING else — deliberately not `model`
|
||||
* or `tools`: a model id must exist in the org's live catalog and a tool must exist
|
||||
* in its tool plane, and a model asked to name one will happily invent it. Those two
|
||||
* fields stay with the pickers that know the real answers. PURE.
|
||||
*/
|
||||
export function draftInstruction(): string {
|
||||
return [
|
||||
'You turn a description of an agent into its definition.',
|
||||
'',
|
||||
'Reply with ONE JSON object and nothing else — no prose, no code fence. Keys:',
|
||||
' "name" a short lowercase handle, words joined by hyphens (e.g. support-triage)',
|
||||
' "description" one sentence on what the agent does',
|
||||
' "systemPrompt" the agent\'s own instructions, written in the second person',
|
||||
'',
|
||||
'The system prompt is the real work: state what the agent does, what it must not do,',
|
||||
'and how it should behave when it is unsure. Write it as instructions to the agent,',
|
||||
'not as a description of it.',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/** Stop-words that carry no meaning in a handle. */
|
||||
const NOISE = new Set(['a', 'an', 'the', 'that', 'this', 'my', 'our', 'for', 'to', 'of', 'and', 'is', 'it', 'agent'])
|
||||
|
||||
/**
|
||||
* Put any string into handle FORM: lowercase, letters and digits kept, everything
|
||||
* else a hyphen, no repeated or trailing hyphens, capped. It reshapes and never
|
||||
* re-words — `support-agent` stays `support-agent`. PURE.
|
||||
*/
|
||||
export function toHandle(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 32)
|
||||
.replace(/-+$/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* A handle proposed from PROSE — the user's own sentence — so the form is never left
|
||||
* with an empty required field even when the draft call fails. Drops the words that
|
||||
* carry no meaning in a handle, keeps the first three that do, and puts the result in
|
||||
* handle form. Returns '' when the text carries nothing usable.
|
||||
*
|
||||
* Distinct from `toHandle` on purpose, and the two must not be confused: this one
|
||||
* REWORDS, which is right for a sentence and wrong for a handle. Running it over an
|
||||
* already-formed handle silently renames it — `support-agent` would come back as
|
||||
* `support`, because "agent" is noise in a sentence and load-bearing in a name. PURE.
|
||||
*/
|
||||
export function proposeName(description: string): string {
|
||||
const words = description
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, ' ')
|
||||
.split(/[\s-]+/)
|
||||
.filter((w) => w.length > 1 && !NOISE.has(w))
|
||||
return toHandle(words.slice(0, 3).join('-'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a drafted spec out of a model's answer. Tolerant of the two things models
|
||||
* actually do — wrapping the object in a ```json fence, and adding a sentence before
|
||||
* or after it — by taking the outermost braces. Every field is validated and
|
||||
* anything unrecognized is DROPPED, so a creative answer can only ever produce less
|
||||
* than asked, never a field the builder does not understand. Returns null when there
|
||||
* is no object at all. PURE.
|
||||
*/
|
||||
export function parseDraft(answer: string): Partial<AgentSpec> | null {
|
||||
const start = answer.indexOf('{')
|
||||
const end = answer.lastIndexOf('}')
|
||||
if (start < 0 || end <= start) return null
|
||||
let raw: unknown
|
||||
try {
|
||||
raw = JSON.parse(answer.slice(start, end + 1))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
|
||||
const r = raw as Record<string, unknown>
|
||||
const text = (v: unknown): string | undefined => (typeof v === 'string' && v.trim() ? v.trim() : undefined)
|
||||
|
||||
const out: Partial<AgentSpec> = {}
|
||||
const name = text(r.name)
|
||||
// A handle the backend would refuse is worse than none, so reshape it — but with
|
||||
// `toHandle`, which only changes the FORM. `proposeName` would also re-word it, and
|
||||
// the model was asked for a handle, not a sentence.
|
||||
if (name) {
|
||||
const handle = toHandle(name)
|
||||
if (handle) out.name = handle
|
||||
}
|
||||
const description = text(r.description)
|
||||
if (description) out.description = description
|
||||
const systemPrompt = text(r.systemPrompt) ?? text(r.system_prompt) ?? text(r.prompt)
|
||||
if (systemPrompt) out.systemPrompt = systemPrompt
|
||||
return Object.keys(out).length ? out : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a create failure. A 404 (or an explicit "unavailable" BackendState kind)
|
||||
* means the `/v1/agents` route isn't bound on this deployment — an honest "not
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import { AGENT_TEMPLATES, matchTemplate, searchTemplates, specFromTemplate, templateById } from './templates'
|
||||
import { defaultConfig, emptySpec, toCreateBody } from './logic'
|
||||
|
||||
describe('AGENT_TEMPLATES', () => {
|
||||
it('has unique ids and a handle for every entry', () => {
|
||||
const ids = AGENT_TEMPLATES.map((t) => t.id)
|
||||
expect(new Set(ids).size).toBe(ids.length)
|
||||
for (const t of AGENT_TEMPLATES) {
|
||||
expect(t.name.trim()).not.toBe('')
|
||||
expect(t.title.trim()).not.toBe('')
|
||||
expect(t.summary.trim()).not.toBe('')
|
||||
}
|
||||
})
|
||||
|
||||
it('leads with the blank one — starting from nothing is the honest default', () => {
|
||||
expect(AGENT_TEMPLATES[0].id).toBe('blank')
|
||||
expect(AGENT_TEMPLATES[0].systemPrompt).toBe('')
|
||||
})
|
||||
|
||||
// The whole point of the module doc: a template is a preset, never a promise. It
|
||||
// may only carry fields the create body can already express, so picking one can
|
||||
// never produce an agent the builder itself could not.
|
||||
it('carries nothing the create body cannot express', () => {
|
||||
const allowed = new Set(['id', 'title', 'summary', 'name', 'systemPrompt', 'config'])
|
||||
for (const t of AGENT_TEMPLATES) {
|
||||
for (const key of Object.keys(t)) expect(allowed.has(key)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
// A hardcoded tool id would name something the org may not have activated, and it
|
||||
// would fail at the agent's FIRST invocation rather than here. Tools come from the
|
||||
// live tool plane or not at all.
|
||||
it('names no tools — those come from the live tool plane', () => {
|
||||
for (const t of AGENT_TEMPLATES) expect(t).not.toHaveProperty('tools')
|
||||
})
|
||||
|
||||
it('every template produces a submittable body', () => {
|
||||
for (const t of AGENT_TEMPLATES) {
|
||||
const body = toCreateBody(specFromTemplate(t, emptySpec(), defaultConfig()))
|
||||
expect(body.name).toBe(t.name)
|
||||
expect(body.description).toBe(t.summary)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('matchTemplate / searchTemplates', () => {
|
||||
const t = AGENT_TEMPLATES.find((x) => x.id === 'researcher')!
|
||||
|
||||
it('an empty query matches everything', () => {
|
||||
expect(matchTemplate(t, '')).toBe(true)
|
||||
expect(matchTemplate(t, ' ')).toBe(true)
|
||||
expect(searchTemplates('')).toHaveLength(AGENT_TEMPLATES.length)
|
||||
})
|
||||
|
||||
it('matches title, summary and id, case-insensitively', () => {
|
||||
expect(matchTemplate(t, 'DEEP')).toBe(true)
|
||||
expect(matchTemplate(t, 'sources')).toBe(true)
|
||||
expect(matchTemplate(t, 'researcher')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns nothing for a query nothing carries', () => {
|
||||
expect(searchTemplates('quantum tuba')).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps gallery order', () => {
|
||||
const found = searchTemplates('a').map((x) => x.id)
|
||||
expect(found).toEqual(AGENT_TEMPLATES.filter((x) => matchTemplate(x, 'a')).map((x) => x.id))
|
||||
})
|
||||
})
|
||||
|
||||
describe('templateById', () => {
|
||||
it('finds one, and is null for an unknown id', () => {
|
||||
expect(templateById('blank')?.title).toBe('Blank agent')
|
||||
expect(templateById('nope')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('specFromTemplate', () => {
|
||||
const t = AGENT_TEMPLATES.find((x) => x.id === 'extractor')!
|
||||
|
||||
it('fills name, description and prompt from the template', () => {
|
||||
const s = specFromTemplate(t, emptySpec(), defaultConfig())
|
||||
expect(s.name).toBe(t.name)
|
||||
expect(s.description).toBe(t.summary)
|
||||
expect(s.systemPrompt).toBe(t.systemPrompt)
|
||||
})
|
||||
|
||||
// The template owns the agent's character; the MODEL is the org's own decision and
|
||||
// its tool list is the org's too, so neither is overwritten by picking one.
|
||||
it('keeps a model and tools the user already chose', () => {
|
||||
const current = { ...emptySpec(), model: 'zen5-pro', tools: ['already.picked'] }
|
||||
const s = specFromTemplate(t, current, defaultConfig())
|
||||
expect(s.model).toBe('zen5-pro')
|
||||
expect(s.tools).toEqual(['already.picked'])
|
||||
})
|
||||
|
||||
it('merges the template config over the defaults, leaving the rest alone', () => {
|
||||
const s = specFromTemplate(t, emptySpec(), defaultConfig())
|
||||
expect(s.config?.temperature).toBe(0)
|
||||
expect(s.config?.stream).toBe(defaultConfig().stream)
|
||||
})
|
||||
|
||||
it('posts no config for a template that needs none', () => {
|
||||
const blank = templateById('blank')!
|
||||
expect(specFromTemplate(blank, emptySpec(), defaultConfig()).config).toBeUndefined()
|
||||
expect(toCreateBody(specFromTemplate(blank, emptySpec(), defaultConfig()))).not.toHaveProperty('config')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Agent templates — starting points for the ONE builder, shared by every surface.
|
||||
*
|
||||
* A template is a PRESET, never a promise: every field it carries maps to something
|
||||
* `POST /v1/agents` already accepts (`name`, `description`, `systemPrompt`, and the
|
||||
* `config` knobs in `AgentConfig`). Picking one fills the builder and nothing else
|
||||
* happens — the user still sees, edits and submits the same form, so a template can
|
||||
* never create an agent the builder itself could not.
|
||||
*
|
||||
* Deliberately NO tool ids. Tools come from the live tool plane (`GET /v1/tools`),
|
||||
* which knows what an org has actually activated; a hardcoded `web.search` here would
|
||||
* name something that may not exist and would fail on the agent's first invocation.
|
||||
* What a template CAN say about tools is the truth: `useTools` and `webSearch` are
|
||||
* real switches in the agent contract, so a template that needs them turns them on
|
||||
* and the builder's live tool picker fills in the specifics.
|
||||
*
|
||||
* Pure data + pure helpers — no React, no I/O — so this lifts into
|
||||
* `@hanzo/agent-builder` with the rest of the module.
|
||||
*/
|
||||
import type { AgentConfig, AgentSpec } from './types'
|
||||
|
||||
/** A named starting point: what it is, and the spec it fills the builder with. */
|
||||
export type AgentTemplate = {
|
||||
/** Stable id — the URL/search key. */
|
||||
id: string
|
||||
/** What it is called in the gallery. */
|
||||
title: string
|
||||
/** One line on what the agent does. Shown on the card and searched. */
|
||||
summary: string
|
||||
/** The seed handle; the user renames freely before submitting. */
|
||||
name: string
|
||||
/** The system prompt this template starts from ('' for the blank one). */
|
||||
systemPrompt: string
|
||||
/** Only the knobs this template genuinely needs; the rest stay at their defaults. */
|
||||
config?: Partial<AgentConfig>
|
||||
}
|
||||
|
||||
/**
|
||||
* The gallery, in display order. `blank` leads because starting from nothing is the
|
||||
* honest default — everything after it is a real, specific job.
|
||||
*/
|
||||
export const AGENT_TEMPLATES: readonly AgentTemplate[] = [
|
||||
{
|
||||
id: 'blank',
|
||||
title: 'Blank agent',
|
||||
summary: 'A starting point with nothing assumed — name it, pick a model, write the prompt.',
|
||||
name: 'my-agent',
|
||||
systemPrompt: '',
|
||||
},
|
||||
{
|
||||
id: 'researcher',
|
||||
title: 'Deep researcher',
|
||||
summary: 'Researches a question across the web and answers with the sources it used.',
|
||||
name: 'researcher',
|
||||
systemPrompt:
|
||||
'You research questions and report what you found.\n\n' +
|
||||
'Work in steps: decide what you need to know, search for it, read the results, and only then answer. ' +
|
||||
'Prefer primary sources over summaries of them.\n\n' +
|
||||
'Every claim that came from a source carries that source. When sources disagree, say so and give both. ' +
|
||||
'When you could not find something, say that plainly instead of filling the gap — an honest gap is more ' +
|
||||
'useful than a confident guess.',
|
||||
config: { webSearch: true, thinking: true, reasoningEffort: 'high' },
|
||||
},
|
||||
{
|
||||
id: 'extractor',
|
||||
title: 'Structured extractor',
|
||||
summary: 'Reads unstructured text and returns one typed JSON object, or says which fields were absent.',
|
||||
name: 'extractor',
|
||||
systemPrompt:
|
||||
'You turn unstructured text into one JSON object matching the schema the caller gives you.\n\n' +
|
||||
'Return the object and nothing else — no prose, no code fence, no explanation.\n\n' +
|
||||
'Copy values from the text; never infer one that is not there. A field the text does not support is null, ' +
|
||||
'and a guessed value is a defect. If the schema is ambiguous about a field, choose the reading that the ' +
|
||||
'text supports literally.',
|
||||
config: { temperature: 0, topP: 1 },
|
||||
},
|
||||
{
|
||||
id: 'support',
|
||||
title: 'Support answerer',
|
||||
summary: 'Answers product questions from your own material, and escalates the ones it cannot.',
|
||||
name: 'support',
|
||||
systemPrompt:
|
||||
'You answer product questions for customers, using the material available to you.\n\n' +
|
||||
'Answer from that material only. When it does not cover the question, say so and hand off rather than ' +
|
||||
'improvising — a wrong answer costs more than a slow one.\n\n' +
|
||||
'Lead with the answer, then the steps. Keep it short enough to act on. Never promise a behaviour, a date ' +
|
||||
'or a refund you cannot point to in the material.',
|
||||
config: { useTools: true, temperature: 0.3 },
|
||||
},
|
||||
{
|
||||
id: 'reviewer',
|
||||
title: 'Code reviewer',
|
||||
summary: 'Reads a diff and reports what will actually break, most severe first.',
|
||||
name: 'reviewer',
|
||||
systemPrompt:
|
||||
'You review code changes.\n\n' +
|
||||
'Report only defects you can name concretely: the input or state that triggers them, and the wrong output ' +
|
||||
'or crash that results. Correctness and security first, then clarity. Rank by severity.\n\n' +
|
||||
'Style preferences are not findings. Neither is a concern you cannot demonstrate — if you are unsure a ' +
|
||||
'thing is real, say you are unsure rather than listing it as a defect. Finding nothing is a valid review.',
|
||||
config: { thinking: true, reasoningEffort: 'high', temperature: 0.2 },
|
||||
},
|
||||
{
|
||||
id: 'analyst',
|
||||
title: 'Data analyst',
|
||||
summary: 'Explains a dataset — what is in it, what stands out, and what to check next.',
|
||||
name: 'analyst',
|
||||
systemPrompt:
|
||||
'You explain datasets to people who have to make a decision from them.\n\n' +
|
||||
'Start with the shape: how many rows, which columns, what period, and what is missing. Then the two or ' +
|
||||
'three things that genuinely stand out. Then what you would check next and why.\n\n' +
|
||||
'Every number you state comes from the data. Distinguish what the data shows from what you suspect, and ' +
|
||||
'name the limits — a sample too small to conclude from is the finding, not an obstacle to one.',
|
||||
config: { useTools: true, temperature: 0.2 },
|
||||
},
|
||||
{
|
||||
id: 'summarizer',
|
||||
title: 'Meeting summarizer',
|
||||
summary: 'Turns a transcript into decisions, owners and the questions still open.',
|
||||
name: 'summarizer',
|
||||
systemPrompt:
|
||||
'You turn meeting transcripts into something the people who missed it can act on.\n\n' +
|
||||
'Three sections: decisions made, actions with their owner, and questions left open. Nothing else.\n\n' +
|
||||
'Only record a decision that was actually reached — a topic discussed without resolution belongs under ' +
|
||||
'open questions. Attribute an action to a person only when the transcript names them; otherwise leave the ' +
|
||||
'owner unassigned and say so.',
|
||||
config: { temperature: 0.2 },
|
||||
},
|
||||
{
|
||||
id: 'triage',
|
||||
title: 'Incident triager',
|
||||
summary: 'Classifies an incoming report by severity and area, and drafts the first reply.',
|
||||
name: 'triage',
|
||||
systemPrompt:
|
||||
'You triage incoming incident reports.\n\n' +
|
||||
'For each one give: severity, the area it belongs to, what is affected, and a first reply to the reporter.\n\n' +
|
||||
'Severity follows blast radius, not tone — a calm report of data loss outranks an urgent one about a ' +
|
||||
'typo. When the report lacks what you need to classify it, the first reply asks for exactly that and the ' +
|
||||
'severity stays provisional. Never guess an area to avoid leaving one blank.',
|
||||
config: { temperature: 0.2, reasoningEffort: 'medium' },
|
||||
},
|
||||
]
|
||||
|
||||
/** Case-insensitive, whitespace-tolerant match over the fields a person would type. */
|
||||
export function matchTemplate(t: AgentTemplate, query: string): boolean {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return true
|
||||
return `${t.title} ${t.summary} ${t.id}`.toLowerCase().includes(q)
|
||||
}
|
||||
|
||||
/** The templates matching a query, in gallery order. */
|
||||
export function searchTemplates(query: string, templates: readonly AgentTemplate[] = AGENT_TEMPLATES): AgentTemplate[] {
|
||||
return templates.filter((t) => matchTemplate(t, query))
|
||||
}
|
||||
|
||||
/** The template with this id, or null. */
|
||||
export function templateById(id: string, templates: readonly AgentTemplate[] = AGENT_TEMPLATES): AgentTemplate | null {
|
||||
return templates.find((t) => t.id === id) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* The builder state a template starts from. Merged over the CURRENT spec so a model
|
||||
* the user already chose survives picking a template — the template owns the prompt
|
||||
* and the character of the agent, never the model, which is the org's own decision.
|
||||
*/
|
||||
export function specFromTemplate(t: AgentTemplate, current: AgentSpec, defaults: AgentConfig): AgentSpec {
|
||||
return {
|
||||
...current,
|
||||
name: t.name,
|
||||
description: t.summary,
|
||||
systemPrompt: t.systemPrompt,
|
||||
tools: current.tools,
|
||||
config: t.config ? { ...defaults, ...t.config } : undefined,
|
||||
}
|
||||
}
|
||||
@@ -138,6 +138,24 @@ export type AgentBuilderLoaders = {
|
||||
loadPromptBody?: (name: string) => Promise<string>
|
||||
/** The live tool catalog. Rejects → typeable-only tools. */
|
||||
loadTools?: () => Promise<BuilderOption[]>
|
||||
/**
|
||||
* Draft a spec from a plain-English description — the quickstart's "describe your
|
||||
* agent" box. A model call, so it is an effect like the rest; the instruction and
|
||||
* the parse of the answer are pure (`draftInstruction`, `parseDraft`) and shared.
|
||||
* Absent → the quickstart still works: the description seeds the handle and the
|
||||
* description field, and the user writes the prompt. Rejects → the same fallback,
|
||||
* with the reason shown, so a drafting failure never blocks building an agent.
|
||||
*/
|
||||
draftAgent?: (description: string) => Promise<Partial<AgentSpec>>
|
||||
/**
|
||||
* Run the agent once (`POST /v1/agents/:ref/run`) and return the RECORDED run.
|
||||
* The quickstart's third step — proving the thing that was just created actually
|
||||
* answers, which is the only step that can prove it. Absent → the step says so and
|
||||
* points at the endpoint instead of pretending. THIS SPENDS: the backend authorizes
|
||||
* the org's balance before any inference, so an unfunded org is refused rather than
|
||||
* given free compute.
|
||||
*/
|
||||
runAgent?: (name: string, input: string) => Promise<AgentRunResult>
|
||||
/**
|
||||
* Create the agent from the pruned body (`toCreateBody(spec)`). This is the ONE
|
||||
* mutation — it MUST target the unified agent backend (`POST /v1/agents`), which
|
||||
@@ -147,6 +165,21 @@ export type AgentBuilderLoaders = {
|
||||
createAgent: (body: AgentCreateBody) => Promise<unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* One recorded run, as the quickstart needs it. Deliberately the small half of the
|
||||
* backend's run view: what happened, which model did it, and what came out. A
|
||||
* `status` other than `ok` is a run that REALLY failed — the backend records the
|
||||
* failure as a run rather than hiding it — so `error` is a fact about the execution,
|
||||
* not a transport problem to guess at.
|
||||
*/
|
||||
export type AgentRunResult = {
|
||||
status: string
|
||||
model?: string
|
||||
output?: string
|
||||
error?: string
|
||||
durationMs?: number
|
||||
}
|
||||
|
||||
/** The reason a create failed, distinguished so the UI reacts correctly. */
|
||||
export type BuilderErrorKind =
|
||||
/** The `/v1/agents` route isn't bound on this deployment yet (404). */
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -20,9 +20,9 @@ import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { RefreshCw, TriangleAlert } from '@hanzogui/lucide-icons-2'
|
||||
import { RecordsView, type FieldDefinition } from '@hanzo/data'
|
||||
|
||||
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
|
||||
import { BaseDataApi, type BaseRecord } from '~/lib/base-data/api'
|
||||
import { baseCollectionToFields } from '~/lib/base-data/fields'
|
||||
import { BackendStateCard, classifyBackend, type BackendState } from '@hanzo/ui/product'
|
||||
|
||||
type LoadState =
|
||||
| { phase: 'loading' }
|
||||
|
||||
@@ -21,11 +21,10 @@ import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { ArrowLeft, Pencil, Save, Trash2, TriangleAlert, X } from '@hanzogui/lucide-icons-2'
|
||||
import { RecordDetail, RecordForm, type FieldDefinition } from '@hanzo/data'
|
||||
|
||||
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
|
||||
import { PrimaryButton } from '~/components/ui/PrimaryButton'
|
||||
import { BaseDataApi, type BaseRecord } from '~/lib/base-data/api'
|
||||
import { baseCollectionToFields } from '~/lib/base-data/fields'
|
||||
import { recordLabel, savePayload } from './records'
|
||||
import { BackendStateCard, PrimaryButton, classifyBackend, type BackendState } from '@hanzo/ui/product'
|
||||
|
||||
export interface RecordDetailViewProps {
|
||||
/** A configured Base client (transport already wired — e.g. the `/superbase` proxy). */
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user