Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
+1
-1
@@ -1,4 +1,4 @@
|
||||
# console2 — Hanzo Cloud Console (Next.js 15 + @hanzo/gui). BSD-3-Clause.
|
||||
# console2 — Hanzo Cloud Console (Next.js 15 + @hanzo/gui). MIT OR Apache-2.0.
|
||||
# NEXT_PUBLIC_* are inlined at build time (browser config), so they are build args.
|
||||
FROM public.ecr.aws/docker/library/node:24-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
@@ -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/*.
|
||||
|
||||
@@ -3588,8 +3588,8 @@ and the rail hid them.
|
||||
rail, `SubNav`, and ⌘K all say the same word. The eight products missing `subpages`
|
||||
now declare them; the icons the strips were carrying moved onto the declarations.
|
||||
- **`components/ui/SubNav.tsx` is the ONE strip**, rendered from `productSubpages` and
|
||||
hidden at `lg+` (`$lg={{ display: 'none' }}`) because the sidebar's `DrillNav` owns
|
||||
level 2 there. One declaration, two mounts — never two navs painting at once. It
|
||||
hidden at `lg+` (`$lg={{ display: 'none' }}`) because the sidebar owns level 2
|
||||
there (then `DrillNav`; now `SubRows` — see "The rail stopped drilling" below). One declaration, two mounts — never two navs painting at once. It
|
||||
takes an optional `href` for a product whose tabs carry URL state (Containers keeps
|
||||
its `?cluster=` selection across tabs). `subpageIcon` moved here and `dashboard.tsx`
|
||||
imports it, so the sub-page icon defaults exist once.
|
||||
@@ -3747,3 +3747,177 @@ configuration, not a property of the deployment. Both now name the organization,
|
||||
does the onboarding step's "Payments aren't set up", which had the same defect. The
|
||||
stale `GET /v1/billing/payment-config` endpoint hints under those cards now read
|
||||
`settings`.
|
||||
|
||||
## The assistant has one home, and the app directory is one you can walk
|
||||
|
||||
Three fixes to the console's own chrome. Each root cause was measured in a browser
|
||||
on computed style, geometry or where the browser lands — never inferred from source.
|
||||
|
||||
**The assistant lived in a third place.** `FloatingChat` owned every shape the
|
||||
assistant can take (the sheet, the docked column, the dock state) except the way
|
||||
in, which was two small buttons in the TOPBAR — a brand-H "Chat with Hanzo" and a
|
||||
"Talk to Hanzo" mic — wedged between the search box and the org/theme/alert
|
||||
cluster. On a 390px phone that put five controls in the header and squeezed the
|
||||
search field to "Search or jump…". Both controls moved into `AssistantFab`, one
|
||||
floating cluster fixed bottom-right, in the corner the assistant actually appears
|
||||
in. Chat and voice are the same surface opened two ways, so they sit together.
|
||||
Nothing about the assistant was rewritten: the FAB calls the same `openChat` /
|
||||
`startVoice` the topbar called, and `open`/`toggle`/`ask` still drive it
|
||||
programmatically (the Code hub's "Ask AI").
|
||||
|
||||
It is suppressed exactly where the assistant is already on screen — while the
|
||||
sheet is open, on the pages that ARE a composer, and, at `lg+` only, while it is
|
||||
docked as a column. That last half is a CSS media prop rather than a JS branch so
|
||||
SSR and first paint agree, and it sits above the Developers dock (whose collapsed
|
||||
bar is 44px and exists only at `lg+`).
|
||||
|
||||
**[BUG, measured] All products was a directory you could not walk.** The pane that
|
||||
lists every Hanzo app — the sidebar's "All products", the one place the whole
|
||||
catalog is browsable — rendered each app as an inert `XStack`: a plain `DIV` with
|
||||
`role=null` and `cursor: auto`, no handler, no pointer affordance. Measured, not
|
||||
read. The only live control in the row was the pin, so a user could curate the
|
||||
sidebar but could not open anything from the list. The row now opens its app
|
||||
through the shared `openProduct` — the ONE opener the sidebar, ⌘K and the category
|
||||
pages already route through — and closes the pane behind it, because a directory is
|
||||
not a destination. Pin stays a separate control on the same row and stops the press
|
||||
from bubbling: curating never navigates, navigating never curates.
|
||||
|
||||
**[BUG] A pin made after sign-in was thrown away on the next reload.** Preferences
|
||||
are read off `properties['hanzo.preferences']` in the IAM access token's claims — a
|
||||
SNAPSHOT taken when that token was minted. An earlier lane fixed the case where the
|
||||
snapshot is SILENT about a key. The other half was never closed: once a user has
|
||||
saved anything, the next token CARRIES a snapshot, and the merge let it win over a
|
||||
newer local write. So the second pin onward read as pinned and was gone after F5.
|
||||
|
||||
The merge is now told the ordering it was missing. `Account` carries the token's
|
||||
own `iat`; the provider stamps `…prefs.<user>.writtenAt` when — and only when — the
|
||||
SERVER acknowledges a write; `mergePrefs(cached, fromAccount, order)` lets the cache
|
||||
win only when a confirmed write is newer than the snapshot. Last writer wins, and
|
||||
both writers are now identifiable. A fresh device (no cache, no stamp) and a fresh
|
||||
sign-in (token minted after the write) both still take the account wholesale, so
|
||||
cross-device is preserved. Stamping only server-confirmed writes is what keeps this
|
||||
from being localStorage impersonating a backend: a save that never landed earns
|
||||
nothing and the account stays authoritative.
|
||||
|
||||
**Backend gap, named rather than papered over.** There is no READ for this
|
||||
document. `PATCH /v1/ai/preferences` (hanzoai/ai `UpdatePreferences`) writes it to
|
||||
the IAM user's `properties['hanzo.preferences']` and returns the merged result;
|
||||
nothing serves a GET, so the token's snapshot is the only read the console has. The
|
||||
smallest seam that removes the ordering problem entirely is `GET
|
||||
/v1/ai/preferences` returning that property after the handler's existing
|
||||
`refreshSessionUser` — the write path already does every part of it. Better still
|
||||
is `GET/PATCH /v1/prefs` (hanzoai/cloud `apps/prefs`), the canonical cross-surface
|
||||
plane, which answers 503 on api.hanzo.ai today.
|
||||
|
||||
**House rule: one filled CTA.** Counted by computed background luminance on the
|
||||
rendered home, not by reading JSX: FIVE white-filled buttons competed — "Take the
|
||||
tour", the getting-started card's active step, and all three `PrimaryActionTile`
|
||||
CTAs (two of them saying "Get API key"). The same measurement now returns ONE: the
|
||||
checklist's ACTIVE step, the thing to do next. The tour is a neutral aside beside its
|
||||
dismiss, and the three tiles are neutral because they are PEERS — a menu of things
|
||||
you can do, not a call to action, and three primaries are none.
|
||||
|
||||
**Verification.** `tsc --noEmit` clean; `vitest` 3175 passed / 8 skipped (256 files,
|
||||
+6 ordering tests). RED→GREEN proven both ways: disabling `cacheIsNewer` turns the
|
||||
two new ordering tests red and the browser test with it. `e2e/assistant-fab-and-apps.spec.ts`
|
||||
(4 tests, 1440 and 390) asserts the control's BOX is in the bottom-right quadrant and
|
||||
≥44px, that `.hz-topbar` carries no assistant control, that clicking an app in All
|
||||
products LANDS on `/agents`, and that a pin survives a reload under a token whose
|
||||
snapshot is an hour old. `e2e/chrome-brand-voice.spec.ts` was retargeted, not
|
||||
deleted — every claim it made still holds, only the location moved.
|
||||
|
||||
Two spec gotchas worth keeping. `_session.ts`'s `b64` emitted plain base64; that is
|
||||
fine while a forged payload is tiny, but `+`/`/` appear as soon as one grows (a
|
||||
`properties` bag is enough) and a strict decoder rejects the token outright — the SDK
|
||||
reports signed out and the app sits on its loader forever. It emits base64URL now,
|
||||
which is what a JWT segment actually is. And the assistant's composer carries its own
|
||||
mic with the same `Talk to Hanzo` label, mounted-but-hidden until the panel opens, so
|
||||
a bare attribute locator matches that one first: scope to `getByTestId('assistant-fab')`.
|
||||
|
||||
|
||||
## The agent quickstart, and the rail that stopped drilling (v8.5.62)
|
||||
|
||||
Two changes that share a shape: something that looked finished was standing in for
|
||||
the thing itself.
|
||||
|
||||
**The builder had no way in.** `AgentBuilder` — the canonical, host-agnostic one —
|
||||
was reachable only as a form in a side pane, from a board you first had to have
|
||||
agents to be looking at. `agents/quickstart` is the way someone with none starts:
|
||||
describe what you want in a sentence, or take a template, then configure, run and
|
||||
integrate.
|
||||
|
||||
- **Every step is an endpoint**, which is the whole design constraint. Describe →
|
||||
`POST /v1/chat/completions` (`draftAgent`) turns a sentence into a spec; Configure →
|
||||
the SAME `AgentBuilder`, seeded; Run → `POST /v1/agents/:ref/run` executes it and
|
||||
shows the RECORDED run; Integrate → prints the request that just worked. A ladder
|
||||
of steps is a promise about what happens, and a step that only draws a checkmark
|
||||
turns the promise into decoration. Steps 1 and 3 are optional by construction —
|
||||
their loaders may be absent, and the step then says exactly what is missing.
|
||||
- **`components/agent-builder/templates.ts`** — eight presets, pure data. A template is
|
||||
a PRESET, never a promise: it may only carry fields `toCreateBody` already expresses
|
||||
(`name`, `description`, `systemPrompt`, and the real `AgentConfig` knobs), and a test
|
||||
pins exactly that. **None names a tool.** Tools are per-org, so a hardcoded
|
||||
`web.search` would name something that may not exist and would fail at the agent's
|
||||
FIRST invocation rather than in the form. What a template CAN say truthfully is
|
||||
`useTools` / `webSearch`, which are real switches in the agent contract.
|
||||
- **The tool plane was live the whole time.** `loaders.ts` said "No live tool catalog
|
||||
endpoint on this deployment yet" and left the field typeable-only. `GET /v1/tools` is
|
||||
bound and serving — one flat set spanning connector actions, functions, zap-service
|
||||
routes, agents, skills and the org's own MCP servers, deduplicated by name, each
|
||||
flagged `activated`. `lib/api/tools.ts` reads it, `proxy-allow` admits the head, and
|
||||
`/v1/tools/call` is REFUSED there: running a tool belongs to whatever runs an agent,
|
||||
never to a browser tab. An org with nothing activated gets `{"tools":[]}` — a real
|
||||
empty answer, shown honestly rather than papered over.
|
||||
- **`defaultModel` was picking an embeddings model.** It named `zen-omni` as its exact
|
||||
match, and the live catalog does not carry that id — so the exact arm never fired and
|
||||
the fallback ran instead: `^zen[-.]` over an alphabetically sorted catalog, which
|
||||
selects `zen-embedding`. Every agent created without touching the model field was
|
||||
pointed at a SKU that cannot hold a conversation, and nothing caught it because the
|
||||
dead exact-match read like the rule. The family test is `^zen\d` now, because zen's
|
||||
naming splits cleanly: **`zen5*` are the text models; `zen-<noun>` names a MODALITY**
|
||||
(embedding, image, video, rerank, voice, vl, guard). The model and tool placeholders
|
||||
were advertising the same dead id and two invented tool names; both now say things
|
||||
that exist.
|
||||
|
||||
**The rail stopped drilling.** Clicking a product used to swap the ENTIRE sidebar for
|
||||
that product's sub-nav, behind a "Back to all products" button. The options were
|
||||
identical either way — what the drill took away was every OTHER product, which is
|
||||
precisely what someone needs when the reason they opened the rail was to go somewhere
|
||||
else. `SubRows` replaces `DrillNav`: a product's sub-pages expand beneath its own row,
|
||||
indented on a hairline, `inert` when collapsed.
|
||||
|
||||
- **The label navigates; the chevron only opens and closes.** One target doing both
|
||||
would make "show me what is in here" and "take me there" the same gesture.
|
||||
- `productIsOpen` / `toggleProduct` in `nav-accordion.ts`, beside the category pair and
|
||||
keyed apart from it. The default is the OPPOSITE of a category's, deliberately:
|
||||
categories are few and describe the catalog, so they open; products are many and each
|
||||
brings four to eight rows, so opening them all would bury the catalog under its own
|
||||
detail. The product you are IN is open unless you closed it, and that choice persists.
|
||||
- **A pinned product appears twice** — once under Pinned, once in its category — and
|
||||
exactly ONE copy may carry the sub-list. Two copies is two navs painting at once,
|
||||
which is the thing this rail exists to avoid, and it doubles the rail's height for no
|
||||
information. The pinned copy owns it.
|
||||
|
||||
**Verification.** `tsc --noEmit` clean; `vitest` **3259 passed / 8 skipped** (262 files;
|
||||
+draft/handle parsing, +templates, +the product accordion, +the tool-plane allow/refuse
|
||||
pair, and a `defaultModel` test that goes red on the `zen-embedding` regression).
|
||||
RENDER-proven: `e2e/agent-quickstart.spec.ts` (3 tests) asserts the ladder, that the
|
||||
gallery sits to the RIGHT of the composer by measured geometry at 1440, that searching
|
||||
narrows it, that picking Deep researcher carries its handle and prompt into step 2, and
|
||||
that 390 stacks without the body scrolling sideways. `e2e/level-2-nav.spec.ts` (5/5) was
|
||||
retargeted, not deleted: it now asserts "All products" is still on screen while a
|
||||
product is open — the invariant the drill could never have satisfied — and that no
|
||||
"Back to all products" button exists on any of the 18 converted products.
|
||||
|
||||
**ONE door.** The board's New-Agent button opened the builder in a side pane —
|
||||
the same component, reached by a different shape, with no templates, no drafting,
|
||||
and nowhere to run what it made. It goes to the quickstart now and
|
||||
`NewAgentForm` is deleted; two entrances to one builder is two things to keep in
|
||||
step, and the pane was the lesser of them. A spec clicks the board's CTA and
|
||||
asserts the URL lands on `/agents/quickstart`.
|
||||
|
||||
One placement note that cost a debug cycle: the quickstart branch must return BEFORE
|
||||
`AgentsModule`'s loading/error/empty states. Building an agent does not depend on
|
||||
reading the ones that exist, and the moments you most need the quickstart — no agents
|
||||
yet, or the registry not answering — are exactly the ones those early returns swallow
|
||||
it in.
|
||||
|
||||
@@ -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')}
|
||||
/>
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
// Side-effect CSS imports (`import './globals.css'`, `import '@hanzogui/core/reset.css'`).
|
||||
// The bundler owns them; TypeScript only needs to know the specifier resolves.
|
||||
// TS7 (tsgo) errors on an unresolvable side-effect import (TS2882) where tsc stayed
|
||||
// silent, so the declaration lives here — one place, every stylesheet.
|
||||
declare module '*.css'
|
||||
+24
-8
@@ -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,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()
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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). */
|
||||
|
||||
@@ -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(() => {})
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* The OAuth return raises EXACTLY ONE toast.
|
||||
*
|
||||
* A unit test cannot see this bug. It is a render loop: the toast provider built
|
||||
* its context value fresh on every render and used it as the value, so every
|
||||
* useToast() consumer got a new identity whenever a toast was added — and the
|
||||
* integrations effect both DEPENDS on the toast api and RAISES a toast. Raising
|
||||
* one re-rendered the provider, which handed the effect a new api, which raised
|
||||
* another. Live this stacked ~15 identical "Connected slack" cards down the
|
||||
* viewport. Stripping the query params could not stop it: router.replace is
|
||||
* asynchronous, so the params are still readable on the renders in between.
|
||||
*
|
||||
* So the assertion is a COUNT after the loop has had time to run, on the real
|
||||
* rendered DOM — the only place the defect exists.
|
||||
*/
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { primeSession } from './_session'
|
||||
|
||||
const PROVIDERS = [
|
||||
{
|
||||
id: 'slack',
|
||||
name: 'Slack',
|
||||
description: 'Post messages and receive events in your Slack workspace.',
|
||||
category: 'Communication',
|
||||
available: true,
|
||||
connected: true,
|
||||
connection: { account: 'The Foundation', connectedAt: '2026-08-05T00:16:49Z' },
|
||||
},
|
||||
]
|
||||
|
||||
test.describe('integrations OAuth return', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Everything else answers empty so the module mounts standalone.
|
||||
await page.route('**/v1/**', async (route) => {
|
||||
const url = route.request().url()
|
||||
if (url.includes('/v1/integrations')) {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(PROVIDERS) })
|
||||
}
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: '{"data":[]}' })
|
||||
})
|
||||
await primeSession(page)
|
||||
})
|
||||
|
||||
test('a connected= return raises exactly one toast', async ({ page }) => {
|
||||
await page.goto('/integrations?connected=slack&account=The+Foundation')
|
||||
|
||||
const toasts = page.getByText('Connected slack')
|
||||
await expect(toasts.first()).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
// Give the loop every chance to run: the effect re-fires on each provider
|
||||
// re-render, and the pre-fix build had stacked well past a dozen by now.
|
||||
await page.waitForTimeout(3_000)
|
||||
expect(await toasts.count()).toBe(1)
|
||||
|
||||
await page.screenshot({ path: 'e2e-shots/integrations-one-toast.png', fullPage: false })
|
||||
})
|
||||
|
||||
test('the callback params are stripped so a reload cannot replay it', async ({ page }) => {
|
||||
await page.goto('/integrations?connected=slack&account=The+Foundation')
|
||||
await expect(page.getByText('Connected slack').first()).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
await expect.poll(() => new URL(page.url()).search, { timeout: 10_000 }).toBe('')
|
||||
})
|
||||
})
|
||||
+24
-13
@@ -2,9 +2,15 @@
|
||||
* e2e: ONE level-2 nav.
|
||||
*
|
||||
* Clicking into a product must reveal ITS options rather than replacing the screen,
|
||||
* and there must be exactly ONE such nav on screen — not the sidebar's drill-down AND
|
||||
* a competing tab strip in the content, which is what `/models` used to do (eight
|
||||
* items in the rail, four in the content, disagreeing on the index's own name).
|
||||
* and there must be exactly ONE such nav on screen — not the sidebar's level 2 AND a
|
||||
* competing tab strip in the content, which is what `/models` used to do (eight items
|
||||
* in the rail, four in the content, disagreeing on the index's own name).
|
||||
*
|
||||
* "Rather than replacing the screen" is now literal on both axes: the product's
|
||||
* sub-pages expand BENEATH its row and the rest of the catalog stays put. The rail
|
||||
* used to swap itself for the product's sub-nav behind a "Back to all products"
|
||||
* button, so these specs assert the other products are still there — that is the
|
||||
* whole point of the change, and the part a future drill would silently undo.
|
||||
*
|
||||
* These are assertions only a browser can make. They read COMPUTED style and
|
||||
* GEOMETRY, not source: a strip hidden by a `$lg` media style prop is still in the
|
||||
@@ -76,8 +82,12 @@ test('desktop: the sidebar owns level 2 — the content strip is not a second na
|
||||
const page = await ctx.newPage()
|
||||
await open(page, '/models')
|
||||
|
||||
// The rail drilled into Models and shows the product's own options.
|
||||
await expect(page.getByRole('button', { name: 'Back to all products' })).toBeVisible()
|
||||
// The rail expanded Models in place — and did NOT swap itself for it.
|
||||
await expect(page.getByRole('button', { name: 'Back to all products' })).toHaveCount(0)
|
||||
// The rest of the catalog is still there — "All products" sits at the FOOT of the
|
||||
// product list, so its presence proves the list was never swapped away. This is the
|
||||
// assertion the drill could not have passed.
|
||||
await expect(page.getByRole('button', { name: 'All products' }).first()).toBeVisible()
|
||||
|
||||
// The index is named what the PRODUCT calls it — Models' index is the Catalog,
|
||||
// not a generic "Overview". This is the registry's `indexLabel`, read by the nav.
|
||||
@@ -166,10 +176,10 @@ test('back returns a level without losing pinned state', async ({ browser }) =>
|
||||
await page.waitForTimeout(900)
|
||||
expect(new URL(page.url()).pathname).toBe('/models')
|
||||
|
||||
// Still drilled into Models with the same options — Back moved the LEVEL, it did
|
||||
// not throw the user out to the product list.
|
||||
await expect(page.getByRole('button', { name: 'Back to all products' })).toBeVisible()
|
||||
// Models is still expanded with the same options — browser Back moved the ROUTE,
|
||||
// and the rail followed it without collapsing what the user was looking at.
|
||||
await expect(visibleTab(page, 'Catalog').first()).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Back to all products' })).toHaveCount(0)
|
||||
|
||||
expect(await page.evaluate(() => localStorage.getItem('hanzo.preferences.cache'))).toBe(pinsBefore)
|
||||
await ctx.close()
|
||||
@@ -177,9 +187,10 @@ test('back returns a level without losing pinned state', async ({ browser }) =>
|
||||
|
||||
/**
|
||||
* Every product that used to carry its own `const TABS` — the whole conversion, in
|
||||
* one sweep. For each: the page renders, the rail drills into it, and the content
|
||||
* strip is present but PAINTS NOTHING at lg+. That is the "no second nav" invariant,
|
||||
* and it is the thing that regresses the moment someone adds a tab bar back.
|
||||
* one sweep. For each: the page renders, the rail expands it in place, and the
|
||||
* content strip is present but PAINTS NOTHING at lg+. That is the "no second nav"
|
||||
* invariant, and it is the thing that regresses the moment someone adds a tab bar
|
||||
* back.
|
||||
*/
|
||||
const CONVERTED = [
|
||||
'models', 'evals', 'ai-accounts', 'containers', 'analytics', 'finetuning', 'team',
|
||||
@@ -200,8 +211,8 @@ test('no product paints a second level-2 nav at lg+', async ({ browser }) => {
|
||||
).toBe('none')
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Back to all products' }),
|
||||
`${id}: the rail drilled in`,
|
||||
).toBeVisible()
|
||||
`${id}: the rail expands in place — it must never swap itself for one product`,
|
||||
).toHaveCount(0)
|
||||
}
|
||||
|
||||
await ctx.close()
|
||||
|
||||
@@ -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,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`) })
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+48
-8
@@ -1,4 +1,4 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs'
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
@@ -7,12 +7,16 @@ import { resolveBuildId, readGitSha } from './src/config/build-id.mjs'
|
||||
/**
|
||||
* Hanzo Cloud Console — Next.js config.
|
||||
*
|
||||
* Hanzo GUI is consumed at runtime (no optimizing compiler): the published
|
||||
* `@hanzogui/next-plugin` has a broken npm dependency (`hanzogui-loader@7.3.0`
|
||||
* is unpublished; the available fork renames its exports), so we transpile the
|
||||
* Gui ESM packages with Next's built-in `transpilePackages` and let
|
||||
* `GuiProvider` inject CSS at runtime. Gui is designed to work this way — the
|
||||
* compiler is an optimization, not a requirement.
|
||||
* Hanzo GUI is consumed at runtime (no optimizing compiler): we transpile the Gui
|
||||
* ESM packages with Next's built-in `transpilePackages` and let `GuiProvider`
|
||||
* inject CSS at runtime. Gui is designed to work this way — the compiler is an
|
||||
* optimization, not a requirement.
|
||||
*
|
||||
* (The original reason to avoid `@hanzogui/next-plugin` no longer holds: the loader
|
||||
* it depends on was unpublished at 7.3.0, but 8.x renamed it to `@hanzogui/loader`
|
||||
* and both now ship. Adopting the compiler is therefore a live option — as an
|
||||
* optimization to measure, not a correctness fix, so it is deliberately not bundled
|
||||
* into the 8.x convergence.)
|
||||
*
|
||||
* `react-native` is aliased to `react-native-web` for the browser.
|
||||
*
|
||||
@@ -46,6 +50,9 @@ function guiPackages() {
|
||||
return ['@hanzo/gui', '@hanzo/iam-js-sdk', '@hanzo/dash', '@hanzo/data', '@hanzo/canvas', '@hanzo/finance-ui', '@hanzo/usage', '@hanzo/ui', 'react-native-web', ...scoped]
|
||||
}
|
||||
|
||||
/** A `@hanzogui/<pkg>/<subpath>/index.{js,cjs}` metro-compat shim (see `webpack()`). */
|
||||
const GUI_SUBPATH_SHIM = /\/@hanzogui\/([^/]+)\/([^/]+)\/index\.c?js$/
|
||||
|
||||
/**
|
||||
* Same-origin `/v1/*` — ZERO client-visible prefix (the CTO contract: "no prefix
|
||||
* before /v1/ in any API call"). The browser ALWAYS calls its OWN origin at a clean
|
||||
@@ -206,7 +213,40 @@ const nextConfig = {
|
||||
experimental: {
|
||||
esmExternals: true,
|
||||
},
|
||||
webpack(config) {
|
||||
webpack(config, { webpack }) {
|
||||
// `@hanzogui/*` 8.x ships legacy metro-compat subpath DIRECTORIES (`config/v5/`,
|
||||
// `themes/v5/`, `shorthands/v5/`, …) beside the `exports` map that already names
|
||||
// the real entry. Each holds a CommonJS `index.js` — inside a `"type": "module"`
|
||||
// package. Whatever resolves the directory therefore parses that file as ESM: the
|
||||
// `export *` chain goes opaque ("'defaultConfig' is not exported from
|
||||
// '@hanzogui/config/v5'") and its bare `require('../dist/cjs/v5.cjs')` survives
|
||||
// into the server chunk, where it MODULE_NOT_FOUNDs at prerender (the require is
|
||||
// relative to `.next/server/chunks/`, not to the package).
|
||||
//
|
||||
// So redirect any such shim to the ESM build sitting beside it. Pattern-based, on
|
||||
// the RESOLVED file, so it holds however the request got there — and costs nothing
|
||||
// the day the shims stop shipping.
|
||||
config.plugins.push(
|
||||
new webpack.NormalModuleReplacementPlugin(GUI_SUBPATH_SHIM, (data) => {
|
||||
const resource = data.createData?.resource
|
||||
if (!resource) return
|
||||
const shim = GUI_SUBPATH_SHIM.exec(resource)
|
||||
if (!shim) return
|
||||
const esm = `${resource.slice(0, shim.index)}/@hanzogui/${shim[1]}/dist/esm/${shim[2]}.mjs`
|
||||
if (!existsSync(esm)) return
|
||||
// The module's CONTEXT must move with it, or its own relative imports
|
||||
// (`./v5-base.mjs`) keep resolving against the shim directory.
|
||||
data.createData.resource = esm
|
||||
data.createData.userRequest = esm
|
||||
data.createData.context = dirname(esm)
|
||||
data.context = dirname(esm)
|
||||
}),
|
||||
)
|
||||
// @hanzo/ui is consumed from SOURCE via a workspace link. Keep the symlinked path
|
||||
// so its own imports (@hanzo/gui, @hanzogui/*) walk up into the CONSOLE's
|
||||
// node_modules — one Tamagui instance, as the tsconfig `paths` already pin for
|
||||
// types. Resolving the realpath would load a second copy and break theme context.
|
||||
config.resolve.symlinks = false
|
||||
config.resolve.alias = {
|
||||
...config.resolve.alias,
|
||||
'react-native$': 'react-native-web',
|
||||
|
||||
Generated
+12019
File diff suppressed because it is too large
Load Diff
+8
-8
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "@hanzo/console",
|
||||
"version": "8.5.35",
|
||||
"version": "8.5.62",
|
||||
"packageManager": "pnpm@11.17.0",
|
||||
"private": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"author": "Hanzo AI <dev@hanzo.ai>",
|
||||
"description": "Hanzo Cloud Console — unified admin console for Hanzo Cloud and all cloud products.",
|
||||
"scripts": {
|
||||
@@ -14,26 +14,27 @@
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"e2e": "playwright test",
|
||||
"e2e:headed": "playwright test --headed",
|
||||
"postinstall": "patch-package"
|
||||
"e2e:headed": "playwright test --headed"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hanzo/brand": "^1.4.5",
|
||||
"@hanzo/canvas": "^0.2.1",
|
||||
"@hanzo/dash": "^0.3.0",
|
||||
"@hanzo/data": "^1.2.2",
|
||||
"@hanzo/design": "^0.4.6",
|
||||
"@hanzo/event": "^0.3.8",
|
||||
"@hanzo/finance-ui": "~0.1.1",
|
||||
"@hanzo/gui": "^8.0.0",
|
||||
"@hanzo/iam": "^0.21.2",
|
||||
"@hanzo/iam": "^0.21.6",
|
||||
"@hanzo/logo": "^1.0.14",
|
||||
"@hanzo/ui": "^8.0.38",
|
||||
"@hanzo/ui": "^8.0.56",
|
||||
"@hanzo/usage": "^0.1.6",
|
||||
"@hanzogui/config": "^8.0.0",
|
||||
"@hanzogui/core": "^8.0.0",
|
||||
"@hanzogui/lucide-icons-2": "^8.0.0",
|
||||
"@hanzogui/next-theme": "^8.0.0",
|
||||
"@hanzogui/shell": "^8.0.1",
|
||||
"@hanzogui/telemetry": "^8.0.0",
|
||||
"@hanzogui/shell": "^8.1.1",
|
||||
"@lexical/html": "0.46.0",
|
||||
"@lexical/link": "0.46.0",
|
||||
"@lexical/list": "0.46.0",
|
||||
@@ -62,7 +63,6 @@
|
||||
"@types/node": "22.20.0",
|
||||
"@types/react": "19.2.17",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"patch-package": "^8.0.0",
|
||||
"react-native": "0.83.9",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "3.2.4"
|
||||
|
||||
Generated
+527
-269
File diff suppressed because it is too large
Load Diff
@@ -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')
|
||||
@@ -75,7 +75,7 @@ export function AccountMenu() {
|
||||
// Your people, beside your own settings — the other half of "who am I".
|
||||
// Choosing a DIFFERENT tenant is a different question and lives in the
|
||||
// top-left context switcher, so this menu never re-scopes the console.
|
||||
{ label: 'Team', href: '/team' },
|
||||
{ label: 'Members', href: '/team' },
|
||||
{ label: 'Documentation', href: config.docsUrl, external: true, separatorBefore: true },
|
||||
]}
|
||||
brand={{ name: config.brandName }}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* All products — the directory where you curate your sidebar. Every Hanzo product
|
||||
* is always available on demand; this panel lists the FULL catalog the viewer may
|
||||
* see (brand-scoped, admin surfaces gated), grouped by category, each row with a
|
||||
* PIN toggle that promotes/removes it from the sidebar's Pinned quick-access section
|
||||
* (via `usePins`). Rendered in the shared DetailPane (opened from the sidebar's
|
||||
* "All products" row).
|
||||
* All products — the directory of every Hanzo app, where you OPEN one and where you
|
||||
* curate your sidebar. Every product is always available on demand; this panel lists
|
||||
* the FULL catalog the viewer may see (brand-scoped, admin surfaces gated), grouped
|
||||
* by category. A row OPENS its app; the pin toggle beside it promotes/removes it from
|
||||
* the sidebar's Pinned quick-access section (via `usePins`). Rendered in the shared
|
||||
* DetailPane (opened from the sidebar's "All products" row).
|
||||
*
|
||||
* Honest by construction: pinning is instant + optimistic (persisted through the
|
||||
* account preferences store — no async error state). Products with REAL org usage
|
||||
@@ -15,16 +15,19 @@
|
||||
* the badges/filter degrade away — never a fabricated "in use".
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button, Input, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Activity, Plus, Search, Star } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { visibleCatalogByCategory, type CatalogEntry, type ProductIcon } from '~/lib/products/registry'
|
||||
import { useAppsBeta } from '~/lib/products/beta'
|
||||
import { useIsSuperAdmin } from '~/lib/auth/admin'
|
||||
import { usePins, useProductColors } from '~/lib/products/pins'
|
||||
import { openProduct } from '~/lib/products/open'
|
||||
import { useDetailPane } from '~/components/DetailPane'
|
||||
import { fetchUsageRecords } from '~/lib/api/aimetrics'
|
||||
import { inUseProductIds } from '~/lib/products/product-usage'
|
||||
import { asColor } from '~/components/ui/color'
|
||||
import { EmptyState } from '~/components/ui/EmptyState'
|
||||
import { EmptyState, asColor } from '@hanzo/ui/product'
|
||||
|
||||
/** The list narrowing controls at the top. */
|
||||
type Filter = 'all' | 'inuse' | 'pinned'
|
||||
@@ -41,23 +44,51 @@ function InUseBadge() {
|
||||
)
|
||||
}
|
||||
|
||||
/** One catalog row: icon · label/description (+ In-use badge) · pin toggle. */
|
||||
/**
|
||||
* One catalog row: icon · label/description (+ In-use badge) · pin toggle.
|
||||
*
|
||||
* The row itself OPENS the product. It used to be an inert `XStack` — a plain div
|
||||
* with `cursor: auto`, no role and no handler — so the one place in the console that
|
||||
* lists every app was a directory you could not walk: the only live control in the
|
||||
* row was the pin. Opening is delegated to the shared `openProduct`, the ONE opener
|
||||
* every other surface (sidebar, ⌘K, category page) already routes through.
|
||||
*
|
||||
* Pin stays a SEPARATE control on the same row, so curating never navigates and
|
||||
* navigating never curates. It stops the press from bubbling into the row for the
|
||||
* same reason.
|
||||
*/
|
||||
function ProductRow({
|
||||
entry,
|
||||
color,
|
||||
pinned,
|
||||
inUse,
|
||||
onOpen,
|
||||
onToggle,
|
||||
}: {
|
||||
entry: CatalogEntry
|
||||
color: string
|
||||
pinned: boolean
|
||||
inUse: boolean
|
||||
onOpen: () => void
|
||||
onToggle: () => void
|
||||
}) {
|
||||
const Icon = entry.icon
|
||||
return (
|
||||
<XStack items="center" gap="$3" py="$2" px="$2" rounded="$3" minH={44} hoverStyle={{ bg: '$color2' }}>
|
||||
<XStack
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onPress={onOpen}
|
||||
cursor="pointer"
|
||||
items="center"
|
||||
gap="$3"
|
||||
py="$2"
|
||||
px="$2"
|
||||
rounded="$3"
|
||||
minH={44}
|
||||
hoverStyle={{ bg: '$color2' }}
|
||||
focusStyle={{ bg: '$color2' }}
|
||||
aria-label={`Open ${entry.label}`}
|
||||
>
|
||||
<YStack width={32} height={32} rounded="$3" bg="$color3" items="center" justify="center">
|
||||
<Icon size={16} color={asColor(color)} />
|
||||
</YStack>
|
||||
@@ -73,7 +104,10 @@ function ProductRow({
|
||||
<Button
|
||||
size="$2"
|
||||
icon={pinned ? <Star size={15} /> : <Plus size={15} />}
|
||||
onPress={onToggle}
|
||||
onPress={(e?: { stopPropagation?: () => void }) => {
|
||||
e?.stopPropagation?.()
|
||||
onToggle()
|
||||
}}
|
||||
bg={pinned ? '$color5' : 'transparent'}
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
@@ -121,6 +155,15 @@ export function AddProductPanel() {
|
||||
const showAdmin = useIsSuperAdmin()
|
||||
const { isPinned, toggle } = usePins()
|
||||
const { colorOf } = useProductColors()
|
||||
const router = useRouter()
|
||||
const detail = useDetailPane()
|
||||
// Opening an app takes you to it: navigate, then close the pane you launched from
|
||||
// (it is a directory, not a destination — leaving it open would cover the page it
|
||||
// just opened).
|
||||
const openEntry = (entry: CatalogEntry) => {
|
||||
openProduct(entry, (path) => router.push(path))
|
||||
detail.close()
|
||||
}
|
||||
const [query, setQuery] = useState('')
|
||||
const [filter, setFilter] = useState<Filter>('all')
|
||||
// Real org usage signal. `null` = not (yet) known; a Set (even empty) = a real
|
||||
@@ -147,7 +190,8 @@ export function AddProductPanel() {
|
||||
|
||||
// Source = the FULL catalog the viewer may see (ungated → both pinned and unpinned
|
||||
// appear), grouped by category.
|
||||
const groups = useMemo(() => visibleCatalogByCategory(showAdmin, null), [showAdmin])
|
||||
const showBeta = useAppsBeta(showAdmin)
|
||||
const groups = useMemo(() => visibleCatalogByCategory(showAdmin, null, showBeta), [showAdmin, showBeta])
|
||||
|
||||
// Literal, case-insensitive substring match over label/description/id — NOT a
|
||||
// compiled RegExp of user input.
|
||||
@@ -236,6 +280,7 @@ export function AddProductPanel() {
|
||||
color={colorOf(entry.id)}
|
||||
pinned={isPinned(entry.id)}
|
||||
inUse={inUse?.has(entry.id) ?? false}
|
||||
onOpen={() => openEntry(entry)}
|
||||
onToggle={() => toggle(entry.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1,40 +1,38 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Bridges the console session + App-Router navigation into the shared analytics
|
||||
* client (`@hanzo/event`). Rendered once, inside both `SessionProvider` and
|
||||
* `AnalyticsProvider` (see `Provider.tsx`), it renders nothing.
|
||||
* The console's telemetry surface — the ONE Hanzo telemetry provider, plus the one
|
||||
* thing it deliberately leaves to the app.
|
||||
*
|
||||
* - `usePageview` emits a pageview on every path change (the provider fires the
|
||||
* FIRST pageview itself, so this only covers subsequent client navigations).
|
||||
* - `identify` binds the user to their Hanzo IAM user id (`account.userId`, the
|
||||
* OIDC `sub`) once the session resolves, AND carries the attributes that make
|
||||
* that id legible. Anonymous placeholder sessions are skipped.
|
||||
* `TelemetrySurface` mounts `@hanzogui/telemetry`, which owns the whole plane for
|
||||
* this subtree: pageviews (including SPA route changes), `window.onerror` +
|
||||
* `unhandledrejection`, React render errors (its internal boundary REPORTS and
|
||||
* re-throws, so the app's own error UI still decides what the user sees), lazily
|
||||
* imported interaction capture, and DNT/GPC consent. It replaces the hand-rolled
|
||||
* `<AnalyticsProvider>` + bridge + boundary combo; it mounts @hanzo/event's
|
||||
* `AnalyticsProvider` internally with its own client, so every existing
|
||||
* `useAnalytics()` call site keeps working against that ONE client and one stream.
|
||||
* `product="console"` is all the configuration there is — @hanzo/event's DSN
|
||||
* registry resolves the hanzo-console Sentry project from it, so the error plane
|
||||
* needs no `dsn` prop and no env var.
|
||||
*
|
||||
* WHY NOT `owner/name`. This used to identify by the `${owner}/${name}` actor ref.
|
||||
* That is an org-relative REFERENCE, not a user id: it is a different id space
|
||||
* from the one hanzo.ai and hanzo.chat identify by (the IAM `sub`), so the same
|
||||
* user counted twice the moment they used two Hanzo surfaces — and every
|
||||
* cross-property funnel, retention curve and path silently measured nothing. It
|
||||
* also moves when an org or a login handle is renamed, which rewrites history.
|
||||
* It reads `usePathname()` ITSELF rather than taking a `path` prop from `Provider`:
|
||||
* `Provider` memoizes its tree on `children`, so a path read up there would be
|
||||
* baked into the cached element and go stale on the first client navigation.
|
||||
*
|
||||
* WHY TRAITS. A bare `identify(id)` writes a user id and nothing else, so the
|
||||
* warehouse held a population of opaque subjects: every funnel could count users
|
||||
* but no one could say WHICH user, and answering "who hit this error" meant a
|
||||
* manual IAM lookup per row. Email and name are FIRST-PARTY facts about our own
|
||||
* users — they arrive in the same IAM claims this file already decodes to get the
|
||||
* id, and were simply dropped on the floor. Sent as traits they are exactly as
|
||||
* sensitive as they were in the token, and the id stays the join key.
|
||||
*
|
||||
* WHAT IS STILL NOT SENT: the org. The tenant is stamped SERVER-SIDE from the
|
||||
* validated bearer, so org-level cohorts are already queryable — and a tenant the
|
||||
* client can name is a tenant the client can get wrong. Traits describe the user,
|
||||
* never the scope they are trusted with.
|
||||
* `AnalyticsBridge` is the one thing TelemetryProvider does NOT do — `identify`.
|
||||
* It binds the person to the STABLE `owner/name` actor id (the same id the API
|
||||
* client uses via `setCurrentActor`), never the email, once the session resolves.
|
||||
* The org tenant is stamped server-side from the session, so we send the user id
|
||||
* only, and anonymous placeholder sessions are skipped. It renders nothing and
|
||||
* emits NO pageview — the provider owns those, and a second emitter would
|
||||
* double-count every route.
|
||||
*/
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useEffect, useRef, type ReactNode } from 'react'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useAnalytics, usePageview } from '@hanzo/event/react'
|
||||
import { TelemetryProvider, useTelemetry } from '@hanzogui/telemetry'
|
||||
|
||||
import { iamAccessToken } from '~/lib/auth/iam'
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
import { type Account } from '~/lib/api/types'
|
||||
|
||||
@@ -49,21 +47,30 @@ export function identityTraits(account: Account): Record<string, unknown> {
|
||||
return traits
|
||||
}
|
||||
|
||||
export function TelemetrySurface({ children }: { children: ReactNode }) {
|
||||
const path = usePathname()
|
||||
// @hanzo/iam (PKCE) is the console's ONE credential and it is a BEARER — the same
|
||||
// token `lib/api/client.ts` puts on every call — so telemetry authenticates the
|
||||
// same way rather than relying on a cookie the ingest host would never receive.
|
||||
return (
|
||||
<TelemetryProvider product="console" path={path} getToken={iamAccessToken}>
|
||||
{children}
|
||||
</TelemetryProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export function AnalyticsBridge() {
|
||||
const analytics = useAnalytics()
|
||||
const telemetry = useTelemetry()
|
||||
const { account } = useSession()
|
||||
usePageview(usePathname())
|
||||
|
||||
const identified = useRef('')
|
||||
useEffect(() => {
|
||||
if (!account || account.type === 'anonymous-user') return
|
||||
// No IAM subject means no IAM user — leave the visitor anonymous rather than
|
||||
// inventing an id for them. Identity flows from the token's own claims.
|
||||
const userId = account.userId
|
||||
if (!userId || identified.current === userId) return
|
||||
identified.current = userId
|
||||
analytics.identify(userId, identityTraits(account))
|
||||
}, [account, analytics])
|
||||
if (!account?.owner || !account?.name || account.type === 'anonymous-user') return
|
||||
const personId = `${account.owner}/${account.name}`
|
||||
if (identified.current === personId) return
|
||||
identified.current = personId
|
||||
telemetry.identify(personId)
|
||||
}, [account, telemetry])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -73,17 +73,17 @@ import {
|
||||
|
||||
import { AiApi, IamAdminApi, type Organization } from '~/lib/api'
|
||||
import { findEntry, type CatalogEntry } from '~/lib/products/registry'
|
||||
import { commandBarSystemPrompt, hanzoAssistantSystemPrompt } from '~/lib/assistant'
|
||||
import { assistantState, commandBarSystemPrompt, hanzoAssistantSystemPrompt } from '~/lib/assistant'
|
||||
import { searchDestinations, type Destination } from '~/lib/products/search'
|
||||
import { DEFAULT_GROUP_LABEL, pinnedFirst } from '~/lib/products/pins-core'
|
||||
import { usePins, useProductColors } from '~/lib/products/pins'
|
||||
import { asColor } from '~/components/ui/color'
|
||||
import { useAppsBeta } from '~/lib/products/beta'
|
||||
import { ProductIcon } from '~/components/ui/ProductIcon'
|
||||
import { openProduct } from '~/lib/products/open'
|
||||
import { currentOrg, switchOrg } from '~/lib/org-scope'
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
import { useIsSuperAdmin } from '~/lib/auth/admin'
|
||||
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
|
||||
import { BackendStateCard, asColor, type BackendState } from '@hanzo/ui/product'
|
||||
|
||||
const titleCase = (s: string) => (s ? s[0].toUpperCase() + s.slice(1) : s)
|
||||
|
||||
@@ -365,6 +365,7 @@ function PaletteDialog({
|
||||
const router = useRouter()
|
||||
const { signOut } = useSession()
|
||||
const showAdmin = useIsSuperAdmin()
|
||||
const showBeta = useAppsBeta(showAdmin)
|
||||
const { colorOf } = useProductColors()
|
||||
const pins = usePins()
|
||||
const { current, resolvedTheme, set: setTheme } = useThemeSetting()
|
||||
@@ -440,7 +441,7 @@ function PaletteDialog({
|
||||
// a search, so the ranked branch is left strictly alone.
|
||||
const destResults = useMemo(() => {
|
||||
if (mode !== 'catalog') return []
|
||||
const found = searchDestinations(query, showAdmin, null)
|
||||
const found = searchDestinations(query, showAdmin, null, showBeta)
|
||||
if (sub) return found.slice(0, 50)
|
||||
return pinnedFirst(found, (d) => (d.kind === 'product' ? d.entry.id : ''), pins.pinnedIds)
|
||||
}, [mode, query, sub, showAdmin, pins.pinnedIds])
|
||||
@@ -542,7 +543,7 @@ function PaletteDialog({
|
||||
setRun({ status: 'text', text: ans })
|
||||
}
|
||||
} catch (e) {
|
||||
setRun({ status: 'error', state: classifyBackend(e) })
|
||||
setRun({ status: 'error', state: assistantState(e) })
|
||||
}
|
||||
}, [mode, sub, showAdmin])
|
||||
|
||||
|
||||
@@ -36,9 +36,9 @@ import { IamAdminApi, type Organization } from '~/lib/api'
|
||||
import { ORG_PAGE_SIZE, orgQuery } from '~/lib/org-list'
|
||||
import { currentOrg, leaveOrg, switchOrg } from '~/lib/org-scope'
|
||||
import { contextLabel, scopedOrgRow, titleCase } from '~/lib/account/org-state'
|
||||
import { FieldText } from '~/components/ui/Field'
|
||||
import { MenuRow } from '~/components/ui/MenuRow'
|
||||
import { paper } from '~/components/ui/paper'
|
||||
import { SearchInput } from '@hanzo/ui/product'
|
||||
|
||||
export function ContextSwitcher() {
|
||||
const router = useRouter()
|
||||
@@ -105,9 +105,31 @@ export function ContextSwitcher() {
|
||||
iconAfter={<ChevronsUpDown size={13} opacity={0.6} />}
|
||||
aria-label={`Organization and project — ${contextLabel(orgLabel, scope.project)}`}
|
||||
>
|
||||
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1} flex={1}>
|
||||
{contextLabel(orgLabel, scope.project)}
|
||||
</Text>
|
||||
{org.logo ? (
|
||||
// The org's own logo IS the label — the uploaded mark takes the
|
||||
// slot the name held, height-capped to the row so any aspect fits.
|
||||
// A scoped project keeps its text beside it; the full text stays
|
||||
// in the aria-label either way. Arbitrary tenant URL/data URL, so
|
||||
// a raw <img> (next/image would need a per-tenant remote
|
||||
// allow-list) — same call BrandLogo makes.
|
||||
<XStack items="center" gap="$2" flex={1} minW={0}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={org.logo}
|
||||
alt={orgLabel}
|
||||
style={{ height: 22, width: 'auto', maxWidth: 140, objectFit: 'contain', display: 'block' }}
|
||||
/>
|
||||
{scope.project ? (
|
||||
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1} flex={1}>
|
||||
/ {scope.project}
|
||||
</Text>
|
||||
) : null}
|
||||
</XStack>
|
||||
) : (
|
||||
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1} flex={1}>
|
||||
{contextLabel(orgLabel, scope.project)}
|
||||
</Text>
|
||||
)}
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
|
||||
@@ -122,12 +144,11 @@ export function ContextSwitcher() {
|
||||
it, not scrolling. A regular user has one org and no field. */}
|
||||
{isSuperAdmin ? (
|
||||
<YStack px="$1" pb="$1">
|
||||
<FieldText
|
||||
value={query}
|
||||
onChange={search}
|
||||
placeholder="Find an organization"
|
||||
ariaLabel="Find an organization"
|
||||
/>
|
||||
{/* A search landmark names the control for assistive tech — the shared
|
||||
SearchInput has no accessible-name prop of its own. */}
|
||||
<div role="search" aria-label="Find an organization">
|
||||
<SearchInput value={query} onChange={search} placeholder="Find an organization" name="org" />
|
||||
</div>
|
||||
</YStack>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -28,8 +28,8 @@ import { Button, ScrollView, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { X } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { SlideOver } from '~/components/ui/SlideOver'
|
||||
import { asColor, type IconLike } from '~/components/ui/color'
|
||||
import { Z } from '~/lib/z'
|
||||
import { asColor, type IconLike } from '@hanzo/ui/product'
|
||||
|
||||
export type DetailDescriptor = {
|
||||
/** Pane title (the item's name). */
|
||||
|
||||
+120
-59
@@ -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)
|
||||
|
||||
|
||||
@@ -8,15 +8,13 @@ import { useMemo, type ReactNode } from 'react'
|
||||
import { GuiProvider } from '@hanzo/gui'
|
||||
import { NextThemeProvider, useRootTheme } from '@hanzogui/next-theme'
|
||||
import { registerDefaultFields, registerField } from '@hanzo/data'
|
||||
import { AnalyticsProvider } from '@hanzo/event/react'
|
||||
import { IamProvider } from '@hanzo/iam/react'
|
||||
|
||||
import config from '../../gui.config'
|
||||
import { SessionProvider } from '~/lib/auth/session'
|
||||
import { iamConfig } from '~/lib/auth/iam'
|
||||
import { EntitlementsProvider } from '~/lib/entitlements-context'
|
||||
import { eventClient } from '~/lib/event'
|
||||
import { AnalyticsBridge } from './Analytics'
|
||||
import { AnalyticsBridge, TelemetrySurface } from './Analytics'
|
||||
import { OrgAccentProvider } from './OrgAccentProvider'
|
||||
import { RichTextDisplay, RichTextInput } from './fields/RichTextField'
|
||||
|
||||
@@ -64,16 +62,15 @@ export function Provider({ children }: { children: ReactNode }) {
|
||||
{/* Entitlements live inside the session (they read the signed-in account +
|
||||
active org scope) so the sidebar/palette gate from ONE fetch. */}
|
||||
<EntitlementsProvider>
|
||||
{/* Analytics lives INSIDE the session so `identify` binds the signed-in
|
||||
actor. The ONE shared `eventClient` (same-origin /v1/event; the tenant is
|
||||
stamped server-side, so the client never sends an org) is also referenced
|
||||
by the error boundaries, so every signal rides one stream. The provider
|
||||
fires the first pageview (autoPageview); `AnalyticsBridge` wires the
|
||||
per-navigation pageviews + identity. */}
|
||||
<AnalyticsProvider client={eventClient}>
|
||||
{/* Telemetry lives INSIDE the session so `identify` binds the signed-in
|
||||
actor. `TelemetrySurface` is the ONE provider — pageviews, errors,
|
||||
interaction capture, consent — and it mounts @hanzo/event internally,
|
||||
so `useAnalytics()` call sites share its client. `AnalyticsBridge`
|
||||
adds only identity; the provider owns pageviews. */}
|
||||
<TelemetrySurface>
|
||||
<AnalyticsBridge />
|
||||
{children}
|
||||
</AnalyticsProvider>
|
||||
</TelemetrySurface>
|
||||
</EntitlementsProvider>
|
||||
</SessionProvider>
|
||||
</IamProvider>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -23,9 +23,9 @@ import { Check, ChevronsUpDown, Layers, Plus, Trash } from '@hanzogui/lucide-ico
|
||||
import { useScope } from '~/lib/scope-context'
|
||||
import { STOCK_ENVIRONMENTS } from '~/lib/scope'
|
||||
import { isStockNetwork, parseCustomNetwork, type Network } from '~/lib/network'
|
||||
import { FieldText } from '~/components/ui/Field'
|
||||
import { MenuRow, type DotColor } from '~/components/ui/MenuRow'
|
||||
import { paper } from '~/components/ui/paper'
|
||||
import { FieldText } from '@hanzo/ui/product'
|
||||
|
||||
/** A small dot keyed to the network tier. Monochrome by default; only the genuine
|
||||
* states carry a hue — mainnet is live (green), testnet is a caution (amber). Every
|
||||
|
||||
@@ -26,13 +26,13 @@
|
||||
import { useCallback, useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { OrgMark } from '@hanzo/ui/product'
|
||||
import { BookOpen, Globe, Info, SlidersHorizontal } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { config } from '~/config'
|
||||
import { getBrand } from '~/lib/branding/brands'
|
||||
import { useOrgIdentity } from '~/components/ui/BrandLogo'
|
||||
import { Z } from '~/lib/z'
|
||||
import { OrgMark } from '@hanzo/ui/product'
|
||||
|
||||
type MenuItem = {
|
||||
icon: typeof SlidersHorizontal
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -16,9 +16,6 @@ import { useCallback, useMemo, useState, type CSSProperties } from 'react'
|
||||
import { Button, Card, Input, Label, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { GripVertical, Plus, Trash2, TriangleAlert, ArrowUp, ArrowDown } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { PrimaryButton } from '~/components/ui/PrimaryButton'
|
||||
import { FieldSwitch } from '~/components/ui/Field'
|
||||
import { classifyBackend } from '~/components/ui/BackendState'
|
||||
import type { FrameworkClient } from '~/lib/framework/client'
|
||||
import type { DocType, Fieldtype } from '~/lib/framework/types'
|
||||
import {
|
||||
@@ -32,6 +29,7 @@ import {
|
||||
type BuilderField,
|
||||
} from './builder-logic'
|
||||
import { CHEVRON } from '~/components/ui/Field'
|
||||
import { FieldSwitch, PrimaryButton, classifyBackend } from '@hanzo/ui/product'
|
||||
|
||||
// A value≠label native <select>, themed with the app CSS vars (same idiom as
|
||||
// ui/Field.tsx FieldSelect, but here options carry a distinct value + label).
|
||||
|
||||
@@ -16,15 +16,12 @@ import { useCallback, useEffect, useState } from 'react'
|
||||
import { Card, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Boxes, Plus, TriangleAlert } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { PrimaryButton } from '~/components/ui/PrimaryButton'
|
||||
import { EmptyState } from '~/components/ui/EmptyState'
|
||||
import { Loader } from '~/components/ui/Loader'
|
||||
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
|
||||
import type { FrameworkClient } from '~/lib/framework/client'
|
||||
import type { DocType } from '~/lib/framework/types'
|
||||
import { moduleDoctypes } from '~/lib/framework/fields'
|
||||
import { CollectionBuilder } from './CollectionBuilder'
|
||||
import { BackendStateCard, EmptyState, PageHeader, PrimaryButton, classifyBackend, type BackendState } from '@hanzo/ui/product'
|
||||
|
||||
export interface CollectionsBrowserProps {
|
||||
client: FrameworkClient
|
||||
|
||||
@@ -19,12 +19,11 @@ import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { ArrowLeft, Ban, Globe, Pencil, PenOff, Save, Send, Trash2, TriangleAlert, X } from '@hanzogui/lucide-icons-2'
|
||||
import { RecordDetail, RecordForm, type FieldDefinition, type SelectOption } from '@hanzo/data'
|
||||
|
||||
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
|
||||
import { PrimaryButton } from '~/components/ui/PrimaryButton'
|
||||
import type { FrameworkClient } from '~/lib/framework/client'
|
||||
import type { DocType, FrameworkDoc } from '~/lib/framework/types'
|
||||
import { docTypeToFields, toRecord, enrichLinks, savePayload, newDraft, statusField, titleOf, hasProjectField, PROJECT_FIELD } from '~/lib/framework/fields'
|
||||
import { loadLinkOptions, makeFieldOptions } from './data'
|
||||
import { BackendStateCard, PrimaryButton, classifyBackend, type BackendState } from '@hanzo/ui/product'
|
||||
|
||||
export interface DocTypeDetailProps {
|
||||
client: FrameworkClient
|
||||
|
||||
@@ -19,12 +19,12 @@ import { Button } from '@hanzo/gui'
|
||||
import { RefreshCw } from '@hanzogui/lucide-icons-2'
|
||||
import { RecordsView, type FieldDefinition, type SelectOption } from '@hanzo/data'
|
||||
|
||||
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
|
||||
import type { FrameworkClient } from '~/lib/framework/client'
|
||||
import type { DocType, FrameworkDoc } from '~/lib/framework/types'
|
||||
import { docTypeToFields, toRecord, enrichLinks, savePayload, isMediaDoctype, hasProjectField, PROJECT_FIELD } from '~/lib/framework/fields'
|
||||
import { loadLinkOptions, makeFieldOptions } from './data'
|
||||
import { MediaGrid } from './MediaGrid'
|
||||
import { BackendStateCard, classifyBackend, type BackendState } from '@hanzo/ui/product'
|
||||
|
||||
type LoadState =
|
||||
| { phase: 'loading' }
|
||||
|
||||
@@ -17,13 +17,11 @@ import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Image as ImageIcon, Trash2, TriangleAlert, Upload } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { PrimaryButton } from '~/components/ui/PrimaryButton'
|
||||
import { EmptyState } from '~/components/ui/EmptyState'
|
||||
import { classifyBackend } from '~/components/ui/BackendState'
|
||||
import type { FrameworkClient } from '~/lib/framework/client'
|
||||
import type { DocType, FrameworkDoc } from '~/lib/framework/types'
|
||||
import { mediaFileField, titleOf } from '~/lib/framework/fields'
|
||||
import { uploadMedia, resolveMediaUrl, deleteMediaObject } from './media-upload'
|
||||
import { EmptyState, PrimaryButton, classifyBackend } from '@hanzo/ui/product'
|
||||
|
||||
export interface MediaGridProps {
|
||||
client: FrameworkClient
|
||||
|
||||
@@ -43,7 +43,6 @@ import {
|
||||
Zap,
|
||||
} from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { FadeIn } from '~/components/ui/FadeIn'
|
||||
import { GuidedTour } from '~/components/tour/GuidedTour'
|
||||
import { useGuideSignals } from '~/lib/guide/use-signals'
|
||||
import { stepAnchorId } from '~/lib/guide/signals'
|
||||
@@ -58,6 +57,7 @@ import {
|
||||
type ProductGuide,
|
||||
} from '~/lib/guide/spec'
|
||||
import type { ProductIcon } from '~/lib/products/registry'
|
||||
import { FadeIn } from '@hanzo/ui/product'
|
||||
|
||||
/** Pitch-point icon name → Lucide glyph (kept out of the pure data layer). */
|
||||
const PITCH_ICON: Record<PitchIcon, ProductIcon> = {
|
||||
@@ -129,8 +129,8 @@ export function PitchHero({ guide }: { guide: ProductGuide }) {
|
||||
<Text fontSize="$1" color="$color10" fontWeight="500">
|
||||
Get started
|
||||
</Text>
|
||||
{/* `hz-display` — the ONE way this app sets display leading (globals.css,
|
||||
same as PublicLanding). It was `style={{ lineHeight: 1.12 }}`, which is a
|
||||
{/* `hz-display` — the ONE way this app sets display leading (globals.css).
|
||||
It was `style={{ lineHeight: 1.12 }}`, which is a
|
||||
correct ratio in plain React but NOT under @hanzo/gui: react-native-web
|
||||
appends `px` to any numeric style value absent from its unitless list, and
|
||||
`lineHeight` is absent — so it compiled to `line-height: 1.12px`, a 1px box
|
||||
@@ -143,8 +143,12 @@ export function PitchHero({ guide }: { guide: ProductGuide }) {
|
||||
</Text>
|
||||
</YStack>
|
||||
<XStack gap="$2" items="center">
|
||||
{/* Neutral, not filled: the ONE filled action in this card is the
|
||||
checklist's ACTIVE step below — the thing to do next. A white "Take
|
||||
the tour" beside it made two buttons compete for the same emphasis,
|
||||
and the tour is the aside. */}
|
||||
{tourSteps.length ? (
|
||||
<Button size="$2" theme="light" icon={<Compass size={14} />} onPress={() => setTourOpen(true)}>
|
||||
<Button size="$2" icon={<Compass size={14} />} onPress={() => setTourOpen(true)}>
|
||||
Take the tour
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
@@ -22,7 +22,6 @@ import { config } from '~/config'
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
import { usePreferences } from '~/lib/products/preferences'
|
||||
import { BrandMark } from '~/components/ui/BrandLogo'
|
||||
import { FadeIn } from '~/components/ui/FadeIn'
|
||||
import {
|
||||
ONBOARDING_STEPS,
|
||||
LAST_INDEX,
|
||||
@@ -42,6 +41,7 @@ import { TeamStep } from '~/components/onboarding/steps/TeamStep'
|
||||
import { CreditsStep } from '~/components/onboarding/steps/CreditsStep'
|
||||
import { AiAccessStep } from '~/components/onboarding/steps/AiAccessStep'
|
||||
import { LaunchStep } from '~/components/onboarding/steps/LaunchStep'
|
||||
import { FadeIn } from '@hanzo/ui/product'
|
||||
|
||||
const STEP_COMPONENTS: Record<StepId, (p: StepProps) => ReactNode> = {
|
||||
secure: SecureStep,
|
||||
|
||||
@@ -10,30 +10,50 @@ import type { ReactNode } from 'react'
|
||||
import { Button, Card, Spinner, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { ArrowLeft, ArrowRight, Check } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { PrimaryButton } from '~/components/ui/PrimaryButton'
|
||||
import { ONBOARDING_STEPS, type StepId, type StepStatus } from '~/lib/onboarding/steps'
|
||||
import { PrimaryButton } from '@hanzo/ui/product'
|
||||
|
||||
/** Title + subtitle header + body for one step. */
|
||||
/**
|
||||
* The content area's reserved height. Every step's body occupies at least this
|
||||
* much, so `actions` lands at the SAME y on every step and Continue never moves
|
||||
* under the pointer between steps — the whole reason actions is a slot on the
|
||||
* shell rather than the last child a step happens to render.
|
||||
*
|
||||
* A taller step still grows (the area is flex), so this reserves space without
|
||||
* capping it.
|
||||
*/
|
||||
const CONTENT_MIN_HEIGHT = 320
|
||||
|
||||
export function StepShell({
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
actions,
|
||||
}: {
|
||||
title: string
|
||||
subtitle: string
|
||||
children: ReactNode
|
||||
/**
|
||||
* The step's StepActions. It is a SLOT, not a child, so the shell decides
|
||||
* where it sits — one placement for every step, decided in one place.
|
||||
*/
|
||||
actions?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<YStack gap="$4" flex={1} minW={0}>
|
||||
<YStack gap="$1.5">
|
||||
<Text fontSize="$8" fontWeight="800" color="$color12">
|
||||
<Text testID="onboarding-step-title" fontSize="$8" fontWeight="800" color="$color12">
|
||||
{title}
|
||||
</Text>
|
||||
<Text fontSize="$4" color="$color11">
|
||||
{subtitle}
|
||||
</Text>
|
||||
</YStack>
|
||||
{children}
|
||||
<YStack gap="$4" flex={1} minH={CONTENT_MIN_HEIGHT}>
|
||||
{children}
|
||||
</YStack>
|
||||
{actions}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -61,7 +81,7 @@ export function StepActions({
|
||||
busy?: boolean
|
||||
}) {
|
||||
return (
|
||||
<XStack gap="$3" items="center" justify="space-between" flexWrap="wrap" pt="$2">
|
||||
<XStack testID="onboarding-actions" gap="$3" items="center" justify="space-between" flexWrap="wrap" pt="$2">
|
||||
<XStack>
|
||||
{onBack ? (
|
||||
<Button size="$3" chromeless disabled={busy} icon={<ArrowLeft size={16} />} onPress={onBack}>
|
||||
|
||||
@@ -24,12 +24,11 @@ import { Wand2, KeyRound, LogIn, Check, Plus } from '@hanzogui/lucide-icons-2'
|
||||
import { AiAccountsApi } from '~/lib/api/ai-accounts'
|
||||
import { AiConnectionsApi, AI_CONNECTION_PROVIDERS, type AiConnection, type AiConnectionProvider } from '~/lib/api/ai-connections'
|
||||
import { ApiError } from '~/lib/api/client'
|
||||
import { FieldSelect, FieldText } from '~/components/ui/Field'
|
||||
import { PrimaryButton } from '~/components/ui/PrimaryButton'
|
||||
import { useToast } from '~/components/ui/Toast'
|
||||
import { ChoiceCard, StepShell, StepActions } from '~/components/onboarding/parts'
|
||||
import { withAiChoice, type AiChoice } from '~/lib/onboarding/steps'
|
||||
import type { StepProps } from '~/components/onboarding/types'
|
||||
import { FieldSelect, FieldText, PrimaryButton } from '@hanzo/ui/product'
|
||||
|
||||
const providerLabel = (id: AiConnectionProvider): string => AI_CONNECTION_PROVIDERS.find((p) => p.id === id)?.label ?? id
|
||||
const providerFromLabel = (label: string): AiConnectionProvider =>
|
||||
@@ -121,7 +120,19 @@ export function AiAccessStep({ state, patch, next, skip, back, isFirst }: StepPr
|
||||
const connectedLabels = connections.map((c) => c.provider).join(', ')
|
||||
|
||||
return (
|
||||
<StepShell title="AI access" subtitle="Choose how you want to power AI. You can change or combine these anytime in AI Accounts.">
|
||||
<StepShell
|
||||
title="AI access"
|
||||
subtitle="Choose how you want to power AI. You can change or combine these anytime in AI Accounts."
|
||||
actions={
|
||||
<StepActions
|
||||
onBack={isFirst ? undefined : back}
|
||||
onSkip={skip}
|
||||
skipLabel="Decide later"
|
||||
onContinue={next}
|
||||
continueLabel="Continue"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ChoiceCard
|
||||
icon={<Wand2 size={20} />}
|
||||
title="Let Hanzo power it"
|
||||
@@ -247,14 +258,6 @@ export function AiAccessStep({ state, patch, next, skip, back, isFirst }: StepPr
|
||||
{err}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<StepActions
|
||||
onBack={isFirst ? undefined : back}
|
||||
onSkip={skip}
|
||||
skipLabel="Decide later"
|
||||
onContinue={next}
|
||||
continueLabel="Continue"
|
||||
/>
|
||||
</StepShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,9 +12,9 @@ import { FileText, Sparkles } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { config } from '~/config'
|
||||
import { getBrand } from '~/lib/branding/brands'
|
||||
import { FieldSwitch } from '~/components/ui/Field'
|
||||
import { StepShell, StepActions, InlineLink } from '~/components/onboarding/parts'
|
||||
import type { StepProps } from '~/components/onboarding/types'
|
||||
import { FieldSwitch } from '@hanzo/ui/product'
|
||||
|
||||
export function ConsentStep({ state, patch, next, back, isFirst }: StepProps) {
|
||||
const brand = getBrand()
|
||||
@@ -33,7 +33,17 @@ export function ConsentStep({ state, patch, next, back, isFirst }: StepProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<StepShell title="Data & consent" subtitle={`A couple of choices about how ${config.brandName} handles your data.`}>
|
||||
<StepShell
|
||||
title="Data & consent"
|
||||
subtitle={`A couple of choices about how ${config.brandName} handles your data.`}
|
||||
actions={
|
||||
// No Skip here on purpose: accepting the Terms is not optional, so an
|
||||
// affordance that skips past them would be dishonest. Continue stays
|
||||
// disabled until the box is ticked. The data-sharing choice beside it IS
|
||||
// optional and defaults to off — leaving it alone is the skip.
|
||||
<StepActions onBack={isFirst ? undefined : back} onContinue={commit} continueDisabled={!agreed} />
|
||||
}
|
||||
>
|
||||
<Card p="$4" gap="$3" borderWidth={1} borderColor="$borderColor">
|
||||
<XStack gap="$3" items="flex-start" justify="space-between">
|
||||
<XStack gap="$3" items="center" flex={1} minW={0}>
|
||||
@@ -80,7 +90,6 @@ export function ConsentStep({ state, patch, next, back, isFirst }: StepProps) {
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<StepActions onBack={isFirst ? undefined : back} onContinue={commit} continueDisabled={!agreed} />
|
||||
</StepShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,10 +19,10 @@ import type { CloudBalance } from '~/lib/api/wallet'
|
||||
import { useSquareCard } from '~/lib/billing/use-square-card'
|
||||
import { trialCents, spendableCents, balanceSplitLabel, invalidateBalance } from '~/lib/billing/live-balance'
|
||||
import { ApiError } from '~/lib/api/client'
|
||||
import { PrimaryButton } from '~/components/ui/PrimaryButton'
|
||||
import { useToast } from '~/components/ui/Toast'
|
||||
import { StepShell, StepActions } from '~/components/onboarding/parts'
|
||||
import type { StepProps } from '~/components/onboarding/types'
|
||||
import { PrimaryButton } from '@hanzo/ui/product'
|
||||
|
||||
const usd = (c: number): string => `$${(c / 100).toFixed(2)}`
|
||||
|
||||
@@ -91,7 +91,21 @@ export function CreditsStep({ next, skip, back, isFirst }: StepProps) {
|
||||
const split = balanceSplitLabel(balance)
|
||||
|
||||
return (
|
||||
<StepShell title="Free trial credits" subtitle="Add a card to unlock free trial credits. No charge now — it just keeps your account ready when the trial ends.">
|
||||
<StepShell
|
||||
title="Free trial credits"
|
||||
subtitle="Add a card to unlock free trial credits. No charge now — it just keeps your account ready when the trial ends."
|
||||
actions={
|
||||
<StepActions
|
||||
onBack={isFirst ? undefined : back}
|
||||
onSkip={phase === 'ready' && !isUnlocked ? skip : undefined}
|
||||
skipLabel="Skip for now"
|
||||
onContinue={next}
|
||||
continueLabel="Continue"
|
||||
continueDisabled={phase === 'ready' && !isUnlocked}
|
||||
busy={adding}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{phase === 'loading' ? (
|
||||
<Card p="$5" items="center" borderWidth={1} borderColor="$borderColor">
|
||||
<Spinner size="large" color="$color11" />
|
||||
@@ -195,16 +209,6 @@ export function CreditsStep({ next, skip, back, isFirst }: StepProps) {
|
||||
</XStack>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<StepActions
|
||||
onBack={isFirst ? undefined : back}
|
||||
onSkip={phase === 'ready' && !isUnlocked ? skip : undefined}
|
||||
skipLabel="Skip for now"
|
||||
onContinue={next}
|
||||
continueLabel="Continue"
|
||||
continueDisabled={phase === 'ready' && !isUnlocked}
|
||||
busy={adding}
|
||||
/>
|
||||
</StepShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@ import { EVENTS } from '@hanzo/event'
|
||||
|
||||
import { config } from '~/config'
|
||||
import { StepShell, ChoiceCard } from '~/components/onboarding/parts'
|
||||
import { PrimaryButton } from '~/components/ui/PrimaryButton'
|
||||
import type { StepProps } from '~/components/onboarding/types'
|
||||
import { PrimaryButton } from '@hanzo/ui/product'
|
||||
|
||||
const TILES: { icon: typeof MessageSquare; title: string; description: string; to: string }[] = [
|
||||
{ icon: MessageSquare, title: 'Start a chat', description: 'Talk to the latest Zen model right now.', to: '/chat' },
|
||||
@@ -30,11 +30,11 @@ export function LaunchStep({ finish, back, isFirst }: StepProps) {
|
||||
<XStack gap="$2" items="center">
|
||||
<PartyPopper size={20} color="var(--green11)" />
|
||||
<Text fontSize="$5" fontWeight="700" color="$green11">
|
||||
Your workspace is set up
|
||||
Your organization is set up
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$3" color="$color11">
|
||||
Two-factor, consent, workspace, credits, and AI access are all configured.
|
||||
Two-factor, consent, organization, credits, and AI access are all configured.
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -13,9 +13,9 @@ import { ShieldCheck, KeyRound, Copy } from '@hanzogui/lucide-icons-2'
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
import { ApiError } from '~/lib/api/client'
|
||||
import { MfaApi, type MfaSetup } from '~/lib/api/mfa'
|
||||
import { PrimaryButton } from '~/components/ui/PrimaryButton'
|
||||
import { StepShell, StepActions } from '~/components/onboarding/parts'
|
||||
import type { StepProps } from '~/components/onboarding/types'
|
||||
import { PrimaryButton } from '@hanzo/ui/product'
|
||||
|
||||
function copy(value: string) {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard) void navigator.clipboard.writeText(value).catch(() => {})
|
||||
@@ -62,7 +62,21 @@ export function SecureStep({ next, skip, back, isFirst }: StepProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<StepShell title="Secure your account" subtitle="Add two-factor authentication so a stolen password isn't enough to sign in.">
|
||||
<StepShell
|
||||
title="Secure your account"
|
||||
subtitle="Add two-factor authentication so a stolen password isn't enough to sign in."
|
||||
actions={
|
||||
<StepActions
|
||||
onBack={isFirst ? undefined : back}
|
||||
onSkip={enabled ? undefined : skip}
|
||||
skipLabel="Skip securing my account"
|
||||
onContinue={next}
|
||||
continueLabel="Continue"
|
||||
continueDisabled={!enabled}
|
||||
busy={busy}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{enabled ? (
|
||||
<Card p="$4" gap="$3" borderWidth={1} borderColor="$green7" bg="$green2">
|
||||
<XStack gap="$2" items="center">
|
||||
@@ -161,16 +175,6 @@ export function SecureStep({ next, skip, back, isFirst }: StepProps) {
|
||||
</XStack>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<StepActions
|
||||
onBack={isFirst ? undefined : back}
|
||||
onSkip={enabled ? undefined : skip}
|
||||
skipLabel="Skip securing my account"
|
||||
onContinue={next}
|
||||
continueLabel="Continue"
|
||||
continueDisabled={!enabled}
|
||||
busy={busy}
|
||||
/>
|
||||
</StepShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Step 3 — Your workspace. Confirms the org the user is in (created at first-run
|
||||
* Step 3 — Your organization. Confirms the org the user is in (created at first-run
|
||||
* org onboarding) and lets them optionally NAME it. REAL: reads/writes the org via
|
||||
* the org-admin `TeamApi` (`get-organization` / `update-organization`, pinned to the
|
||||
* caller's own org server-side). Renaming is best-effort — a read/write failure never
|
||||
* blocks the flow (the workspace already exists).
|
||||
* blocks the flow (the organization already exists).
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Card, Input, Spinner, Text, XStack, YStack } from '@hanzo/gui'
|
||||
@@ -20,7 +20,7 @@ import { useToast } from '~/components/ui/Toast'
|
||||
import { StepShell, StepActions } from '~/components/onboarding/parts'
|
||||
import type { StepProps } from '~/components/onboarding/types'
|
||||
|
||||
export function TeamStep({ next, back, isFirst }: StepProps) {
|
||||
export function TeamStep({ next, skip, back, isFirst }: StepProps) {
|
||||
const { account } = useSession()
|
||||
const toast = useToast()
|
||||
const org = account?.owner || currentOrg()
|
||||
@@ -52,9 +52,9 @@ export function TeamStep({ next, back, isFirst }: StepProps) {
|
||||
setBusy(true)
|
||||
try {
|
||||
await TeamApi.updateOrganization({ ...record, displayName: name })
|
||||
toast.success('Workspace renamed', name)
|
||||
toast.success('Organization renamed', name)
|
||||
} catch (e) {
|
||||
toast.error('Could not rename the workspace', e instanceof ApiError ? e.message : undefined)
|
||||
toast.error('Could not rename the organization', e instanceof ApiError ? e.message : undefined)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
@@ -63,7 +63,20 @@ export function TeamStep({ next, back, isFirst }: StepProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<StepShell title="Your workspace" subtitle="This is where your projects, usage, and billing live. Name it now, or keep the default.">
|
||||
<StepShell
|
||||
title="Your organization"
|
||||
subtitle="This is where your projects, usage, and billing live. Name it now, or keep the default."
|
||||
actions={
|
||||
<StepActions
|
||||
onBack={isFirst ? undefined : back}
|
||||
onSkip={skip}
|
||||
skipLabel="Keep the default"
|
||||
onContinue={() => void commit()}
|
||||
continueLabel="Continue"
|
||||
busy={busy}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Card p="$4" gap="$3" borderWidth={1} borderColor="$borderColor">
|
||||
<XStack gap="$3" items="center">
|
||||
<YStack width={44} height={44} rounded="$4" items="center" justify="center" bg="$color3">
|
||||
@@ -82,7 +95,7 @@ export function TeamStep({ next, back, isFirst }: StepProps) {
|
||||
{record ? (
|
||||
<YStack gap="$1.5">
|
||||
<Text fontSize="$2" color="$color11" fontWeight="600">
|
||||
Workspace name
|
||||
Organization name
|
||||
</Text>
|
||||
<Input value={displayName} onChangeText={setDisplayName} placeholder="Acme Inc" autoCapitalize="words" />
|
||||
</YStack>
|
||||
@@ -92,16 +105,9 @@ export function TeamStep({ next, back, isFirst }: StepProps) {
|
||||
<XStack gap="$2" items="center">
|
||||
<Users size={16} color="var(--color10)" />
|
||||
<Text fontSize="$2" color="$color10">
|
||||
Invite teammates and switch workspaces anytime from the top bar.
|
||||
Invite teammates and switch organizations anytime from the top bar.
|
||||
</Text>
|
||||
</XStack>
|
||||
|
||||
<StepActions
|
||||
onBack={isFirst ? undefined : back}
|
||||
onContinue={() => void commit()}
|
||||
continueLabel="Continue"
|
||||
busy={busy}
|
||||
/>
|
||||
</StepShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,9 +16,8 @@ import { useCallback, useState } from 'react'
|
||||
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Accessibility, ExternalLink } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { DataTable, type Column } from '~/components/ui/DataTable'
|
||||
import { toIssues, summarize, IMPACTS, type A11yIssue, type A11ySummary, type Impact } from '~/lib/a11y/scan'
|
||||
import { DataTable, PageHeader, type Column } from '@hanzo/ui/product'
|
||||
|
||||
type State =
|
||||
| { phase: 'idle' }
|
||||
|
||||
@@ -11,9 +11,7 @@ import { useRouter } from 'next/navigation'
|
||||
|
||||
import type { CatalogEntry, ProductSubpage } from '~/lib/products/registry'
|
||||
import { config } from '~/config'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { EmptyState } from '~/components/ui/EmptyState'
|
||||
import { FadeIn } from '~/components/ui/FadeIn'
|
||||
import { EmptyState, FadeIn, PageHeader } from '@hanzo/ui/product'
|
||||
|
||||
export function AdminManagedNotice({
|
||||
entry,
|
||||
|
||||
@@ -23,10 +23,8 @@ import {
|
||||
} from '~/lib/api/admin'
|
||||
import { config } from '~/config'
|
||||
import { currentOrg } from '~/lib/org-scope'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { DataTable, type Column } from '~/components/ui/DataTable'
|
||||
import { FieldRow, FieldText } from '~/components/ui/Field'
|
||||
import { ErrorState, asApiError, isForbidden, SuperAdminRequired, type HonestCopy } from '~/components/ui/States'
|
||||
import { DataTable, FieldRow, FieldText, PageHeader, SecretInput, type Column } from '@hanzo/ui/product'
|
||||
|
||||
/** IAM-specific guidance for the honest 404 / unauthorized states. */
|
||||
const IAM_COPY: HonestCopy = {
|
||||
@@ -221,7 +219,7 @@ function UsersAdminView({ owner }: { owner: string }) {
|
||||
<Text fontSize="$4" fontWeight="700">New user in {owner}</Text>
|
||||
<FieldRow label="Name"><FieldText value={name} onChange={setName} placeholder="jdoe" disabled={saving} /></FieldRow>
|
||||
<FieldRow label="Email"><FieldText value={email} onChange={setEmail} placeholder="jdoe@hanzo.ai" disabled={saving} /></FieldRow>
|
||||
<FieldRow label="Password"><FieldText value={password} onChange={setPassword} placeholder="initial password" secure disabled={saving} /></FieldRow>
|
||||
<FieldRow label="Password"><SecretInput value={password} onChange={setPassword} placeholder="initial password" disabled={saving} copy={false} id="initial-password" /></FieldRow>
|
||||
<XStack gap="$2" items="center">
|
||||
<Button self="flex-start" icon={<Plus size={15} />} disabled={saving} onPress={() => void create()}>
|
||||
{saving ? 'Creating…' : 'Create user'}
|
||||
|
||||
@@ -20,11 +20,11 @@ import { Activity, AlertTriangle, Boxes, Building2, Coins, Cpu, Gauge, ScrollTex
|
||||
|
||||
import { AdminO11yApi, type FleetO11y, type O11yRange } from '~/lib/api/admin-o11y'
|
||||
import { MetricCard } from '~/components/ui/Metric'
|
||||
import { DataTable, type Column } from '~/components/ui/DataTable'
|
||||
import { LineChart, type ChartPoint } from '~/components/ui/Charts'
|
||||
import { asApiError, ErrorState, SuperAdminRequired } from '~/components/ui/States'
|
||||
import { useIsSuperAdmin } from '~/lib/auth/admin'
|
||||
import { formatMetric } from '~/components/products/overview/living/logic'
|
||||
import { DataTable, type Column } from '@hanzo/ui/product'
|
||||
|
||||
const RANGES: O11yRange[] = ['24h', '7d', '30d']
|
||||
const pct1 = (v: number): string => (Number.isFinite(v) ? `${v.toFixed(1)}%` : '—')
|
||||
|
||||
@@ -21,14 +21,8 @@ import { Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Target, Plus, RefreshCw } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { PLATFORMS, STATUSES, AdsApi, type Campaign, type Summary } from '~/lib/api/ads'
|
||||
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
|
||||
import { DataTable, type Column } from '~/components/ui/DataTable'
|
||||
import { EmptyState } from '~/components/ui/EmptyState'
|
||||
import { FieldRow, FieldText, FieldSelect } from '~/components/ui/Field'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { PrimaryButton } from '~/components/ui/PrimaryButton'
|
||||
import { StatusTag } from '~/components/ui/StatusTag'
|
||||
import { SlideOver } from '~/components/ui/SlideOver'
|
||||
import { BackendStateCard, DataTable, EmptyState, FieldRow, FieldSelect, FieldText, PageHeader, PrimaryButton, StatusTag, classifyBackend, type BackendState, type Column } from '@hanzo/ui/product'
|
||||
|
||||
/** cents (int64 minor units from the backend) → a $ label. */
|
||||
function money(cents: number): string {
|
||||
|
||||
@@ -15,15 +15,12 @@ import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { BadgeCheck, Ban, Coins, HandCoins, Handshake, Percent, RefreshCw, Users, Wallet, Zap } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { AdminAffiliatesApi, type AdminAffiliate, type AdminAffiliatesView, type SweepResult } from '~/lib/api/admin-affiliates'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { MetricCard } from '~/components/ui/Metric'
|
||||
import { EmptyState } from '~/components/ui/EmptyState'
|
||||
import { PrimaryButton } from '~/components/ui/PrimaryButton'
|
||||
import { FieldRow, FieldSelect, FieldText } from '~/components/ui/Field'
|
||||
import { asApiError, ErrorState, isForbidden, SuperAdminRequired } from '~/components/ui/States'
|
||||
import { ApiError } from '~/lib/api'
|
||||
import { dollarsToCents, percentToBps, ratePct, shortDate, statusLabel, statusColor, usd } from './affiliates/logic'
|
||||
import { toneColor } from '~/components/ui/tone'
|
||||
import { EmptyState, FieldRow, FieldSelect, FieldText, PageHeader, PrimaryButton } from '@hanzo/ui/product'
|
||||
|
||||
/** The backend caps the DIRECT (L1) rate at 9300 bps so the L1+L2+L3 schedule ≤ 100% of margin. */
|
||||
const MAX_RATE_BPS = 9300
|
||||
|
||||
@@ -16,14 +16,11 @@ import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { BadgeCheck, Check, Coins, Copy, HandCoins, Handshake, RefreshCw, Users, Wallet } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { AffiliatesApi, type AffiliateOverview } from '~/lib/api/affiliates'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { MetricCard } from '~/components/ui/Metric'
|
||||
import { PrimaryButton } from '~/components/ui/PrimaryButton'
|
||||
import { FieldRow, FieldText } from '~/components/ui/Field'
|
||||
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
|
||||
import { payoutMethodLabel, ratePct, shortDate, statusLabel, statusColor, usd } from './affiliates/logic'
|
||||
import { EarningsPanel, LinksPanel, LeaderboardPanel } from './affiliates/panels'
|
||||
import { toneColor } from '~/components/ui/tone'
|
||||
import { BackendStateCard, FieldRow, FieldText, PageHeader, PrimaryButton, classifyBackend, type BackendState } from '@hanzo/ui/product'
|
||||
|
||||
type Async<T> =
|
||||
| { phase: 'loading' }
|
||||
@@ -203,7 +200,7 @@ function AffiliateDashboard({
|
||||
</Text>
|
||||
) : (
|
||||
<Text fontSize="$3" color="$color11">
|
||||
Share your link. Every team that signs up through it earns you {ratePct(data.rateBps)} of their spend, every
|
||||
Share your link. Every organization that signs up through it earns you {ratePct(data.rateBps)} of their spend, every
|
||||
period.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -14,12 +14,13 @@
|
||||
* facade (`AgentsApi.metrics`); until that route is bound they show a truthful "not
|
||||
* connected" note, never a placeholder trend. When the org has ZERO agents (or the
|
||||
* `/v1/agents` route isn't bound yet) the board is replaced by a polished
|
||||
* "create your first agent" empty state with the real New-Agent flow — never the
|
||||
* mockup's sample data.
|
||||
* "create your first agent" empty state that opens the QUICKSTART — the one way to
|
||||
* create an agent here — never the mockup's sample data.
|
||||
*
|
||||
* Style props use the @hanzo/gui v5 shorthand set (bg/p/px/py/gap/rounded/items/…).
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button, Card, Input, Spinner, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { useAnalytics } from '@hanzo/event/react'
|
||||
import { EVENTS } from '@hanzo/event'
|
||||
@@ -64,10 +65,6 @@ import {
|
||||
type MetricsRange,
|
||||
} from '~/lib/api/agents'
|
||||
import { productColorHex } from '~/lib/products/colors'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { DataTable, type Column } from '~/components/ui/DataTable'
|
||||
import { EmptyState } from '~/components/ui/EmptyState'
|
||||
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
|
||||
import { LineChart, Sparkline, type ChartPoint } from '~/components/ui/Charts'
|
||||
import { useDetailPane } from '~/components/DetailPane'
|
||||
import { MetricCard, Delta } from './functions/parts'
|
||||
@@ -81,7 +78,10 @@ import {
|
||||
TopAgents,
|
||||
VersionBadge,
|
||||
} from './agents/parts'
|
||||
import { AgentDetailView, NewAgentForm } from './agents/forms'
|
||||
import { AgentDetailView } from './agents/forms'
|
||||
import { agentBuilderLoaders } from './agents/loaders'
|
||||
import { AgentQuickstart } from '~/components/agent-builder'
|
||||
import { BackendStateCard, DataTable, EmptyState, PageHeader, classifyBackend, type BackendState, type Column } from '@hanzo/ui/product'
|
||||
|
||||
const PAGE_SIZE = 8
|
||||
const DASH = '—'
|
||||
@@ -137,6 +137,7 @@ function useAgents() {
|
||||
}
|
||||
|
||||
export function AgentsModule(props: { params: Record<string, string> }) {
|
||||
const router = useRouter()
|
||||
const detail = useDetailPane()
|
||||
const { agents, loading, error, live, activity, reload, setAgents } = useAgents()
|
||||
|
||||
@@ -229,26 +230,11 @@ export function AgentsModule(props: { params: Record<string, string> }) {
|
||||
|
||||
const analytics = useAnalytics()
|
||||
|
||||
const openNew = useCallback(
|
||||
() =>
|
||||
detail.open({
|
||||
title: 'New agent',
|
||||
subtitle: 'Define a model, prompt, and tools',
|
||||
icon: Bot,
|
||||
iconColor: agentColor,
|
||||
content: (
|
||||
<NewAgentForm
|
||||
onCancel={detail.close}
|
||||
onCreated={() => {
|
||||
analytics.capture(EVENTS.AGENT_CREATED)
|
||||
detail.close()
|
||||
void reload()
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
[detail, agentColor, reload, analytics],
|
||||
)
|
||||
// ONE way to create an agent, and it is the quickstart. This used to open the
|
||||
// builder in a side pane — the same component, reached by a different shape, with
|
||||
// no templates, no drafting and nowhere to run what it made. Two entrances to one
|
||||
// builder is two things to keep in step; the pane was the lesser of them.
|
||||
const openNew = useCallback(() => router.push('/agents/quickstart'), [router])
|
||||
|
||||
const header = (
|
||||
<PageHeader
|
||||
@@ -282,6 +268,36 @@ export function AgentsModule(props: { params: Record<string, string> }) {
|
||||
/>
|
||||
)
|
||||
|
||||
// Owned sub-pages: Status/Logs/Metrics render focused slices of the agents' OWN
|
||||
// runs (from /v1/agents), so they are never the empty generic o11y/ledger subpage.
|
||||
// Overview ('') shows everything. Metrics = counts + invocation trend + resource;
|
||||
// Status = health donut + agents table; Logs = the invocation activity feed.
|
||||
const routeTab = props.params?.tab ?? ''
|
||||
|
||||
// ── Quickstart ──────────────────────────────────────────────────────────────
|
||||
// Its own surface, and it answers BEFORE the list's loading/error/empty states on
|
||||
// purpose: building an agent does not depend on reading the ones that exist, and
|
||||
// the moments you most need it — no agents yet, or the registry not answering —
|
||||
// are exactly the ones those early returns would have swallowed it in.
|
||||
if (routeTab === 'quickstart') {
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Build an agent"
|
||||
subtitle="Describe what you want, or start from a template. Four steps, and every one is a real call."
|
||||
/>
|
||||
<AgentQuickstart
|
||||
loaders={agentBuilderLoaders}
|
||||
apiBase={config.apiUrl}
|
||||
onFinished={() => {
|
||||
analytics.capture(EVENTS.AGENT_CREATED)
|
||||
void reload()
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Initial loading ─────────────────────────────────────────────────────────
|
||||
if (loading && agents.length === 0 && !error) {
|
||||
return (
|
||||
@@ -400,11 +416,6 @@ export function AgentsModule(props: { params: Record<string, string> }) {
|
||||
const tabs: StatusTab[] = ['all', ...AGENT_STATUSES]
|
||||
const tabCount = (t: StatusTab): number => (t === 'all' ? agents.length : health[t])
|
||||
|
||||
// Owned sub-pages: Status/Logs/Metrics render focused slices of the agents' OWN
|
||||
// runs (from /v1/agents), so they are never the empty generic o11y/ledger subpage.
|
||||
// Overview ('') shows everything. Metrics = counts + invocation trend + resource;
|
||||
// Status = health donut + agents table; Logs = the invocation activity feed.
|
||||
const routeTab = props.params?.tab ?? ''
|
||||
const showMetrics = routeTab === '' || routeTab === 'metrics'
|
||||
const showStatus = routeTab === '' || routeTab === 'status'
|
||||
const showLogs = routeTab === '' || routeTab === 'logs'
|
||||
|
||||
@@ -18,10 +18,8 @@ import { Button, Text } from '@hanzo/gui'
|
||||
import { RefreshCw } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { restGet, cloudProxyV1Url } from '~/lib/api/client'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { DataTable, type Column } from '~/components/ui/DataTable'
|
||||
import { StatusTag } from '~/components/ui/StatusTag'
|
||||
import { interpretPlatformError, PlatformStateCard, type PlatformError } from './platform/state'
|
||||
import { DataTable, PageHeader, StatusTag, type Column } from '@hanzo/ui/product'
|
||||
|
||||
type Alert = {
|
||||
id: string
|
||||
|
||||
@@ -35,12 +35,10 @@ import {
|
||||
type Top,
|
||||
} from '~/lib/api/analytics'
|
||||
import { fmtUsd, fmtInt } from '~/lib/api/functions'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { DataTable, type Column } from '~/components/ui/DataTable'
|
||||
import { LineChart, Donut, type ChartPoint, type Slice } from '~/components/ui/Charts'
|
||||
import { RAMP } from '~/lib/theme/ramp'
|
||||
import { classifyBackend, BackendStateCard, type BackendState } from '~/components/ui/BackendState'
|
||||
import { MetricCard } from './functions/parts'
|
||||
import { BackendStateCard, DataTable, PageHeader, classifyBackend, type BackendState, type Column } from '@hanzo/ui/product'
|
||||
|
||||
const fmtPct = (n: number): string => (Number.isFinite(n) ? `${(n * 100).toFixed(1)}%` : '—')
|
||||
|
||||
|
||||
@@ -21,11 +21,10 @@ import {
|
||||
type AnnotationQueueItem,
|
||||
type O11yPageMeta,
|
||||
} from '~/lib/api'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { DataTable, type Column } from '~/components/ui/DataTable'
|
||||
import { RuntimeNotice } from './observability/RuntimeNotice'
|
||||
import { Pager } from './observability/Pager'
|
||||
import { fmtDate } from './observability/format'
|
||||
import { DataTable, PageHeader, type Column } from '@hanzo/ui/product'
|
||||
|
||||
const PAGE_LIMIT = 50
|
||||
|
||||
|
||||
@@ -22,8 +22,8 @@ import { EVENTS } from '@hanzo/event'
|
||||
import { ApiError, KeysApi, type KeyStatus } from '~/lib/api'
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
import { config } from '~/config'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { ErrorState } from '~/components/ui/States'
|
||||
import { CopyButton, PageHeader, SecretInput } from '@hanzo/ui/product'
|
||||
|
||||
// The docs site serves everything under the `/docs` base path (a bare
|
||||
// `docs.hanzo.ai/<slug>` 404s), so the API reference is `/docs/api` — white-labeled
|
||||
@@ -37,25 +37,6 @@ function fmtKeyDate(iso?: string): string {
|
||||
return Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
/** Copy-to-clipboard button with a transient confirmed state. */
|
||||
function CopyButton({ value, label = 'Copy' }: { value: string; label?: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard?.writeText(value)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
} catch {
|
||||
/* clipboard blocked (insecure context) — the value is already visible */
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Button size="$2" icon={copied ? <Check size={14} /> : <Copy size={14} />} onPress={() => void copy()}>
|
||||
{copied ? 'Copied' : label}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
/** The full secret, shown ONCE right after creation. */
|
||||
function NewKeyCard({ accessKey, onDone }: { accessKey: string; onDone: () => void }) {
|
||||
return (
|
||||
@@ -66,19 +47,7 @@ function NewKeyCard({ accessKey, onDone }: { accessKey: string; onDone: () => vo
|
||||
Your new API key
|
||||
</Text>
|
||||
</XStack>
|
||||
<XStack gap="$2" items="center" flexWrap="wrap">
|
||||
<Text
|
||||
fontSize="$3"
|
||||
color="$color12"
|
||||
flex={1}
|
||||
minW={260}
|
||||
selectable
|
||||
style={{ fontFamily: 'monospace' }}
|
||||
>
|
||||
{accessKey}
|
||||
</Text>
|
||||
<CopyButton value={accessKey} />
|
||||
</XStack>
|
||||
<SecretInput value={accessKey} id="api-key" />
|
||||
<XStack gap="$2" items="center">
|
||||
<TriangleAlert size={14} color="$yellow10" />
|
||||
<Text fontSize="$2" color="$color11">
|
||||
|
||||
@@ -35,12 +35,7 @@ import { AppWindow, Cpu, ExternalLink, Globe, HardDrive, Pencil, RefreshCw, Rock
|
||||
import { config } from '~/config'
|
||||
import { AppsApi, builderEditUrl, type App, type AppDeployment } from '~/lib/api/apps'
|
||||
import { PlatformSitesApi } from '~/lib/api/platform-sites'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { DataTable, type Column } from '~/components/ui/DataTable'
|
||||
import { StatusTag } from '~/components/ui/StatusTag'
|
||||
import { EmptyState } from '~/components/ui/EmptyState'
|
||||
import { PrimaryButton } from '~/components/ui/PrimaryButton'
|
||||
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
|
||||
import { BackendStateCard, DataTable, EmptyState, PageHeader, PrimaryButton, StatusTag, classifyBackend, type BackendState, type Column } from '@hanzo/ui/product'
|
||||
|
||||
type Async<T> =
|
||||
| { phase: 'loading' }
|
||||
|
||||
@@ -15,10 +15,8 @@ import { Button, Text } from '@hanzo/gui'
|
||||
import { RefreshCw } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { restGet } from '~/lib/api/client'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { DataTable, type Column } from '~/components/ui/DataTable'
|
||||
import { StatusTag } from '~/components/ui/StatusTag'
|
||||
import { interpretPlatformError, PlatformStateCard, type PlatformError } from './platform/state'
|
||||
import { DataTable, PageHeader, StatusTag, type Column } from '@hanzo/ui/product'
|
||||
|
||||
const paas = (path: string) => `/paas/${path.replace(/^\/+/, '')}`
|
||||
|
||||
|
||||
@@ -15,15 +15,12 @@ import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { BadgeCheck, Ban, Coins, Github, HandCoins, RefreshCw, Wallet, Zap } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { AdminAuthorsApi, type AdminAuthor, type AdminAuthorsView, type SweepResult } from '~/lib/api/admin-authors'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { MetricCard } from '~/components/ui/Metric'
|
||||
import { EmptyState } from '~/components/ui/EmptyState'
|
||||
import { PrimaryButton } from '~/components/ui/PrimaryButton'
|
||||
import { FieldRow, FieldSelect, FieldText } from '~/components/ui/Field'
|
||||
import { asApiError, ErrorState, isForbidden, SuperAdminRequired } from '~/components/ui/States'
|
||||
import { ApiError } from '~/lib/api'
|
||||
import { dollarsToCents, sharePct, shortDate, statusLabel, statusColor, usd } from './authors/logic'
|
||||
import { toneColor } from '~/components/ui/tone'
|
||||
import { EmptyState, FieldRow, FieldSelect, FieldText, PageHeader, PrimaryButton } from '@hanzo/ui/product'
|
||||
|
||||
type Async<T> =
|
||||
| { phase: 'loading' }
|
||||
|
||||
@@ -31,13 +31,10 @@ import {
|
||||
} from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { AuthorsApi, type AuthorOverview } from '~/lib/api/authors'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { MetricCard } from '~/components/ui/Metric'
|
||||
import { PrimaryButton } from '~/components/ui/PrimaryButton'
|
||||
import { FieldRow, FieldText } from '~/components/ui/Field'
|
||||
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
|
||||
import { payoutMethodLabel, sharePct, shortDate, statusLabel, statusColor, usd, verifyMethodLabel } from './authors/logic'
|
||||
import { toneColor } from '~/components/ui/tone'
|
||||
import { BackendStateCard, FieldRow, FieldText, PageHeader, PrimaryButton, classifyBackend, type BackendState } from '@hanzo/ui/product'
|
||||
|
||||
type Async<T> =
|
||||
| { phase: 'loading' }
|
||||
@@ -122,7 +119,7 @@ function AuthorConnect({ onConnected }: { onConnected: () => void }) {
|
||||
<Text fontSize="$3" color="$color11">
|
||||
The OSS Author program pays you an ongoing share of the platform spend of every organization that deploys your
|
||||
open-source project on Hanzo. Connect GitHub, verify the repositories you own, and earn your share every period —
|
||||
for as long as teams build on your work. New authors are reviewed by our team before earnings begin.
|
||||
for as long as organizations build on your work. New authors are reviewed by our team before earnings begin.
|
||||
</Text>
|
||||
<YStack gap="$1">
|
||||
<FieldRow label="GitHub login (optional)">
|
||||
|
||||
Binary file not shown.
@@ -26,15 +26,8 @@ import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Blocks, ListChecks, Play, Plus, Power, RefreshCw, Trash2, Workflow } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { AutomationsApi, type AutomationFlow, type FlowRun, type Piece } from '~/lib/api/automations'
|
||||
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
|
||||
import { DataTable, type Column } from '~/components/ui/DataTable'
|
||||
import { EmptyState } from '~/components/ui/EmptyState'
|
||||
import { FieldSelect, FieldText } from '~/components/ui/Field'
|
||||
import { Loader } from '~/components/ui/Loader'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { PrimaryButton } from '~/components/ui/PrimaryButton'
|
||||
import { SlideOver } from '~/components/ui/SlideOver'
|
||||
import { StatusTag } from '~/components/ui/StatusTag'
|
||||
import {
|
||||
authLabel,
|
||||
capabilitySummary,
|
||||
@@ -49,6 +42,7 @@ import {
|
||||
validateFlowName,
|
||||
} from './automations/logic'
|
||||
import { toneColor } from '~/components/ui/tone'
|
||||
import { BackendStateCard, DataTable, EmptyState, FieldSelect, FieldText, PageHeader, PrimaryButton, StatusTag, classifyBackend, type BackendState, type Column } from '@hanzo/ui/product'
|
||||
|
||||
/** Cap the rendered connector cards; search/category narrow the rest (honest note). */
|
||||
const CATALOG_RENDER_CAP = 240
|
||||
|
||||
@@ -14,9 +14,8 @@ import { FlaskConical, RefreshCw } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { ApiError } from '~/lib/api'
|
||||
import { EnablementApi, type UserEnablementItem } from '~/lib/api/admin-cockpit'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { EmptyState } from '~/components/ui/EmptyState'
|
||||
import { ErrorState, asApiError } from '~/components/ui/States'
|
||||
import { EmptyState, PageHeader } from '@hanzo/ui/product'
|
||||
|
||||
export function BetaFeaturesModule() {
|
||||
const [view, setView] = useState<{ items: UserEnablementItem[]; betas: UserEnablementItem[] } | null>(null)
|
||||
|
||||
@@ -14,8 +14,7 @@ import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { RefreshCw } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { ApiError, BotApi } from '~/lib/api'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { StatusTag } from '~/components/ui/StatusTag'
|
||||
import { PageHeader, StatusTag } from '@hanzo/ui/product'
|
||||
|
||||
/** Operator surfaces for the bot — public product domains, opened in a tab. */
|
||||
const SURFACES: ReadonlyArray<{ label: string; href: string; hint: string }> = [
|
||||
|
||||
@@ -31,8 +31,7 @@ import { Bot, Cpu, ExternalLink, Monitor, Play, RefreshCw, Square, Terminal } fr
|
||||
|
||||
import { ApiError } from '~/lib/api'
|
||||
import { BotsApi, type BotRunRow, type BotSurface } from '~/lib/api/bots'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { StatusTag } from '~/components/ui/StatusTag'
|
||||
import { PageHeader, StatusTag } from '@hanzo/ui/product'
|
||||
|
||||
/** Statuses a run can't be stopped from — the Stop button is disabled on these. */
|
||||
const TERMINAL = new Set([
|
||||
|
||||
@@ -13,10 +13,8 @@ import { Button, Text } from '@hanzo/gui'
|
||||
import { RefreshCw } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { restGet, cloudProxyV1Url } from '~/lib/api/client'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { DataTable, type Column } from '~/components/ui/DataTable'
|
||||
import { StatusTag } from '~/components/ui/StatusTag'
|
||||
import { interpretPlatformError, PlatformStateCard, type PlatformError } from './platform/state'
|
||||
import { DataTable, PageHeader, StatusTag, type Column } from '@hanzo/ui/product'
|
||||
|
||||
type Build = {
|
||||
id: string
|
||||
|
||||
@@ -54,21 +54,15 @@ import {
|
||||
type Safe,
|
||||
type Round,
|
||||
} from '~/lib/api/captable'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { PrimaryButton } from '~/components/ui/PrimaryButton'
|
||||
import { DataTable, type Column } from '~/components/ui/DataTable'
|
||||
import { EmptyState } from '~/components/ui/EmptyState'
|
||||
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
|
||||
import { StatusTag } from '~/components/ui/StatusTag'
|
||||
import { SlideOver } from '~/components/ui/SlideOver'
|
||||
import { FieldRow, FieldText, FieldSelect, FieldOptionSelect } from '~/components/ui/Field'
|
||||
import { FieldOptionSelect } from '~/components/ui/Field'
|
||||
import { MetricCard } from '~/components/ui/Metric'
|
||||
import { Donut } from '~/components/ui/Charts'
|
||||
import { RAMP } from '~/lib/theme/ramp'
|
||||
import { Loader } from '~/components/ui/Loader'
|
||||
import { useToast } from '~/components/ui/Toast'
|
||||
import { ConfirmDelete } from '~/components/ui/ConfirmDelete'
|
||||
import { toneColor } from '~/components/ui/tone'
|
||||
import { BackendStateCard, ConfirmDelete, DataTable, EmptyState, FieldRow, FieldSelect, FieldText, PageHeader, PrimaryButton, StatusTag, classifyBackend, type BackendState, type Column } from '@hanzo/ui/product'
|
||||
|
||||
type Tab = 'summary' | 'stakeholders' | 'shares' | 'classes' | 'fundraising'
|
||||
const tabPath = (id: Tab): string => (id === 'summary' ? '/captable' : `/captable/${id}`)
|
||||
|
||||
@@ -27,12 +27,7 @@ import { Boxes, Check, Pencil, Plus, RefreshCw, Save, Trash2 } from '@hanzogui/l
|
||||
import { ApiError } from '~/lib/api'
|
||||
import { CatalogAdminApi, type CatalogEntry, type CatalogEntryInput } from '~/lib/api/catalog-admin'
|
||||
import { config } from '~/config'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { DataTable, type Column } from '~/components/ui/DataTable'
|
||||
import { FieldRow, FieldSelect, FieldSwitch, FieldText, FieldTextArea } from '~/components/ui/Field'
|
||||
import { PrimaryButton } from '~/components/ui/PrimaryButton'
|
||||
import { SlideOver } from '~/components/ui/SlideOver'
|
||||
import { ConfirmDelete } from '~/components/ui/ConfirmDelete'
|
||||
import { ErrorState, asApiError, isForbidden, SuperAdminRequired, type HonestCopy } from '~/components/ui/States'
|
||||
import { MetadataEditor } from './pricing/MetadataEditor'
|
||||
import {
|
||||
@@ -49,6 +44,7 @@ import {
|
||||
specSummary,
|
||||
} from './catalog/logic'
|
||||
import { toneVar } from '~/components/ui/tone'
|
||||
import { ConfirmDelete, DataTable, FieldRow, FieldSelect, FieldSwitch, FieldText, FieldTextArea, PageHeader, PrimaryButton, type Column } from '@hanzo/ui/product'
|
||||
|
||||
const CATALOG_COPY: HonestCopy = {
|
||||
notFound:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user