Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8d83250f7 | ||
|
|
a7a7fc9ef6 | ||
|
|
7c6305c455 | ||
|
|
15da85d0ac | ||
|
|
d20f0859f2 | ||
|
|
53375e44ed | ||
|
|
87abc48add | ||
|
|
8153606ac8 | ||
|
|
c4ac4eff3f | ||
|
|
922ba7d676 | ||
|
|
15b8cff948 | ||
|
|
bbff6b3a82 | ||
|
|
83e6eb4667 | ||
|
|
32c88caa12 | ||
|
|
b4c71e1aac | ||
|
|
c8295794fa | ||
|
|
40da2fc17d | ||
|
|
d09d3cae1b | ||
|
|
0207df0759 | ||
|
|
a0a489986d | ||
|
|
b6e35ee042 | ||
|
|
06e416365b | ||
|
|
da268976b8 | ||
|
|
2271c29297 | ||
|
|
9da398459e | ||
|
|
b9d31aa305 | ||
|
|
1b50c31a96 | ||
|
|
846069c98b | ||
|
|
06b7f5416a | ||
|
|
80cbfcbbb1 | ||
|
|
4656c316f4 | ||
|
|
d9bccc9e8d | ||
|
|
ffd291fedf | ||
|
|
4da48f9274 | ||
|
|
7a37a8fd92 | ||
|
|
29c98ddf5b | ||
|
|
f64de84a4e | ||
|
|
ecb9be3a8b | ||
|
|
400a206d80 | ||
|
|
628e6fb469 | ||
|
|
c1a7d48113 | ||
|
|
e5f510b994 | ||
|
|
0a883aefaf | ||
|
|
0c097129d8 | ||
|
|
3749f2669f | ||
|
|
06d0681081 | ||
|
|
cdb6f9d091 | ||
|
|
744164c4c5 | ||
|
|
454636c4e2 | ||
|
|
8bdbc9116a | ||
|
|
599f6f3a31 | ||
|
|
f26c8fbcca | ||
|
|
447825ef6d | ||
|
|
daef1ea32c | ||
|
|
b55eb201d6 | ||
|
|
e7b36af216 | ||
|
|
9d6190a783 |
+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/*.
|
||||
|
||||
@@ -92,7 +92,8 @@ keyed by `owner/modelName`, so modelName is form-entered, not generated).
|
||||
|
||||
One `request()` in `lib/api/client.ts`: always `credentials: 'include'` (the
|
||||
backend sets a session cookie at `/v1/signin`), forwards `Accept-Language`,
|
||||
unwraps the casibase `{ status, msg, data, data2 }` envelope, throws typed
|
||||
unwraps the casibase `{ status, msg, data, total }` envelope (named `total`
|
||||
first, legacy `data2` count accepted until the emitters finish renaming), throws typed
|
||||
`ApiError` (401/403 carry status). Base URL = `config.cloudUrl` (default
|
||||
`https://cloud.hanzo.ai`, override `NEXT_PUBLIC_CLOUD_URL`).
|
||||
|
||||
@@ -325,14 +326,14 @@ keep all credentials server-side (the browser only ever sends its session cookie
|
||||
embeddings|rerank` (not a general tunnel). `playground.ts` now points at this
|
||||
proxy (`<origin>/ai`), so Models/Playground/Chat/cmd+K all work with no key in
|
||||
the browser and no rotation on a chat turn.
|
||||
- **`app/keys/route.ts`** — per-user `hk-` Cloud API key. POST mint/rotate, DELETE
|
||||
- **`app/keys/route.ts`** — per-user `sk-` Cloud API key. POST mint/rotate, DELETE
|
||||
revoke, GET status (no secret). Same app-on-behalf pattern via
|
||||
`/v1/iam/mint-user-keys` + `/v1/iam/revoke-user-keys`. The `hk-` secret is shown
|
||||
`/v1/iam/mint-user-keys` + `/v1/iam/revoke-user-keys`. The `sk-` secret is shown
|
||||
ONCE (POST). `ApiKeysModule` is now create/copy/rotate/revoke.
|
||||
- Shared trust boundary: `src/lib/server/identity.ts` (server-only) — `resolveUser`
|
||||
+ `mintUserKey`/`revokeUserKey`/`issueUserToken`. The `hanzo-console` client is
|
||||
allow-listed in IAM `IAM_KEY_MINT_ALLOWED_APPS`; verified end-to-end that a
|
||||
minted `hk-` key and an issued user JWT both 200 on `api.hanzo.ai/v1/chat/
|
||||
minted `sk-` key and an issued user JWT both 200 on `api.hanzo.ai/v1/chat/
|
||||
completions`.
|
||||
- **Chat is interactive** (`chat/ChatConversation.tsx`): a real multi-turn
|
||||
conversation over `AiApi.chat` (→ the `/ai` proxy), with a Zen default model,
|
||||
@@ -3701,3 +3702,134 @@ and I will not type a password; one SuperAdmin session re-running this spec clos
|
||||
that. Untouched and flagged for the caps pass: `MarketplaceModule`'s "CATEGORIES"
|
||||
and the palette's own uppercased section labels are `textTransform` sites that
|
||||
belong to that lane, not this one.
|
||||
|
||||
## Billing calls the route names the server actually registers
|
||||
|
||||
Commerce dropped the compound prefixes from its billing routes — the `/v1/billing/`
|
||||
namespace already says "billing", so `billing/payment-methods` stuttered. Both servers
|
||||
register only the short names, measured against the live edge: `/v1/billing/methods`
|
||||
401, `/v1/billing/settings` 403, `/v1/billing/alerts` 403, while `payment-methods`,
|
||||
`payment-config` and `spend-alerts` are all 404. The console never followed. Its card
|
||||
reads, its card writes and its Square-config read were all addressed at routes that no
|
||||
longer exist, so a new user could not add a card — the revenue path was broken in
|
||||
production.
|
||||
|
||||
The client had already been repointed for alerts, so `payment-methods` (list, save,
|
||||
detach) and `payment-config` were the ones still dead. They now build `methods` and
|
||||
`settings`. There is deliberately no alias and no fallback: one name per concept.
|
||||
|
||||
The tests were part of the defect, not the safety net. Every suite around payment
|
||||
methods stubbed a response body and asserted the normalization, so a client pointed at
|
||||
a 404 stayed green — the exact reason this survived. The URL is now pinned where the
|
||||
request is actually made, including the two reads nothing had ever asserted
|
||||
(`methods`, `settings`) and `alerts` beside them. Reverting any of the four short names
|
||||
turns the suite red, which was checked rather than assumed.
|
||||
|
||||
`POST /v1/billing/me/welcome` and everything feeding it is deleted, not repointed.
|
||||
Commerce removed that route on purpose: it was a self-service mint, a browser could
|
||||
grant its own org $5, and commerce's own `api/billing/mint_gates_test.go` names it the
|
||||
TOCTOU double-mint. Credit is minted only through the mint-gated `POST
|
||||
/v1/billing/credit`. The call had been failing silently, so restoring it would have
|
||||
re-opened a closed money hole to fix nothing. The trial credit still lands — commerce
|
||||
grants it server-side when a card is vaulted, and signup grants it server-side — and
|
||||
that path is untouched. `src/lib/billing/welcome.ts` had no callers left at all.
|
||||
|
||||
Scope, checked rather than assumed: `/v1/finance/payment-methods` is still 401 (alive)
|
||||
and `/v1/finance/methods` is 404, so the finance ledger keeps the compound name — a
|
||||
blanket repo-wide rename would have broken it. The Billing Center's tab slugs
|
||||
(`/billing/payment-methods`, `/billing/credits`) are console page URLs, not server
|
||||
routes, and are unchanged.
|
||||
|
||||
Two headlines were lying about which layer failed. "Card top-up isn't available on this
|
||||
deployment yet" and "Adding a card isn't available on this deployment yet" both fire
|
||||
when the ORG has no Square `applicationId`/`locationId` — a per-organization
|
||||
configuration, not a property of the deployment. Both now name the organization, as
|
||||
does the onboarding step's "Payments aren't set up", which had the same defect. The
|
||||
stale `GET /v1/billing/payment-config` endpoint hints under those cards now read
|
||||
`settings`.
|
||||
|
||||
## The assistant has one home, and the app directory is one you can walk
|
||||
|
||||
Three fixes to the console's own chrome. Each root cause was measured in a browser
|
||||
on computed style, geometry or where the browser lands — never inferred from source.
|
||||
|
||||
**The assistant lived in a third place.** `FloatingChat` owned every shape the
|
||||
assistant can take (the sheet, the docked column, the dock state) except the way
|
||||
in, which was two small buttons in the TOPBAR — a brand-H "Chat with Hanzo" and a
|
||||
"Talk to Hanzo" mic — wedged between the search box and the org/theme/alert
|
||||
cluster. On a 390px phone that put five controls in the header and squeezed the
|
||||
search field to "Search or jump…". Both controls moved into `AssistantFab`, one
|
||||
floating cluster fixed bottom-right, in the corner the assistant actually appears
|
||||
in. Chat and voice are the same surface opened two ways, so they sit together.
|
||||
Nothing about the assistant was rewritten: the FAB calls the same `openChat` /
|
||||
`startVoice` the topbar called, and `open`/`toggle`/`ask` still drive it
|
||||
programmatically (the Code hub's "Ask AI").
|
||||
|
||||
It is suppressed exactly where the assistant is already on screen — while the
|
||||
sheet is open, on the pages that ARE a composer, and, at `lg+` only, while it is
|
||||
docked as a column. That last half is a CSS media prop rather than a JS branch so
|
||||
SSR and first paint agree, and it sits above the Developers dock (whose collapsed
|
||||
bar is 44px and exists only at `lg+`).
|
||||
|
||||
**[BUG, measured] All products was a directory you could not walk.** The pane that
|
||||
lists every Hanzo app — the sidebar's "All products", the one place the whole
|
||||
catalog is browsable — rendered each app as an inert `XStack`: a plain `DIV` with
|
||||
`role=null` and `cursor: auto`, no handler, no pointer affordance. Measured, not
|
||||
read. The only live control in the row was the pin, so a user could curate the
|
||||
sidebar but could not open anything from the list. The row now opens its app
|
||||
through the shared `openProduct` — the ONE opener the sidebar, ⌘K and the category
|
||||
pages already route through — and closes the pane behind it, because a directory is
|
||||
not a destination. Pin stays a separate control on the same row and stops the press
|
||||
from bubbling: curating never navigates, navigating never curates.
|
||||
|
||||
**[BUG] A pin made after sign-in was thrown away on the next reload.** Preferences
|
||||
are read off `properties['hanzo.preferences']` in the IAM access token's claims — a
|
||||
SNAPSHOT taken when that token was minted. An earlier lane fixed the case where the
|
||||
snapshot is SILENT about a key. The other half was never closed: once a user has
|
||||
saved anything, the next token CARRIES a snapshot, and the merge let it win over a
|
||||
newer local write. So the second pin onward read as pinned and was gone after F5.
|
||||
|
||||
The merge is now told the ordering it was missing. `Account` carries the token's
|
||||
own `iat`; the provider stamps `…prefs.<user>.writtenAt` when — and only when — the
|
||||
SERVER acknowledges a write; `mergePrefs(cached, fromAccount, order)` lets the cache
|
||||
win only when a confirmed write is newer than the snapshot. Last writer wins, and
|
||||
both writers are now identifiable. A fresh device (no cache, no stamp) and a fresh
|
||||
sign-in (token minted after the write) both still take the account wholesale, so
|
||||
cross-device is preserved. Stamping only server-confirmed writes is what keeps this
|
||||
from being localStorage impersonating a backend: a save that never landed earns
|
||||
nothing and the account stays authoritative.
|
||||
|
||||
**Backend gap, named rather than papered over.** There is no READ for this
|
||||
document. `PATCH /v1/ai/preferences` (hanzoai/ai `UpdatePreferences`) writes it to
|
||||
the IAM user's `properties['hanzo.preferences']` and returns the merged result;
|
||||
nothing serves a GET, so the token's snapshot is the only read the console has. The
|
||||
smallest seam that removes the ordering problem entirely is `GET
|
||||
/v1/ai/preferences` returning that property after the handler's existing
|
||||
`refreshSessionUser` — the write path already does every part of it. Better still
|
||||
is `GET/PATCH /v1/prefs` (hanzoai/cloud `apps/prefs`), the canonical cross-surface
|
||||
plane, which answers 503 on api.hanzo.ai today.
|
||||
|
||||
**House rule: one filled CTA.** Counted by computed background luminance on the
|
||||
rendered home, not by reading JSX: FIVE white-filled buttons competed — "Take the
|
||||
tour", the getting-started card's active step, and all three `PrimaryActionTile`
|
||||
CTAs (two of them saying "Get API key"). The same measurement now returns ONE: the
|
||||
checklist's ACTIVE step, the thing to do next. The tour is a neutral aside beside its
|
||||
dismiss, and the three tiles are neutral because they are PEERS — a menu of things
|
||||
you can do, not a call to action, and three primaries are none.
|
||||
|
||||
**Verification.** `tsc --noEmit` clean; `vitest` 3175 passed / 8 skipped (256 files,
|
||||
+6 ordering tests). RED→GREEN proven both ways: disabling `cacheIsNewer` turns the
|
||||
two new ordering tests red and the browser test with it. `e2e/assistant-fab-and-apps.spec.ts`
|
||||
(4 tests, 1440 and 390) asserts the control's BOX is in the bottom-right quadrant and
|
||||
≥44px, that `.hz-topbar` carries no assistant control, that clicking an app in All
|
||||
products LANDS on `/agents`, and that a pin survives a reload under a token whose
|
||||
snapshot is an hour old. `e2e/chrome-brand-voice.spec.ts` was retargeted, not
|
||||
deleted — every claim it made still holds, only the location moved.
|
||||
|
||||
Two spec gotchas worth keeping. `_session.ts`'s `b64` emitted plain base64; that is
|
||||
fine while a forged payload is tiny, but `+`/`/` appear as soon as one grows (a
|
||||
`properties` bag is enough) and a strict decoder rejects the token outright — the SDK
|
||||
reports signed out and the app sits on its loader forever. It emits base64URL now,
|
||||
which is what a JWT segment actually is. And the assistant's composer carries its own
|
||||
mic with the same `Talk to Hanzo` label, mounted-but-hidden until the panel opens, so
|
||||
a bare attribute locator matches that one first: scope to `getByTestId('assistant-fab')`.
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -22,7 +22,6 @@ import { openProduct } from '~/lib/products/open'
|
||||
import { useFavorites } from '~/lib/products/favorites'
|
||||
import { useIsSuperAdmin } from '~/lib/auth/admin'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { PrimaryButton } from '~/components/ui/PrimaryButton'
|
||||
import { ProductIcon } from '~/components/ui/ProductIcon'
|
||||
import type { IconLike } from '~/components/ui/color'
|
||||
import { useProductColors } from '~/lib/products/pins'
|
||||
@@ -148,13 +147,19 @@ function PrimaryActionTile({
|
||||
{description}
|
||||
</Text>
|
||||
<XStack>
|
||||
<PrimaryButton
|
||||
{/* Neutral, not filled. These three tiles are PEERS — a menu of things you can
|
||||
do, not a call to action — so three white buttons side by side gave the
|
||||
screen three primaries and therefore none. The one filled action on this
|
||||
page is the getting-started card's active step: the thing to do NEXT. */}
|
||||
<Button
|
||||
size="$3"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
iconAfter={external ? <ExternalLink size={15} /> : <ArrowRight size={15} />}
|
||||
onPress={onPress}
|
||||
>
|
||||
{ctaLabel}
|
||||
</PrimaryButton>
|
||||
</Button>
|
||||
</XStack>
|
||||
</Card>
|
||||
)
|
||||
|
||||
@@ -95,7 +95,7 @@ export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
|
||||
/**
|
||||
* POST — the GLOBAL-admin mutations that ride the same god-view gate
|
||||
* (`/v1/admin/providers/{toggle,primary}`, `/v1/admin/spend-caps` create). Identical
|
||||
* (`/v1/admin/providers/{toggle,primary}`, `/v1/admin/caps` create). Identical
|
||||
* path through `getAdminGate` (fail-closed 403) → `forwardWithUserBearer`, which applies
|
||||
* the same-origin CSRF check to this mutating method BEFORE resolving the user, streams
|
||||
* the JSON body through, and re-validates the path against `allowAdminSurface` (so a POST
|
||||
@@ -116,16 +116,16 @@ export async function PUT(req: NextRequest, ctx: Ctx) {
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH — the GLOBAL-admin partial edits (`PATCH /v1/admin/spend-caps/:id?org=<slug>`,
|
||||
* PATCH — the GLOBAL-admin partial edits (`PATCH /v1/admin/caps/:id?org=<slug>`,
|
||||
* override an org's usage cap). Same gate + same CSRF/traversal hardening; the `:id`
|
||||
* sub-path passes because `allowAdminSurface` admits `v1/admin/spend-caps[/...]`.
|
||||
* sub-path passes because `allowAdminSurface` admits `v1/admin/caps[/...]`.
|
||||
*/
|
||||
export async function PATCH(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE — the GLOBAL-admin removals (`DELETE /v1/admin/spend-caps/:id?org=<slug>`,
|
||||
* DELETE — the GLOBAL-admin removals (`DELETE /v1/admin/caps/:id?org=<slug>`,
|
||||
* remove an org's usage cap). Same gate + CSRF/traversal hardening as the other
|
||||
* mutating verbs; only an allow-listed head/sub-path is ever reached.
|
||||
*/
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* `/v1/chat/completions` (and friends) REQUIRE an `Authorization: Bearer` token; a
|
||||
* browser session cookie alone is rejected. Rather than ship the user's durable
|
||||
* `hk-` key to the browser, the console calls its OWN origin at the canonical, prefix-free
|
||||
* `sk-` key to the browser, the console calls its OWN origin at the canonical, prefix-free
|
||||
* `/v1/<aihead>` (the /v1-first law); `next.config.mjs` dispatches those heads to THIS `/ai`
|
||||
* proxy (re-rooting the upstream at `v1/` — invisible to the client). `forwardWithUserBearer`
|
||||
* resolves the user, mints a SHORT-LIVED, user-bound IAM token (shared per-user cache in
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
+5
-5
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Per-user `hk-` Cloud API key — the SAME-ORIGIN console route (the fix for the
|
||||
* Per-user `sk-` Cloud API key — the SAME-ORIGIN console route (the fix for the
|
||||
* API-keys "sign in to manage API keys" / CORS crack).
|
||||
*
|
||||
* The browser calls this OWN-origin route (`/keys`) with just its first-party
|
||||
@@ -7,19 +7,19 @@
|
||||
* (`resolveUser`) and mints/reads/revokes the key through IAM as the confidential
|
||||
* `hanzo-console` client (`identity.ts` `mintUserKey`/`getUserKey`/`revokeUserKey`,
|
||||
* over IAM `mint-user-keys`/`get-user`/`revoke-user-keys` — the WORKING key path,
|
||||
* verified live). No credential ever reaches the browser; the `hk-` secret is
|
||||
* verified live). No credential ever reaches the browser; the `sk-` secret is
|
||||
* returned ONLY by POST (show once).
|
||||
*
|
||||
* Why not `cloud.hanzo.ai/v1/iam/keys` (the old path): that is a DIFFERENT
|
||||
* ORIGIN than console.hanzo.ai, so a browser `fetch` is blocked by CORS ("Failed to
|
||||
* fetch") — and cloud-api's own keys handler 501s ("IAM client unset") on this
|
||||
* deployment anyway. The IAM confidential-client mint the console already uses for
|
||||
* `hk-` keys elsewhere (`app/ai` chat) is the ONE authoritative, same-origin,
|
||||
* `sk-` keys elsewhere (`app/ai` chat) is the ONE authoritative, same-origin,
|
||||
* always-working path — so the Org-Settings API-keys surface uses it too (DRY: the
|
||||
* exact primitives from `identity.ts`, no new IAM plumbing).
|
||||
*
|
||||
* GET → { hasKey, keyPrefix, createdAt } (no secret)
|
||||
* POST → { accessKey } (mint/rotate; full hk- shown ONCE)
|
||||
* POST → { accessKey } (mint/rotate; full sk- shown ONCE)
|
||||
* DELETE → { ok: true } (revoke; the old key stops working)
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
@@ -57,7 +57,7 @@ export async function GET(req: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
/** POST — mint (or rotate) the key. Returns the full `hk-` secret ONCE. */
|
||||
/** POST — mint (or rotate) the key. Returns the full `sk-` secret ONCE. */
|
||||
export async function POST(req: NextRequest) {
|
||||
// CSRF: minting mutates (and is billable-adjacent) from the auto-sent cookie —
|
||||
// refuse a cross-origin request before any work.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Same-origin proxy to the cloud ML/training surface on hanzoai/ai (`/v1/train/*`,
|
||||
* `/v1/ml/models`, and the fine-tuning broker `/v1/finetune/*`).
|
||||
* Same-origin proxy to the cloud ML/training surface (`/v1/ml/models` and the
|
||||
* fine-tuning broker `/v1/finetune/*`).
|
||||
*
|
||||
* The console's Training page calls its OWN origin (`/training/...`) with just the
|
||||
* first-party session cookie; this server handler resolves the signed-in user from
|
||||
@@ -11,16 +11,16 @@
|
||||
* this is user-scoped (resolveUser), NOT the control-plane admin gate the `/paas`
|
||||
* proxy uses. The cloud backend resolves the org from the token's `owner` claim (and
|
||||
* the X-Org-Id the plain-REST train sub-service reads), so a caller can only ever
|
||||
* touch their own org's jobs. `POST /v1/train/jobs` is billing-gated by the live
|
||||
* ResourceMeter and returns 402 on an unfunded org — that status flows straight back
|
||||
* so the UI can surface it honestly.
|
||||
* touch their own org's jobs. `POST /v1/finetune/jobs` is billing-gated upstream and
|
||||
* returns 402 on an unfunded org — that status flows straight back so the UI can
|
||||
* surface it honestly.
|
||||
*
|
||||
* Why a Bearer and NOT the cookie (the fix for the "Not enabled" 403): cloud-api's
|
||||
* `/v1/train/*` authorizes on a VALIDATED JWT principal and returns 403 "no validated
|
||||
* `/v1/*` authorizes on a VALIDATED JWT principal and returns 403 "no validated
|
||||
* principal" for a cookie-only call — the raw casibase session cookie is NOT a
|
||||
* principal it accepts (only the sanitizer's cookie-token names or a Bearer). Minting
|
||||
* the same user-bound token the `/v1` proxy uses is the ONE way a signed-in tenant
|
||||
* reaches the train surface; the cookie is deliberately dropped upstream (it can't
|
||||
* reaches this surface; the cookie is deliberately dropped upstream (it can't
|
||||
* authenticate, and a cookie + JWT together risks the public-gateway 431).
|
||||
*
|
||||
* Least privilege: only the explicit ML/training sub-paths are forwarded; anything
|
||||
@@ -44,14 +44,9 @@ const CLOUD_API_URL = trim(process.env.CLOUD_API_URL ?? 'http://cloud.hanzo.svc.
|
||||
|
||||
/** The exact `/v1/<...>` ML/training sub-paths the console is allowed to reach. */
|
||||
const ALLOWED = new Set([
|
||||
// mlsvc — the canonical training surface (task #40 ResourceMeter gates POST jobs).
|
||||
'train/jobs',
|
||||
'train/experiments',
|
||||
// Model serving — the org's deployed kserve InferenceServices.
|
||||
'ml/models',
|
||||
// Real Kubeflow control-plane probe (which operators/CRDs are actually served).
|
||||
// Read-only; 503 + body flows through so the UI can report a degraded plane.
|
||||
'train/health',
|
||||
// fine-tuning broker (custom-data runs, HF search) — sibling surface.
|
||||
// fine-tuning broker (custom-data runs, HF search) — the ONE training door.
|
||||
'finetune/jobs',
|
||||
'finetune/job',
|
||||
'finetune/cancel',
|
||||
@@ -68,7 +63,7 @@ async function forward(req: NextRequest, path: string[]): Promise<NextResponse>
|
||||
return NextResponse.json({ status: 'error', msg: 'Not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// CSRF: `POST /train/jobs` mutates (and bills) from the auto-sent cookie — refuse a
|
||||
// CSRF: `POST /finetune/jobs` mutates (and bills) from the auto-sent cookie — refuse a
|
||||
// cross-origin one before any work (safe reads pass).
|
||||
const csrf = csrfRefusal(req, 'casibase')
|
||||
if (csrf) return csrf
|
||||
@@ -82,7 +77,7 @@ async function forward(req: NextRequest, path: string[]): Promise<NextResponse>
|
||||
}
|
||||
|
||||
// Mint a short-lived, user-bound Bearer (the SAME per-user cache the `/v1`
|
||||
// proxy uses). cloud-api's `/v1/train/*` 403s a cookie-only call ("no validated
|
||||
// proxy uses). cloud-api's `/v1/*` 403s a cookie-only call ("no validated
|
||||
// principal"); a Bearer is the one credential it accepts. Fail CLOSED with 502 if
|
||||
// the token can't be minted — never fall through to an unauthenticated forward.
|
||||
let bearer: string
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
* billing UI tab URLs (`/billing/reports`, `/billing/invoices`, …) — they differ at
|
||||
* the FIRST path segment, so the tab slugs fall through to the SPA.
|
||||
*
|
||||
* Verbs: GET (reads: balance/usage/invoices/subscriptions/payment-methods, and the
|
||||
* per-invoice PDF), POST (writes: top-up, spend-alerts, save-a-method, cancel/
|
||||
* Verbs: GET (reads: balance/usage/invoices/subscriptions/methods, and the
|
||||
* per-invoice PDF), POST (writes: top-up, alerts, save-a-method, cancel/
|
||||
* reactivate a subscription), PATCH (edit a budget/spend-alert), DELETE (detach a
|
||||
* saved payment method, remove a budget). Each is scoped to the caller's OWN org
|
||||
* server-side; a mutating verb is CSRF-guarded (`forwardBilling`).
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
// Side-effect CSS imports (`import './globals.css'`, `import '@hanzogui/core/reset.css'`).
|
||||
// The bundler owns them; TypeScript only needs to know the specifier resolves.
|
||||
// TS7 (tsgo) errors on an unresolvable side-effect import (TS2882) where tsc stayed
|
||||
// silent, so the declaration lives here — one place, every stylesheet.
|
||||
declare module '*.css'
|
||||
+3
-2
@@ -2,8 +2,9 @@
|
||||
|
||||
The console talks to the unified Hanzo Cloud backend (`hanzoai/cloud`).
|
||||
Base URL: `${NEXT_PUBLIC_CLOUD_URL}/v1`. All requests send cookie
|
||||
credentials; responses are the envelope `{ status, msg, data, data2 }` (`data2`
|
||||
is the total row count on list endpoints).
|
||||
credentials; responses are the envelope `{ status, msg, data, total }` (`total`
|
||||
is the row count on list endpoints; the legacy `data2` count is still accepted
|
||||
as a fallback until every emitter finishes the rename).
|
||||
|
||||
Client modules live in `src/lib/api/`.
|
||||
|
||||
|
||||
+24
-8
@@ -27,12 +27,24 @@ export type SessionClaims = {
|
||||
email?: string
|
||||
displayName?: string
|
||||
isAdmin?: boolean
|
||||
/** The IAM user's property bag — where `hanzo.preferences` rides as a SNAPSHOT. */
|
||||
properties?: Record<string, string>
|
||||
/** When the token was minted (`iat`, seconds). Defaults to now; set it in the past
|
||||
* to reproduce the production case where the snapshot predates a later write. */
|
||||
issuedAt?: number
|
||||
}
|
||||
|
||||
const b64 = (o: object): string => Buffer.from(JSON.stringify(o)).toString('base64')
|
||||
/**
|
||||
* base64URL — what a JWT segment actually is. Plain base64 was close enough while the
|
||||
* payloads were tiny, but `+` and `/` appear as soon as one grows (a `properties` bag
|
||||
* is enough), and a strict decoder rejects the token outright: the SDK reports signed
|
||||
* out and the app sits on its loader forever.
|
||||
*/
|
||||
const b64 = (o: object): string =>
|
||||
Buffer.from(JSON.stringify(o)).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
|
||||
/** The default identity render specs run as — a hanzo-org admin. */
|
||||
export const DEFAULT_CLAIMS: Required<SessionClaims> = {
|
||||
export const DEFAULT_CLAIMS: Required<Omit<SessionClaims, 'properties' | 'issuedAt'>> = {
|
||||
owner: 'hanzo',
|
||||
name: 'z',
|
||||
email: 'z@hanzo.ai',
|
||||
@@ -40,20 +52,24 @@ export const DEFAULT_CLAIMS: Required<SessionClaims> = {
|
||||
isAdmin: true,
|
||||
}
|
||||
|
||||
/** An unsigned JWT whose payload carries the claims + a far-future `exp`. */
|
||||
export function forgeToken(claims: Required<SessionClaims>): string {
|
||||
const payload = { ...claims, sub: `${claims.owner}/${claims.name}`, exp: Math.floor(Date.now() / 1000) + 3600 }
|
||||
/** An unsigned JWT whose payload carries the claims, an `iat` and a far-future `exp`. */
|
||||
export function forgeToken(claims: SessionClaims): string {
|
||||
const iat = claims.issuedAt ?? Math.floor(Date.now() / 1000)
|
||||
const payload = { ...claims, sub: `${claims.owner}/${claims.name}`, iat, exp: iat + 86_400 }
|
||||
return `${b64({ alg: 'none' })}.${b64(payload)}.x`
|
||||
}
|
||||
|
||||
/** Seed tokens + gate keys and register the IAM endpoint mocks. */
|
||||
export async function primeSession(page: Page, overrides: Partial<SessionClaims> = {}): Promise<void> {
|
||||
const claims: Required<SessionClaims> = { ...DEFAULT_CLAIMS, ...overrides }
|
||||
const claims: SessionClaims = { ...DEFAULT_CLAIMS, ...overrides }
|
||||
await page.addInitScript(
|
||||
({ org, token }: { org: string; token: string }) => {
|
||||
try {
|
||||
sessionStorage.setItem('hanzo_iam_access_token', token)
|
||||
sessionStorage.setItem('hanzo_iam_expires_at', String(Date.now() + 3600_000))
|
||||
// localStorage, not sessionStorage: the `@hanzo/iam` token store is shared
|
||||
// across tabs (that IS the session), so seeding a per-tab area would leave
|
||||
// the SDK reading an empty store and every primed spec signed out.
|
||||
localStorage.setItem('hanzo_iam_access_token', token)
|
||||
localStorage.setItem('hanzo_iam_expires_at', String(Date.now() + 3600_000))
|
||||
localStorage.setItem('hanzo.console.org', org)
|
||||
localStorage.setItem('hanzo.console.org.selected', '1')
|
||||
localStorage.setItem(`hz_onboarding_done:${org}`, '1')
|
||||
|
||||
+25
-10
@@ -1,10 +1,17 @@
|
||||
/**
|
||||
* The ONE account control, at the foot of the rail.
|
||||
* The ONE account control, at the foot of the rail — and the ONE org switch.
|
||||
*
|
||||
* There used to be three: an org switcher at the top of the sidebar, an account
|
||||
* popover at the bottom, and a third menu in the mobile drawer — with four ways
|
||||
* to sign out between them. This spec pins the replacement: one control, both
|
||||
* switchers, at the bottom, in a shell that ships no Tailwind.
|
||||
* There used to be three account-ish menus: an org switcher at the top of the
|
||||
* sidebar, an account popover at the bottom, and a third in the mobile drawer,
|
||||
* with four ways to sign out between them. They became one control that answered
|
||||
* BOTH "who am I" and "where am I".
|
||||
*
|
||||
* They have now been split again, but by QUESTION rather than by accident: the
|
||||
* account control at the foot answers who you are (identity, team, personal
|
||||
* settings, balance, the way out), and `ContextSwitcher` at the TOP-LEFT answers
|
||||
* where you are (organization + project, together, beside the tenant's mark).
|
||||
* So the cross-tenant reach is asserted against the context switcher below, and
|
||||
* the account menu is asserted to no longer offer a tenant at all.
|
||||
*
|
||||
* Everything is asserted on computed style and geometry. The failure this guards
|
||||
* against is a menu that is present in the DOM and unreadable — a library that
|
||||
@@ -40,6 +47,9 @@ const accountTrigger = (page: Page) => page.getByTestId('nav-user').first()
|
||||
/** The trigger inside the phone's account sheet — the last mount in the document. */
|
||||
const drawerTrigger = (page: Page) => page.getByTestId('nav-user').last()
|
||||
|
||||
/** The org + project control at the top-left — the only thing that switches tenant. */
|
||||
const contextTrigger = (page: Page) => page.getByTestId('switcher-context').first()
|
||||
|
||||
async function mountConsole(page: Page, seen: Scoped[]) {
|
||||
// The standalone console reaches the cross-tenant list through its own gated
|
||||
// `/admin/iam` proxy; the go:embed build reaches cloud's `/v1/iam` directly.
|
||||
@@ -182,18 +192,23 @@ test.describe('account control', () => {
|
||||
expect(typedInCaps).toEqual([])
|
||||
})
|
||||
|
||||
test('the org switcher reaches a tenant the caller is not a member of', async ({ page }) => {
|
||||
test('the context switcher reaches a tenant the caller is not a member of', async ({ page }) => {
|
||||
const seen: Scoped[] = []
|
||||
await mountConsole(page, seen)
|
||||
await accountTrigger(page).click()
|
||||
await page.locator('[role=menu]').waitFor()
|
||||
// Tenancy is the TOP-LEFT control's job now, not the account menu's.
|
||||
await contextTrigger(page).click()
|
||||
|
||||
// Acme is nobody's membership — it exists only in the cross-tenant list an
|
||||
// admin may search. A memberships-only switcher could not offer it at all.
|
||||
await page.getByLabel('Find an organization').fill('acme')
|
||||
const acme = page.getByRole('option', { name: 'Acme Industrial' })
|
||||
// `radiogroup`/`radio`, not `listbox`/`option`: @hanzo/gui's `role` union is
|
||||
// React Native's a11y set, which carries `option` but NOT `listbox`.
|
||||
const orgList = page.getByRole('radiogroup', { name: 'Organizations' })
|
||||
const acme = orgList.getByRole('radio', { name: 'Acme Industrial' })
|
||||
await acme.waitFor()
|
||||
await expect(page.getByRole('option')).toHaveCount(1)
|
||||
// Scoped to the ORG group — the same popover also lists projects, and a bare
|
||||
// getByRole('radio') would silently count those too.
|
||||
await expect(orgList.getByRole('radio')).toHaveCount(1)
|
||||
|
||||
await page.screenshot({ path: 'e2e-shots/account-menu-find-org.png', animations: 'disabled' })
|
||||
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* e2e: the assistant's ONE entry point, and the All-products directory you can act in.
|
||||
*
|
||||
* Three claims, each measured in a real browser rather than inferred from source:
|
||||
*
|
||||
* 1. The assistant opens from a FLOATING bottom-right control, not from the header —
|
||||
* asserted on GEOMETRY (the control's box is in the bottom-right quadrant of the
|
||||
* viewport) and on the header carrying no assistant control at all.
|
||||
* 2. Clicking an app in the All-products directory NAVIGATES to that app. This is the
|
||||
* regression that matters: the rows rendered, hovered, and did nothing, so the
|
||||
* directory looked interactive and was not. Asserted on where the browser LANDS.
|
||||
* 3. A pin made in the directory survives a reload EVEN WHEN the identity token
|
||||
* carries an older preferences snapshot — the exact production condition (the
|
||||
* token is minted at sign-in; a pin made after it is not in it).
|
||||
*
|
||||
* Local dev server + mocked network; `primeSession` supplies the IAM-PKCE identity.
|
||||
*
|
||||
* Run: BASE_URL=http://localhost:4000 npx playwright test assistant-fab-and-apps
|
||||
*/
|
||||
import { test, expect, type Route, type Page } from '@playwright/test'
|
||||
import { requireFixtureServer } from './_fixture'
|
||||
import { primeSession } from './_session'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4000'
|
||||
|
||||
requireFixtureServer()
|
||||
const SHOTS = join(process.cwd(), 'e2e-shots')
|
||||
|
||||
const API_RE = /\/(v1|cloud|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations)(\/|$|\?)/
|
||||
|
||||
async function mock(route: Route) {
|
||||
const req = route.request()
|
||||
if (req.resourceType() === 'document') return route.continue()
|
||||
const url = new URL(req.url())
|
||||
const sameOrigin = url.origin === new URL(BASE_URL).origin
|
||||
if (sameOrigin && !API_RE.test(url.pathname)) return route.continue()
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ status: 'ok', msg: '', data: [], data2: 0 }),
|
||||
})
|
||||
}
|
||||
|
||||
/** Sign in and land on `path`, waiting for the signed-in shell to have mounted. */
|
||||
async function boot(page: Page, path = '/', claims?: Parameters<typeof primeSession>[1]) {
|
||||
await page.route('**/*', mock)
|
||||
await primeSession(page, claims)
|
||||
await page.goto(`${BASE_URL}${path}`, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page.getByRole('button', { name: 'Ask Hanzo' })).toBeVisible({ timeout: 60_000 })
|
||||
}
|
||||
|
||||
test.beforeAll(() => mkdirSync(SHOTS, { recursive: true }))
|
||||
|
||||
test('the assistant opens from the bottom-right, and the header carries no AI control', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
await boot(page)
|
||||
|
||||
const fab = page.getByRole('button', { name: 'Ask Hanzo' })
|
||||
const box = await fab.boundingBox()
|
||||
expect(box).not.toBeNull()
|
||||
// Bottom-right quadrant: the whole point of the relocation.
|
||||
expect(box!.x).toBeGreaterThan(1440 / 2)
|
||||
expect(box!.y).toBeGreaterThan(900 / 2)
|
||||
// A comfortable target, not a hairline.
|
||||
expect(box!.width).toBeGreaterThanOrEqual(44)
|
||||
expect(box!.height).toBeGreaterThanOrEqual(44)
|
||||
|
||||
// The topbar itself holds no assistant control any more — it used to carry two
|
||||
// (a brand-H "Chat with Hanzo" and a "Talk to Hanzo" mic) beside the search box.
|
||||
const inTopbar = await page.evaluate(() =>
|
||||
Array.from(document.querySelectorAll('.hz-topbar [aria-label]')).map((n) => n.getAttribute('aria-label') ?? ''),
|
||||
)
|
||||
expect(inTopbar).not.toHaveLength(0) // the topbar was found at all
|
||||
expect(inTopbar.filter((l) => /Hanzo/i.test(l))).toHaveLength(0)
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'assistant-fab-desktop.png') })
|
||||
|
||||
// It opens the SAME assistant surface.
|
||||
await fab.click()
|
||||
await expect(page.getByText('Assistant').first()).toBeVisible({ timeout: 15_000 })
|
||||
await page.screenshot({ path: join(SHOTS, 'assistant-open-desktop.png') })
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('the assistant control is reachable on a phone and never scrolls the body sideways', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 390, height: 844 } })
|
||||
const page = await ctx.newPage()
|
||||
await boot(page)
|
||||
|
||||
const fab = page.getByRole('button', { name: 'Ask Hanzo' })
|
||||
const box = await fab.boundingBox()
|
||||
expect(box).not.toBeNull()
|
||||
expect(box!.x + box!.width).toBeLessThanOrEqual(390)
|
||||
expect(box!.y).toBeGreaterThan(844 / 2)
|
||||
|
||||
const [scrollW, clientW] = await page.evaluate(() => [
|
||||
document.documentElement.scrollWidth,
|
||||
document.documentElement.clientWidth,
|
||||
])
|
||||
expect(scrollW).toBe(clientW)
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'assistant-fab-mobile.png') })
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('clicking an app in All products opens that app', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
await boot(page)
|
||||
|
||||
await page.getByRole('button', { name: 'All products' }).first().click()
|
||||
const row = page.getByRole('button', { name: 'Open Agents' })
|
||||
await expect(row).toBeVisible({ timeout: 15_000 })
|
||||
await page.screenshot({ path: join(SHOTS, 'all-products-desktop.png') })
|
||||
|
||||
await row.click()
|
||||
// Where the browser LANDS is the claim — not that a handler fired.
|
||||
await expect(page).toHaveURL(/\/agents$/, { timeout: 15_000 })
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('a pin made in All products survives a reload under a STALE token snapshot', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
|
||||
// The production condition: the identity token was minted an hour ago and carries a
|
||||
// preferences SNAPSHOT from then. Treating that snapshot as authoritative is what
|
||||
// silently threw away every pin made since — the pin reads as pinned, and is gone
|
||||
// after a reload.
|
||||
const snapshot = { pins: [{ id: 'models', group: '' }], pinGroups: [] }
|
||||
await boot(page, '/', {
|
||||
properties: { 'hanzo.preferences': JSON.stringify(snapshot) },
|
||||
issuedAt: Math.floor(Date.now() / 1000) - 3600,
|
||||
})
|
||||
|
||||
const openDirectory = async () => {
|
||||
await page.getByRole('button', { name: 'All products' }).first().click()
|
||||
// "…to sidebar" / "…from sidebar" are the directory's own labels — the home page's
|
||||
// Apps map carries a plain "Pin Agents", so the short form is ambiguous.
|
||||
await expect(page.getByRole('button', { name: /Agents (to|from) sidebar/ })).toBeVisible({ timeout: 15_000 })
|
||||
}
|
||||
|
||||
// The snapshot the token carries is what the sidebar starts from.
|
||||
await openDirectory()
|
||||
await page.getByRole('button', { name: 'Pin Agents to sidebar' }).click()
|
||||
await expect(page.getByRole('button', { name: 'Remove Agents from sidebar' })).toBeVisible()
|
||||
|
||||
// Only a write the SERVER acknowledged earns the stamp that out-ranks the snapshot.
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => localStorage.getItem('hanzo.console2.prefs.z.writtenAt')), { timeout: 10_000 })
|
||||
.not.toBeNull()
|
||||
|
||||
await page.reload({ waitUntil: 'domcontentloaded' })
|
||||
await expect(page.getByRole('button', { name: 'Ask Hanzo' })).toBeVisible({ timeout: 60_000 })
|
||||
|
||||
// Still pinned — the hour-old snapshot did not win. Asserted on what the user sees…
|
||||
await openDirectory()
|
||||
await expect(page.getByRole('button', { name: 'Remove Agents from sidebar' })).toBeVisible({ timeout: 15_000 })
|
||||
// …and on what was actually kept (models from the snapshot, agents from the write).
|
||||
const pins = await page.evaluate(() => {
|
||||
const raw = JSON.parse(localStorage.getItem('hanzo.console2.prefs.z') ?? '{}')
|
||||
return (raw.pins ?? []).map((p: { id: string }) => p.id)
|
||||
})
|
||||
expect(pins).toContain('agents')
|
||||
expect(pins).toContain('models')
|
||||
await ctx.close()
|
||||
})
|
||||
@@ -1,12 +1,12 @@
|
||||
/**
|
||||
* e2e: two-tenant BILLING ISOLATION through the `/billing/*` proxy.
|
||||
* e2e: two-tenant BILLING ISOLATION through the `/v1/billing/*` proxy.
|
||||
*
|
||||
* The proxy (app/billing/v1/[...path]/route.ts) resolves the billing subject from the
|
||||
* The proxy (app/v1/billing/[...path]/route.ts) resolves the billing subject from the
|
||||
* session server-side and pins the full subject-key set (user/userId/customerId) +
|
||||
* the X-Org-Id header, so a tenant can only ever read its OWN commerce ledger. This
|
||||
* spec proves that end-to-end against the LIVE proxy: two accounts in DIFFERENT orgs
|
||||
* each fetch `/billing/subscriptions` (and `/payment-methods`), and we assert the
|
||||
* two result sets are disjoint — neither tenant can see the other's rows.
|
||||
* each fetch `/v1/billing/subscriptions` (and `/v1/billing/methods`), and we assert
|
||||
* the two result sets are disjoint — neither tenant can see the other's rows.
|
||||
*
|
||||
* This is the regression guard for the IDOR RED found (the proxy previously pinned
|
||||
* only `?user=` while commerce filters subscriptions on `?userId=`, so subscriptions
|
||||
@@ -36,11 +36,12 @@ async function signIn(page: Page, email: string, password: string) {
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
}
|
||||
|
||||
/** Fetch a billing path through the same-origin DATA proxy (`/billing/v1/*`), as the
|
||||
* signed-in browser. (`/billing/<slug>` without `v1/` is a UI tab, served by the SPA.) */
|
||||
/** Fetch a billing path through the same-origin DATA proxy (`/v1/billing/*`), as the
|
||||
* signed-in browser. (`/billing/<slug>` is a UI tab, served by the SPA — it differs at
|
||||
* the FIRST path segment, so the two never collide.) */
|
||||
async function billing(page: Page, path: string): Promise<{ status: number; ids: string[] }> {
|
||||
return page.evaluate(async (p) => {
|
||||
const res = await fetch(`/billing/v1/${p}`, { credentials: 'include', headers: { Accept: 'application/json' } })
|
||||
const res = await fetch(`/v1/billing/${p}`, { credentials: 'include', headers: { Accept: 'application/json' } })
|
||||
let ids: string[] = []
|
||||
try {
|
||||
const body = await res.json()
|
||||
@@ -73,9 +74,9 @@ test.describe('billing is isolated per tenant through the proxy', () => {
|
||||
await signIn(pageB, B.email, B.password)
|
||||
|
||||
// `invoices` is included because its row ids drive the per-invoice PDF URL
|
||||
// (`/billing/v1/invoices/:id/pdf`) — proving the invoice list is tenant-isolated
|
||||
// (`/v1/billing/invoices/:id/pdf`) — proving the invoice list is tenant-isolated
|
||||
// proves a user can only ever build a PDF URL for their OWN org's invoices.
|
||||
for (const path of ['subscriptions', 'payment-methods', 'invoices']) {
|
||||
for (const path of ['subscriptions', 'methods', 'invoices']) {
|
||||
const a = await billing(pageA, path)
|
||||
const b = await billing(pageB, path)
|
||||
|
||||
|
||||
@@ -131,8 +131,8 @@ test.describe('Money/usage/o11y surface is fail-closed for anonymous (unauthenti
|
||||
'/v1/billing/balance',
|
||||
'/v1/billing/invoices',
|
||||
'/v1/billing/usage',
|
||||
'/v1/billing/payment-methods',
|
||||
'/v1/billing/spend-alerts',
|
||||
'/v1/billing/methods',
|
||||
'/v1/billing/alerts',
|
||||
'/v1/usage/summary',
|
||||
'/v1/get-cloud-usages',
|
||||
'/v1/o11y/observations',
|
||||
|
||||
@@ -40,7 +40,7 @@ const CANONICAL_IDS: string[] = JSON.parse(readFileSync(join(process.cwd(), 'e2e
|
||||
* are NOT registry ids — they must resolve via SLUG_ALIASES to a real module (never
|
||||
* a 404 blank). Auditing them here proves the alias map end-to-end against the real app.
|
||||
*/
|
||||
const ALIAS_SLUGS = ['traces', 'deploy', 'plans-pricing', 'wallets', 'model-catalog', 'fine-tuning', 'web-search', 'mlpipelines', 'kubeflow']
|
||||
const ALIAS_SLUGS = ['traces', 'deploy', 'plans-pricing', 'wallets', 'model-catalog', 'fine-tuning', 'web-search']
|
||||
const IDS: string[] = [...CANONICAL_IDS, ...ALIAS_SLUGS]
|
||||
|
||||
/** A global-admin (sees every surface) or a tenant customer (Dave/maxpower shape). */
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* Runs against a LOCAL server (BASE_URL=http://localhost:4000) with the whole network
|
||||
* mocked (same pattern as blank-audit): `/auth/session` → a global admin so the shell
|
||||
* mounts, `/v1/billing/spend-alerts` → real-shaped budget rows (org default + project
|
||||
* mounts, `/v1/billing/alerts` → real-shaped budget rows (org default + project
|
||||
* warn + service over + unlimited/rate-limit-only), everything else → an empty-ok
|
||||
* envelope.
|
||||
*
|
||||
@@ -39,7 +39,7 @@ const ACCOUNT = {
|
||||
signupApplication: 'hanzo-cloud',
|
||||
}
|
||||
|
||||
/** Real-shaped `/v1/billing/spend-alerts` rows — one per verdict/scope (threshold = cents). */
|
||||
/** Real-shaped `/v1/billing/alerts` rows — one per verdict/scope (threshold = cents). */
|
||||
const BUDGETS = [
|
||||
{ id: 'b1', title: 'Org monthly cap', threshold: 500000, currency: 'usd', project: '', service: '', enforce: true, softPct: 80, rateLimitRpm: 0, periodSpentCents: 312000, over: false, warn: false },
|
||||
{ id: 'b2', title: 'Inference budget', threshold: 200000, currency: 'usd', project: 'acme-prod', service: 'inference', enforce: false, softPct: 75, rateLimitRpm: 600, periodSpentCents: 186000, over: false, warn: true },
|
||||
@@ -61,8 +61,8 @@ async function mock(route: Route) {
|
||||
if (path.startsWith('/auth/')) {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) })
|
||||
}
|
||||
// The page under test — the real spend-alerts contract.
|
||||
if (path === '/v1/billing/spend-alerts') {
|
||||
// The page under test — the real alerts contract.
|
||||
if (path === '/v1/billing/alerts') {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(BUDGETS) })
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/**
|
||||
* e2e: brand-forward chrome + voice — mocked-network render proof.
|
||||
*
|
||||
* The Chrome wave: the big floating chat CIRCLE was removed; the assistant now opens
|
||||
* from the TOPBAR (a small brand-H "Chat with Hanzo" + a "Talk to Hanzo" mic), the
|
||||
* top-left SidebarBrand renders the org's own logo (white-label), and the Developers
|
||||
* dock is drag-resizable with a live "Create key". This spec proves all of it in a
|
||||
* browser.
|
||||
* The Chrome wave: the big floating chat CIRCLE was removed; the assistant opens from
|
||||
* ONE control — a floating bottom-right cluster (a brand-H "Ask Hanzo" + a "Talk to
|
||||
* Hanzo" mic, `AssistantFab`), NOT the topbar, which carries navigation and account
|
||||
* chrome only. The top-left SidebarBrand renders the org's own logo (white-label), and
|
||||
* the Developers dock is drag-resizable with a live "Create key". This spec proves all
|
||||
* of it in a browser.
|
||||
*
|
||||
* Same harness as workbench.spec (the closest sibling): a LOCAL server with the
|
||||
* network mocked. `primeSession` seeds the IAM-PKCE identity AND the first-run gates
|
||||
@@ -13,7 +14,7 @@
|
||||
* real-shaped ledger rows for the dock's Overview, `/v1/models` → a small catalog for
|
||||
* the assistant's model list; everything else → an empty-ok envelope.
|
||||
*
|
||||
* Voice gotcha: headless chromium ships NO webkitSpeechRecognition, so the topbar mic
|
||||
* Voice gotcha: headless chromium ships NO webkitSpeechRecognition, so the mic
|
||||
* (rendered only when `voiceSupported()`) would be absent for an environment reason,
|
||||
* not a code one. A tiny, inert Web Speech stub is injected BEFORE load
|
||||
* (`installVoiceStub`) so `voiceSupported()` is deterministically true and the mic
|
||||
@@ -107,20 +108,20 @@ function installVoiceStub(page: Page) {
|
||||
})
|
||||
}
|
||||
|
||||
/** Prime + navigate; the topbar brand-H is on EVERY viewport, so it is the mount signal. */
|
||||
/** Prime + navigate; the floating brand-H is on EVERY viewport, so it is the mount signal. */
|
||||
async function openHome(page: Page, waitForMount = true) {
|
||||
await installVoiceStub(page)
|
||||
await page.route('**/*', mock)
|
||||
await primeSession(page)
|
||||
await page.goto(`${BASE_URL}/`, { waitUntil: 'domcontentloaded' })
|
||||
if (waitForMount) {
|
||||
await expect(page.locator('[aria-label="Chat with Hanzo"]').first()).toBeVisible({ timeout: 20_000 })
|
||||
await expect(page.locator('[aria-label="Ask Hanzo"]').first()).toBeVisible({ timeout: 20_000 })
|
||||
}
|
||||
}
|
||||
|
||||
test.beforeAll(() => mkdirSync(SHOTS, { recursive: true }))
|
||||
|
||||
test('the floating circle is gone; the topbar carries chat + voice, the sidebar brand + docked assistant + Developers dock work', async ({ browser }) => {
|
||||
test('one floating control carries chat + voice; the topbar carries neither; sidebar brand + docked assistant + Developers dock work', async ({ browser }) => {
|
||||
// laptop (≥ lg 1024): the persistent sidebar, the Developers dock, and the docked
|
||||
// assistant column are all present (they are desktop-only concerns).
|
||||
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } })
|
||||
@@ -130,10 +131,18 @@ test('the floating circle is gone; the topbar carries chat + voice, the sidebar
|
||||
// 1. The OLD floating circle is GONE — the bubble that covered page content.
|
||||
await expect(page.locator('[aria-label="Open AI assistant"]')).toHaveCount(0)
|
||||
|
||||
// 2. Topbar: the small brand-H "Chat with Hanzo" AND the "Talk to Hanzo" mic, both
|
||||
// visible (the mic renders because the Web Speech stub makes voiceSupported() true).
|
||||
await expect(page.locator('[aria-label="Chat with Hanzo"]').first()).toBeVisible()
|
||||
await expect(page.locator('[aria-label="Talk to Hanzo"]').first()).toBeVisible()
|
||||
// 2. ONE floating control, bottom-right: the brand-H "Ask Hanzo" AND the "Talk to
|
||||
// Hanzo" mic (the mic renders because the Web Speech stub makes voiceSupported()
|
||||
// true) — and the topbar carries no assistant control at all. The two used to live
|
||||
// up there beside the search box, which put the assistant in a third place.
|
||||
// Scoped to the control itself: the assistant's own composer carries a mic with
|
||||
// the same label, mounted-but-hidden until the panel opens, so a bare
|
||||
// `[aria-label="Talk to Hanzo"]` matches that one first and reads "hidden".
|
||||
const fab = page.getByTestId('assistant-fab')
|
||||
await expect(fab.locator('[aria-label="Ask Hanzo"]')).toBeVisible()
|
||||
await expect(fab.locator('[aria-label="Talk to Hanzo"]')).toBeVisible()
|
||||
await expect(page.locator('.hz-topbar [aria-label="Ask Hanzo"]')).toHaveCount(0)
|
||||
await expect(page.locator('.hz-topbar [aria-label="Talk to Hanzo"]')).toHaveCount(0)
|
||||
|
||||
// 3. The top-left SidebarBrand renders the org logo / BrandMark (an <img> or <svg>).
|
||||
const brand = page.locator('[aria-label*="right-click for brand menu"]').first()
|
||||
@@ -147,15 +156,18 @@ test('the floating circle is gone; the topbar carries chat + voice, the sidebar
|
||||
await expect(page.locator('[title="Drag to resize"]').first()).toBeVisible()
|
||||
await expect(page.locator('text=Create key').first()).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
// 5. Clicking "Chat with Hanzo" opens the DOCKED assistant surface — the "Assistant"
|
||||
// header + its Undock control appear (uniquely the docked panel at lg+).
|
||||
await page.locator('[aria-label="Chat with Hanzo"]').first().click()
|
||||
// 5. Clicking "Ask Hanzo" opens the DOCKED assistant surface — the "Assistant"
|
||||
// header + its Undock control appear (uniquely the docked panel at lg+). The
|
||||
// floating control then steps aside: at lg+ the docked column IS the assistant,
|
||||
// so keeping a button to open it on top of itself would be a second way in.
|
||||
await fab.locator('[aria-label="Ask Hanzo"]').click()
|
||||
await expect(page.locator('[aria-label^="Undock"]').first()).toBeVisible({ timeout: 15_000 })
|
||||
await expect(page.getByText('Assistant', { exact: true }).filter({ visible: true }).first()).toBeVisible()
|
||||
await expect(page.locator('[aria-label="Ask Hanzo"]')).toHaveCount(0)
|
||||
|
||||
// 6. The mic is wired: "Talk to Hanzo" → startVoice → the conversation opens the
|
||||
// recognition (voiceSignal effect → voice.start() → the stub records the call).
|
||||
await page.locator('[aria-label="Talk to Hanzo"]').first().click()
|
||||
// 6. The mic is wired: "Talk to Hanzo" (now the open conversation's own) → the
|
||||
// recognition opens (voice.start() → the stub records the call).
|
||||
await page.locator('[aria-label="Talk to Hanzo"]').filter({ visible: true }).first().click()
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => (window as unknown as { __voiceStarted?: number }).__voiceStarted ?? 0), { timeout: 15_000 })
|
||||
.toBeGreaterThan(0)
|
||||
@@ -179,7 +191,7 @@ test('renders across breakpoints with no horizontal body scroll on a phone; scre
|
||||
// (real render, or an honest blank shell if the sandbox can't paint the SPA).
|
||||
await openHome(page, false)
|
||||
await page
|
||||
.locator('[aria-label="Chat with Hanzo"]')
|
||||
.locator('[aria-label="Ask Hanzo"]')
|
||||
.first()
|
||||
.waitFor({ state: 'visible', timeout: 20_000 })
|
||||
.catch(() => {})
|
||||
|
||||
+10
-10
@@ -14,7 +14,7 @@
|
||||
*
|
||||
* Run:
|
||||
* HANZO_PASSWORD=xxx pnpm e2e
|
||||
* HANZO_PASSWORD=xxx HANZO_API_KEY=hk-xxx pnpm e2e
|
||||
* HANZO_PASSWORD=xxx HANZO_API_KEY=sk-xxx pnpm e2e
|
||||
*/
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
@@ -151,15 +151,15 @@ test.describe('Hanzo Cloud Console e2e', () => {
|
||||
|
||||
if (needsCreate) {
|
||||
await createBtn.click()
|
||||
// One-time reveal card with the hk- key
|
||||
await expect(page.locator('text=/hk-/')).toBeVisible({ timeout: 25_000 })
|
||||
// One-time reveal card with the sk- key
|
||||
await expect(page.locator('text=/sk-/')).toBeVisible({ timeout: 25_000 })
|
||||
await expect(page.locator('text=/shown only once/i')).toBeVisible()
|
||||
await expect(page.locator('button:has-text("Copy")')).toBeVisible()
|
||||
console.log('✓ API key created (hk- one-time reveal shown)')
|
||||
console.log('✓ API key created (sk- one-time reveal shown)')
|
||||
} else {
|
||||
// Key already exists
|
||||
await expect(hasKey).toBeVisible({ timeout: 10_000 })
|
||||
await expect(page.locator('text=/hk-…|hk-[A-Za-z0-9]{3,}/i')).toBeVisible({ timeout: 5_000 })
|
||||
await expect(page.locator('text=/sk-…|sk-[A-Za-z0-9]{3,}/i')).toBeVisible({ timeout: 5_000 })
|
||||
console.log('✓ API key already exists (prefix shown)')
|
||||
}
|
||||
})
|
||||
@@ -183,19 +183,19 @@ test.describe('Hanzo Cloud Console e2e', () => {
|
||||
} else if (await createBtn.isVisible({ timeout: 1_000 }).catch(() => false)) {
|
||||
await createBtn.click()
|
||||
}
|
||||
await expect(page.locator('text=/hk-/')).toBeVisible({ timeout: 25_000 })
|
||||
await expect(page.locator('text=/sk-/')).toBeVisible({ timeout: 25_000 })
|
||||
|
||||
// Grab the FULL key from the one-time reveal — never the masked display
|
||||
// (the account card shows `hk-2f18…` with an ellipsis, which is not a
|
||||
// usable credential). Match only a full hk- token (no `…`/`...`).
|
||||
const fullKey = /hk-[A-Za-z0-9._-]{16,}/
|
||||
// (the account card shows `sk-2f18…` with an ellipsis, which is not a
|
||||
// usable credential). Match only a full sk- token (no `…`/`...`).
|
||||
const fullKey = /sk-[A-Za-z0-9._-]{16,}/
|
||||
const keyEl = page.locator('[style*="monospace"]').filter({ hasText: fullKey }).first()
|
||||
apiKey = (((await keyEl.textContent().catch(() => '')) ?? '').match(fullKey) ?? [''])[0]
|
||||
if (!apiKey) {
|
||||
const m = ((await page.textContent('body')) ?? '').match(fullKey)
|
||||
apiKey = m ? m[0] : ''
|
||||
}
|
||||
expect(apiKey, 'Could not extract hk- key from page').toMatch(/^hk-/)
|
||||
expect(apiKey, 'Could not extract sk- key from page').toMatch(/^sk-/)
|
||||
console.log(`✓ Extracted key prefix: ${apiKey.slice(0, 11)}…`)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,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('')
|
||||
})
|
||||
})
|
||||
@@ -134,7 +134,7 @@ test.describe('LIVE v8.4.15 — (a) business board + (c) billing dimension', ()
|
||||
|
||||
test('(c) billing Reports renders the cost-dimension surface', async ({ page }) => {
|
||||
await signIn(page, CONSOLE)
|
||||
// v8.4.16: the data proxy moved to /billing/v1/*, so /billing/reports now falls
|
||||
// The data proxy lives at /v1/billing/*, so /billing/reports now falls
|
||||
// through to the SPA (was shadowed by the /billing/[...path] proxy → raw JSON).
|
||||
// A hard deep-link must render the Reports UI, not a proxy "not found".
|
||||
await page.goto(`${CONSOLE}/billing/reports`, { waitUntil: 'domcontentloaded' })
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Onboarding — Continue never moves, and Skip is always reachable.
|
||||
*
|
||||
* The complaint this pins: the Continue button landed at a different height on
|
||||
* every step, so a user clicking through had to re-aim each time. StepActions was
|
||||
* the LAST CHILD of a flex column, so its y was whatever the step's content
|
||||
* happened to add up to. It is now a SLOT on StepShell above a content area with
|
||||
* a reserved height — one placement, decided in one place.
|
||||
*
|
||||
* This is a GEOMETRY assertion on purpose. The JSX move is invisible to a unit
|
||||
* test (both shapes render the same button with the same label); only the painted
|
||||
* box says whether the thing the user complained about is fixed.
|
||||
*/
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import { primeSession } from './_session'
|
||||
|
||||
/** Every step whose footer must line up, in flow order. */
|
||||
const STEPS = ['Secure your account', 'Data & consent', 'Your workspace', 'Free trial credits', 'AI access']
|
||||
|
||||
/** The y of the actions row, in page coordinates. */
|
||||
async function actionsY(page: Page): Promise<number> {
|
||||
const row = page.getByTestId('onboarding-actions')
|
||||
await expect(row).toBeVisible()
|
||||
const box = await row.boundingBox()
|
||||
if (!box) throw new Error('actions row has no box')
|
||||
return Math.round(box.y)
|
||||
}
|
||||
|
||||
/** Advance past the current step, preferring Skip so the flow stays clickable. */
|
||||
async function advance(page: Page): Promise<void> {
|
||||
const row = page.getByTestId('onboarding-actions')
|
||||
const skip = row.getByRole('button', { name: /^(Skip|Keep the default)/ })
|
||||
if (await skip.count()) {
|
||||
await skip.first().click()
|
||||
return
|
||||
}
|
||||
// Consent has no Skip by design (accepting Terms is not optional), so tick the
|
||||
// agreement and use Continue. Tick only if Continue is still disabled — a caller
|
||||
// may already have ticked it, and toggling twice turns it back OFF.
|
||||
const cont = row.getByRole('button', { name: /Continue/ })
|
||||
if (await cont.isDisabled()) {
|
||||
const agree = page.locator('[role="switch"], input[type="checkbox"]').first()
|
||||
if (await agree.count()) await agree.click()
|
||||
}
|
||||
await cont.click()
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Anything the steps reach for answers empty — they are best-effort and must
|
||||
// still render. Registered BEFORE primeSession so its handlers win.
|
||||
await page.route('**/v1/**', (r) => r.fulfill({ status: 200, contentType: 'application/json', body: '{}' }))
|
||||
await primeSession(page)
|
||||
// primeSession marks onboarding DONE so other specs can reach the app. This
|
||||
// spec is about the wizard, so un-mark it (the tour gate stays seeded).
|
||||
await page.addInitScript(() => {
|
||||
for (const k of Object.keys(localStorage)) if (k.startsWith('hz_onboarding_done:')) localStorage.removeItem(k)
|
||||
})
|
||||
})
|
||||
|
||||
test('Continue lands at the same height on every step', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
|
||||
const seen: { step: string; y: number }[] = []
|
||||
for (const step of STEPS) {
|
||||
await expect(page.getByTestId('onboarding-step-title')).toHaveText(step, { timeout: 15_000 })
|
||||
seen.push({ step, y: await actionsY(page) })
|
||||
await advance(page)
|
||||
}
|
||||
|
||||
const ys = seen.map((s) => s.y)
|
||||
const spread = Math.max(...ys) - Math.min(...ys)
|
||||
expect(
|
||||
spread,
|
||||
`Continue moved ${spread}px across steps — ${seen.map((s) => `${s.step}:${s.y}`).join(' ')}`,
|
||||
).toBeLessThanOrEqual(2)
|
||||
})
|
||||
|
||||
test('every step always offers an enabled way forward', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
|
||||
// The real invariant behind "skip so easy to click through": on every step there
|
||||
// is ALWAYS at least one enabled control that advances you — Skip where there is
|
||||
// something to decline, Continue where there is not. Credits swaps between the
|
||||
// two on purpose (Skip appears only when a card could be added; otherwise
|
||||
// Continue carries you), so asserting a literal "Skip" everywhere would be
|
||||
// asserting the wrong thing. Being STUCK is the defect.
|
||||
for (const step of STEPS) {
|
||||
await expect(page.getByTestId('onboarding-step-title')).toHaveText(step, { timeout: 15_000 })
|
||||
|
||||
// Consent gates Continue on accepting the Terms — not optional, so tick it
|
||||
// first and then assert the way forward exists.
|
||||
if (step === 'Data & consent') {
|
||||
const agree = page.locator('[role="switch"], input[type="checkbox"]').first()
|
||||
if (await agree.count()) await agree.click()
|
||||
}
|
||||
|
||||
const row = page.getByTestId('onboarding-actions')
|
||||
const forward = row.getByRole('button', { name: /^(Skip|Keep the default|Continue)/ })
|
||||
const n = await forward.count()
|
||||
expect(n, `${step} renders no forward control`).toBeGreaterThan(0)
|
||||
|
||||
let usable = 0
|
||||
for (let i = 0; i < n; i++) {
|
||||
const b = forward.nth(i)
|
||||
if (await b.isDisabled()) continue
|
||||
const box = await b.boundingBox()
|
||||
if (!box || box.height < 24) continue
|
||||
const hit = await b.evaluate((el) => {
|
||||
const r = el.getBoundingClientRect()
|
||||
return el.contains(document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2))
|
||||
})
|
||||
if (hit) usable++
|
||||
}
|
||||
expect(usable, `${step} has no enabled, clickable way forward`).toBeGreaterThan(0)
|
||||
|
||||
await advance(page)
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -43,7 +43,7 @@ async function mock(route: Route) {
|
||||
const req = route.request()
|
||||
if (req.resourceType() === 'document') return route.continue()
|
||||
const url = new URL(req.url())
|
||||
if (/\/v1\/admin\/block-storage(\/|$|\?)/.test(url.pathname)) {
|
||||
if (/\/v1\/admin\/volumes(\/|$|\?)/.test(url.pathname)) {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(SNAPSHOT) })
|
||||
}
|
||||
const sameOrigin = url.origin === new URL(BASE_URL).origin
|
||||
|
||||
+50
-10
@@ -1,4 +1,4 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs'
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
@@ -7,12 +7,16 @@ import { resolveBuildId, readGitSha } from './src/config/build-id.mjs'
|
||||
/**
|
||||
* Hanzo Cloud Console — Next.js config.
|
||||
*
|
||||
* Hanzo GUI is consumed at runtime (no optimizing compiler): the published
|
||||
* `@hanzogui/next-plugin` has a broken npm dependency (`hanzogui-loader@7.3.0`
|
||||
* is unpublished; the available fork renames its exports), so we transpile the
|
||||
* Gui ESM packages with Next's built-in `transpilePackages` and let
|
||||
* `GuiProvider` inject CSS at runtime. Gui is designed to work this way — the
|
||||
* compiler is an optimization, not a requirement.
|
||||
* Hanzo GUI is consumed at runtime (no optimizing compiler): we transpile the Gui
|
||||
* ESM packages with Next's built-in `transpilePackages` and let `GuiProvider`
|
||||
* inject CSS at runtime. Gui is designed to work this way — the compiler is an
|
||||
* optimization, not a requirement.
|
||||
*
|
||||
* (The original reason to avoid `@hanzogui/next-plugin` no longer holds: the loader
|
||||
* it depends on was unpublished at 7.3.0, but 8.x renamed it to `@hanzogui/loader`
|
||||
* and both now ship. Adopting the compiler is therefore a live option — as an
|
||||
* optimization to measure, not a correctness fix, so it is deliberately not bundled
|
||||
* into the 8.x convergence.)
|
||||
*
|
||||
* `react-native` is aliased to `react-native-web` for the browser.
|
||||
*
|
||||
@@ -46,6 +50,9 @@ function guiPackages() {
|
||||
return ['@hanzo/gui', '@hanzo/iam-js-sdk', '@hanzo/dash', '@hanzo/data', '@hanzo/canvas', '@hanzo/finance-ui', '@hanzo/usage', '@hanzo/ui', 'react-native-web', ...scoped]
|
||||
}
|
||||
|
||||
/** A `@hanzogui/<pkg>/<subpath>/index.{js,cjs}` metro-compat shim (see `webpack()`). */
|
||||
const GUI_SUBPATH_SHIM = /\/@hanzogui\/([^/]+)\/([^/]+)\/index\.c?js$/
|
||||
|
||||
/**
|
||||
* Same-origin `/v1/*` — ZERO client-visible prefix (the CTO contract: "no prefix
|
||||
* before /v1/ in any API call"). The browser ALWAYS calls its OWN origin at a clean
|
||||
@@ -97,10 +104,10 @@ const AI_V1_HEADS = ['models', 'chat', 'embeddings', 'rerank', 'audio', 'images'
|
||||
// (`providers/toggle`, `providers/primary`) both match the `/:path*` rewrite below,
|
||||
// which is method-agnostic (Next matches on the URL), so POST is covered without a
|
||||
// second entry. Keep this in sync with `admin-aggregate.ts` ADMIN_AGGREGATE_HEADS.
|
||||
const ADMIN_V1_HEADS = ['overview', 'usage', 'orgs', 'audit', 'products', 'finance', 'compute', 'o11y', 'providers', 'customers', 'revenue', 'analytics', 'enablement', 'grants', 'referrals', 'affiliates', 'authors', 'treasury', 'services', 'promos', 'spend-caps', 'block-storage']
|
||||
const ADMIN_V1_HEADS = ['overview', 'usage', 'orgs', 'audit', 'products', 'finance', 'compute', 'o11y', 'providers', 'customers', 'revenue', 'analytics', 'enablement', 'grants', 'referrals', 'affiliates', 'authors', 'treasury', 'services', 'promos', 'caps', 'volumes']
|
||||
/**
|
||||
* DEV-ONLY: proxy the client's direct-cloud `/v1/{iam,o11y}/*` calls (get-account,
|
||||
* annotation-queues/users) to a real cloud backend so `npm run dev` renders the
|
||||
* reviews/users) to a real cloud backend so `npm run dev` renders the
|
||||
* authenticated shell locally. Enabled ONLY when `DEV_CLOUD_ORIGIN` is set (never in
|
||||
* the built image), so production is unchanged — there the console host's edge routes
|
||||
* `/v1` to the console, whose `/v1` catch-all forwards to cloud-api. The request cookie
|
||||
@@ -206,7 +213,40 @@ const nextConfig = {
|
||||
experimental: {
|
||||
esmExternals: true,
|
||||
},
|
||||
webpack(config) {
|
||||
webpack(config, { webpack }) {
|
||||
// `@hanzogui/*` 8.x ships legacy metro-compat subpath DIRECTORIES (`config/v5/`,
|
||||
// `themes/v5/`, `shorthands/v5/`, …) beside the `exports` map that already names
|
||||
// the real entry. Each holds a CommonJS `index.js` — inside a `"type": "module"`
|
||||
// package. Whatever resolves the directory therefore parses that file as ESM: the
|
||||
// `export *` chain goes opaque ("'defaultConfig' is not exported from
|
||||
// '@hanzogui/config/v5'") and its bare `require('../dist/cjs/v5.cjs')` survives
|
||||
// into the server chunk, where it MODULE_NOT_FOUNDs at prerender (the require is
|
||||
// relative to `.next/server/chunks/`, not to the package).
|
||||
//
|
||||
// So redirect any such shim to the ESM build sitting beside it. Pattern-based, on
|
||||
// the RESOLVED file, so it holds however the request got there — and costs nothing
|
||||
// the day the shims stop shipping.
|
||||
config.plugins.push(
|
||||
new webpack.NormalModuleReplacementPlugin(GUI_SUBPATH_SHIM, (data) => {
|
||||
const resource = data.createData?.resource
|
||||
if (!resource) return
|
||||
const shim = GUI_SUBPATH_SHIM.exec(resource)
|
||||
if (!shim) return
|
||||
const esm = `${resource.slice(0, shim.index)}/@hanzogui/${shim[1]}/dist/esm/${shim[2]}.mjs`
|
||||
if (!existsSync(esm)) return
|
||||
// The module's CONTEXT must move with it, or its own relative imports
|
||||
// (`./v5-base.mjs`) keep resolving against the shim directory.
|
||||
data.createData.resource = esm
|
||||
data.createData.userRequest = esm
|
||||
data.createData.context = dirname(esm)
|
||||
data.context = dirname(esm)
|
||||
}),
|
||||
)
|
||||
// @hanzo/ui is consumed from SOURCE via a workspace link. Keep the symlinked path
|
||||
// so its own imports (@hanzo/gui, @hanzogui/*) walk up into the CONSOLE's
|
||||
// node_modules — one Tamagui instance, as the tsconfig `paths` already pin for
|
||||
// types. Resolving the realpath would load a second copy and break theme context.
|
||||
config.resolve.symlinks = false
|
||||
config.resolve.alias = {
|
||||
...config.resolve.alias,
|
||||
'react-native$': 'react-native-web',
|
||||
|
||||
Generated
+12019
File diff suppressed because it is too large
Load Diff
+22
-21
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "@hanzo/console",
|
||||
"version": "8.5.33",
|
||||
"version": "8.5.35",
|
||||
"packageManager": "pnpm@11.17.0",
|
||||
"private": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"author": "Hanzo AI <dev@hanzo.ai>",
|
||||
"description": "Hanzo Cloud Console — unified admin console for Hanzo Cloud and all cloud products.",
|
||||
"description": "Hanzo Cloud Console \u2014 unified admin console for Hanzo Cloud and all cloud products.",
|
||||
"scripts": {
|
||||
"dev": "next dev -p 4000",
|
||||
"build": "next build",
|
||||
@@ -14,26 +14,27 @@
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"e2e": "playwright test",
|
||||
"e2e:headed": "playwright test --headed",
|
||||
"postinstall": "patch-package"
|
||||
"e2e:headed": "playwright test --headed"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hanzo/brand": "^1.4.0",
|
||||
"@hanzo/canvas": "^0.1.0",
|
||||
"@hanzo/dash": "0.3.0",
|
||||
"@hanzo/data": "^1.2.0",
|
||||
"@hanzo/event": "^0.3.4",
|
||||
"@hanzo/finance-ui": "0.1.1",
|
||||
"@hanzo/gui": "7.3.0",
|
||||
"@hanzo/iam": "^0.21.2",
|
||||
"@hanzo/logo": "^1.0.13",
|
||||
"@hanzo/ui": "^8.0.20",
|
||||
"@hanzo/brand": "^1.4.5",
|
||||
"@hanzo/canvas": "^0.2.1",
|
||||
"@hanzo/dash": "^0.3.0",
|
||||
"@hanzo/data": "^1.2.2",
|
||||
"@hanzo/design": "^0.4.6",
|
||||
"@hanzo/event": "^0.3.8",
|
||||
"@hanzo/finance-ui": "~0.1.1",
|
||||
"@hanzo/gui": "^8.0.0",
|
||||
"@hanzo/iam": "^0.21.6",
|
||||
"@hanzo/logo": "^1.0.14",
|
||||
"@hanzo/ui": "^8.0.38",
|
||||
"@hanzo/usage": "^0.1.6",
|
||||
"@hanzogui/config": "7.3.0",
|
||||
"@hanzogui/core": "7.3.0",
|
||||
"@hanzogui/lucide-icons-2": "7.3.0",
|
||||
"@hanzogui/next-theme": "7.3.0",
|
||||
"@hanzogui/shell": "^7.6.3",
|
||||
"@hanzogui/config": "^8.0.0",
|
||||
"@hanzogui/core": "^8.0.0",
|
||||
"@hanzogui/lucide-icons-2": "^8.0.0",
|
||||
"@hanzogui/next-theme": "^8.0.0",
|
||||
"@hanzogui/telemetry": "^8.0.0",
|
||||
"@hanzogui/shell": "^8.1.1",
|
||||
"@lexical/html": "0.46.0",
|
||||
"@lexical/link": "0.46.0",
|
||||
"@lexical/list": "0.46.0",
|
||||
@@ -53,6 +54,7 @@
|
||||
"qrcode.react": "4.2.0",
|
||||
"react": "19.2.7",
|
||||
"react-dom": "19.2.7",
|
||||
"react-native-svg": "15.15.5",
|
||||
"react-native-web": "0.21.2",
|
||||
"superjson": "2.2.2"
|
||||
},
|
||||
@@ -61,7 +63,6 @@
|
||||
"@types/node": "22.20.0",
|
||||
"@types/react": "19.2.17",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"patch-package": "^8.0.0",
|
||||
"react-native": "0.83.9",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "3.2.4"
|
||||
|
||||
Generated
+1303
-5434
File diff suppressed because it is too large
Load Diff
@@ -195,8 +195,8 @@ try {
|
||||
stdio: 'inherit',
|
||||
// CONSOLE_EMBED gates the server-side build transforms; NEXT_PUBLIC_CONSOLE_EMBED
|
||||
// is inlined into the CLIENT bundle so runtime code (lib/embed.ts → IS_EMBED) can
|
||||
// skip the BFF-only session probes (/auth/refresh|session, /billing welcome) that
|
||||
// don't exist in this static, server-less deployment.
|
||||
// skip the BFF-only session probes (/auth/refresh|session) that don't exist in
|
||||
// this static, server-less deployment.
|
||||
env: { ...process.env, CONSOLE_EMBED: '1', NEXT_PUBLIC_CONSOLE_EMBED: '1' },
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Asks the RENDERER which props @hanzo/gui 8 actually honors.
|
||||
*
|
||||
* gui accepts any prop and drops the ones it does not know, so a gui-7 `tag="a"`
|
||||
* type-checks, builds, and ships a <div>: the link is inert and nothing anywhere says
|
||||
* so. A green build cannot answer this; only the rendered markup can. Every rule in
|
||||
* `src/lib/gui8-props.ts` was verified here before it was written down.
|
||||
*
|
||||
* gui injects its stylesheet as a leading <style>, so read the host element from the
|
||||
* marked child — never from the first tag in the string.
|
||||
*
|
||||
* node scripts/gui-prop-probe.mjs (slow: it resolves ~184 unbundled ESM packages)
|
||||
*/
|
||||
import React from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { defaultConfig } from '@hanzogui/config/v5'
|
||||
import { XStack, Text, createGui, GuiProvider } from '@hanzo/gui'
|
||||
|
||||
const config = createGui(defaultConfig)
|
||||
const render = (el) =>
|
||||
renderToStaticMarkup(React.createElement(GuiProvider, { config, defaultTheme: 'dark' }, el))
|
||||
|
||||
/** The element carrying our marker id — not gui's injected <style>. */
|
||||
const marked = (markup) => markup.match(/<([a-z]+)[^>]*\bid="probe"[^>]*>/)?.[0] ?? ''
|
||||
const hostOf = (markup) => marked(markup).match(/^<([a-z]+)/)?.[1] ?? '(not found)'
|
||||
|
||||
const probe = (label, Comp, props) => {
|
||||
const markup = render(React.createElement(Comp, { id: 'probe', ...props }, 'x'))
|
||||
console.log(`${label.padEnd(30)} -> ${marked(markup) || '(not found)'}`)
|
||||
return { host: hostOf(markup), markup }
|
||||
}
|
||||
|
||||
console.log('--- host element: tag (gui 7) vs render (gui 8) ---')
|
||||
const withTag = probe('tag="a"', XStack, { tag: 'a', href: 'https://hanzo.ai' })
|
||||
const withRender = probe('render="a"', XStack, { render: 'a', href: 'https://hanzo.ai' })
|
||||
|
||||
console.log('\n--- style props that silently drop or mis-unit ---')
|
||||
probe('lineHeight={1.1} (prop)', Text, { lineHeight: 1.1 })
|
||||
probe('style lineHeight: 1.1 (ratio)', Text, { style: { lineHeight: 1.1 } })
|
||||
probe("style lineHeight: '1.1' (string)", Text, { style: { lineHeight: '1.1' } })
|
||||
probe('letterSpacing="-0.02em"', Text, { letterSpacing: '-0.02em' })
|
||||
probe('animation="quick"', XStack, { animation: 'quick' })
|
||||
probe('$sm={{...}}', XStack, { $sm: { bg: '$red10' } })
|
||||
probe('$gtSm={{...}}', XStack, { $gtSm: { bg: '$red10' } })
|
||||
|
||||
console.log()
|
||||
console.log(withTag.host === 'a' ? 'tag WORKS' : 'tag IS SILENTLY DROPPED')
|
||||
console.log(withRender.host === 'a' ? 'render WORKS' : 'render IS SILENTLY DROPPED')
|
||||
@@ -1,29 +1,22 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* The account control — who you are, which organization you are acting in, what
|
||||
* you have left to spend, and the way out. ONE control, at the foot of the rail.
|
||||
* The account control — WHO you are: identity, your team, your personal
|
||||
* settings, what you have left to spend, and the way out. ONE control, at the
|
||||
* foot of the rail.
|
||||
*
|
||||
* It deliberately does NOT switch tenant. Org and project are one question —
|
||||
* WHERE you are — and they are answered together by `ContextSwitcher` at the
|
||||
* top-left, beside the tenant's own mark. Handing this menu an `orgState` too
|
||||
* would put the org in two corners again, which is the exact confusion the
|
||||
* condensed switcher removes. The cross-tenant reach, the admin-gated org list
|
||||
* and the single `org-scope.switchOrg` money seam all moved there intact; there
|
||||
* is still exactly one org switch in the app.
|
||||
*
|
||||
* It is `@hanzo/iam`'s `UserMenu`, the same component hanzo.chat mounts, so the
|
||||
* identity, the switcher and the behaviour (click-away, Escape, close-before-
|
||||
* navigate, never a raw uuid) are shared rather than rebuilt. This file is the
|
||||
* ADAPTER — everything the console knows that the SDK does not:
|
||||
*
|
||||
* - ORG REACH. `useOrganizations()` reads the caller's memberships off the token
|
||||
* and cannot express what an admin console does: enter ANY tenant. So the
|
||||
* switcher is handed `findOrgs`, backed by the console's existing lazy,
|
||||
* server-paged cross-tenant list (`IamAdminApi.organizations` through the
|
||||
* gated `/admin/iam` proxy) — the same source the full-page org picker uses.
|
||||
* A regular user never fires it: they see their own org, synthesized from the
|
||||
* session, exactly as before. Nothing is fabricated, and nobody's reach widens.
|
||||
*
|
||||
* - MONEY. Switching goes through `org-scope.switchOrg` — the console's existing
|
||||
* switch, passed by reference, not reimplemented. It persists the scope and
|
||||
* reloads so every module refetches under the new `X-Org-Id`. That one seam is
|
||||
* where tenant scoping and its billing attribution already live; this file adds
|
||||
* no second switch, no header of its own, and no billing call, so the rule about
|
||||
* which ledger a masquerading admin's spend lands on is exactly where it was.
|
||||
* `org-state.test.ts` pins the identity so a second switch cannot creep in.
|
||||
* identity and the behaviour (click-away, Escape, close-before-navigate, never a
|
||||
* raw uuid) are shared rather than rebuilt. This file is the ADAPTER —
|
||||
* everything the console knows that the SDK does not:
|
||||
*
|
||||
* - THEME. The console themes through `@hanzogui/next-theme` (which drives the
|
||||
* Gui tree). That is adapted into the menu's shape rather than mounting IAM's
|
||||
@@ -32,45 +25,19 @@
|
||||
* - BRAND. The strip at the foot wears THIS host's brand. Passing nothing would
|
||||
* paint a Hanzo mark on a Lux or Zoo console.
|
||||
*/
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { UserMenu, type OrgState, type UserTheme } from '@hanzo/iam/react'
|
||||
import { useMemo } from 'react'
|
||||
import { UserMenu, type UserTheme } from '@hanzo/iam/react'
|
||||
import { useThemeSetting } from '@hanzogui/next-theme'
|
||||
|
||||
import { config } from '~/config'
|
||||
import { adminOrgState, scopedOrgRow } from '~/lib/account/org-state'
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
import { useIsSuperAdmin } from '~/lib/auth/admin'
|
||||
import { IamAdminApi, type Organization } from '~/lib/api'
|
||||
import { ORG_PAGE_SIZE, orgQuery } from '~/lib/org-list'
|
||||
import { currentOrg, leaveOrg, switchOrg } from '~/lib/org-scope'
|
||||
import { useCloudBalance, spendableCents } from '~/lib/billing/live-balance'
|
||||
|
||||
export function AccountMenu() {
|
||||
const { account, signOut } = useSession()
|
||||
// The cross-tenant list is admin-gated at the proxy; a regular user would 403 it,
|
||||
// so they are never asked to. Their own org is the honest answer.
|
||||
const isSuperAdmin = useIsSuperAdmin()
|
||||
const { balance } = useCloudBalance()
|
||||
const { current, resolvedTheme, set } = useThemeSetting()
|
||||
|
||||
const scoped = currentOrg()
|
||||
|
||||
const findOrgs = useCallback(
|
||||
async (query: string): Promise<Organization[]> => {
|
||||
if (!isSuperAdmin) return scopedOrgRow(scoped)
|
||||
const res = await IamAdminApi.organizations(orgQuery(0, query, ORG_PAGE_SIZE))
|
||||
return res.rows ?? []
|
||||
},
|
||||
[isSuperAdmin, scoped],
|
||||
)
|
||||
|
||||
// The console's own switch, by reference: persist the scope, reload, refetch
|
||||
// under the new X-Org-Id. Pinned in `org-state.test.ts`.
|
||||
const orgState: OrgState = useMemo(
|
||||
() => adminOrgState({ scoped, findOrgs, switchOrg }),
|
||||
[scoped, findOrgs],
|
||||
)
|
||||
|
||||
// `system` is a real choice, and the console's provider already understands it.
|
||||
const theme: UserTheme = useMemo(
|
||||
() => ({
|
||||
@@ -98,7 +65,6 @@ export function AccountMenu() {
|
||||
isAuthenticated
|
||||
isLoading={false}
|
||||
onSignOut={() => void signOut()}
|
||||
orgState={orgState}
|
||||
theme={theme}
|
||||
settingsUrl="/profile"
|
||||
usageUrl="/billing"
|
||||
@@ -106,12 +72,10 @@ export function AccountMenu() {
|
||||
// Only shown when the backend actually reported a balance — never a fabricated $0.
|
||||
balance={cents === null ? undefined : { amountUsd: cents / 100, topUpUrl: config.payUrl }}
|
||||
items={[
|
||||
{
|
||||
label: 'All organizations',
|
||||
// De-scope back to the full-page picker, where an org is entered — and
|
||||
// where a new one is created. The dropdown never grew its own form.
|
||||
onSelect: () => leaveOrg(),
|
||||
},
|
||||
// Your people, beside your own settings — the other half of "who am I".
|
||||
// Choosing a DIFFERENT tenant is a different question and lives in the
|
||||
// top-left context switcher, so this menu never re-scopes the console.
|
||||
{ label: 'Team', href: '/team' },
|
||||
{ label: 'Documentation', href: config.docsUrl, external: true, separatorBefore: true },
|
||||
]}
|
||||
brand={{ name: config.brandName }}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* All products — the directory where you curate your sidebar. Every Hanzo product
|
||||
* is always available on demand; this panel lists the FULL catalog the viewer may
|
||||
* see (brand-scoped, admin surfaces gated), grouped by category, each row with a
|
||||
* PIN toggle that promotes/removes it from the sidebar's Pinned quick-access section
|
||||
* (via `usePins`). Rendered in the shared DetailPane (opened from the sidebar's
|
||||
* "All products" row).
|
||||
* All products — the directory of every Hanzo app, where you OPEN one and where you
|
||||
* curate your sidebar. Every product is always available on demand; this panel lists
|
||||
* the FULL catalog the viewer may see (brand-scoped, admin surfaces gated), grouped
|
||||
* by category. A row OPENS its app; the pin toggle beside it promotes/removes it from
|
||||
* the sidebar's Pinned quick-access section (via `usePins`). Rendered in the shared
|
||||
* DetailPane (opened from the sidebar's "All products" row).
|
||||
*
|
||||
* Honest by construction: pinning is instant + optimistic (persisted through the
|
||||
* account preferences store — no async error state). Products with REAL org usage
|
||||
@@ -15,12 +15,15 @@
|
||||
* the badges/filter degrade away — never a fabricated "in use".
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button, Input, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Activity, Plus, Search, Star } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { visibleCatalogByCategory, type CatalogEntry, type ProductIcon } from '~/lib/products/registry'
|
||||
import { useIsSuperAdmin } from '~/lib/auth/admin'
|
||||
import { usePins, useProductColors } from '~/lib/products/pins'
|
||||
import { openProduct } from '~/lib/products/open'
|
||||
import { useDetailPane } from '~/components/DetailPane'
|
||||
import { fetchUsageRecords } from '~/lib/api/aimetrics'
|
||||
import { inUseProductIds } from '~/lib/products/product-usage'
|
||||
import { asColor } from '~/components/ui/color'
|
||||
@@ -41,23 +44,51 @@ function InUseBadge() {
|
||||
)
|
||||
}
|
||||
|
||||
/** One catalog row: icon · label/description (+ In-use badge) · pin toggle. */
|
||||
/**
|
||||
* One catalog row: icon · label/description (+ In-use badge) · pin toggle.
|
||||
*
|
||||
* The row itself OPENS the product. It used to be an inert `XStack` — a plain div
|
||||
* with `cursor: auto`, no role and no handler — so the one place in the console that
|
||||
* lists every app was a directory you could not walk: the only live control in the
|
||||
* row was the pin. Opening is delegated to the shared `openProduct`, the ONE opener
|
||||
* every other surface (sidebar, ⌘K, category page) already routes through.
|
||||
*
|
||||
* Pin stays a SEPARATE control on the same row, so curating never navigates and
|
||||
* navigating never curates. It stops the press from bubbling into the row for the
|
||||
* same reason.
|
||||
*/
|
||||
function ProductRow({
|
||||
entry,
|
||||
color,
|
||||
pinned,
|
||||
inUse,
|
||||
onOpen,
|
||||
onToggle,
|
||||
}: {
|
||||
entry: CatalogEntry
|
||||
color: string
|
||||
pinned: boolean
|
||||
inUse: boolean
|
||||
onOpen: () => void
|
||||
onToggle: () => void
|
||||
}) {
|
||||
const Icon = entry.icon
|
||||
return (
|
||||
<XStack items="center" gap="$3" py="$2" px="$2" rounded="$3" minH={44} hoverStyle={{ bg: '$color2' }}>
|
||||
<XStack
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onPress={onOpen}
|
||||
cursor="pointer"
|
||||
items="center"
|
||||
gap="$3"
|
||||
py="$2"
|
||||
px="$2"
|
||||
rounded="$3"
|
||||
minH={44}
|
||||
hoverStyle={{ bg: '$color2' }}
|
||||
focusStyle={{ bg: '$color2' }}
|
||||
aria-label={`Open ${entry.label}`}
|
||||
>
|
||||
<YStack width={32} height={32} rounded="$3" bg="$color3" items="center" justify="center">
|
||||
<Icon size={16} color={asColor(color)} />
|
||||
</YStack>
|
||||
@@ -73,7 +104,10 @@ function ProductRow({
|
||||
<Button
|
||||
size="$2"
|
||||
icon={pinned ? <Star size={15} /> : <Plus size={15} />}
|
||||
onPress={onToggle}
|
||||
onPress={(e?: { stopPropagation?: () => void }) => {
|
||||
e?.stopPropagation?.()
|
||||
onToggle()
|
||||
}}
|
||||
bg={pinned ? '$color5' : 'transparent'}
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
@@ -121,6 +155,15 @@ export function AddProductPanel() {
|
||||
const showAdmin = useIsSuperAdmin()
|
||||
const { isPinned, toggle } = usePins()
|
||||
const { colorOf } = useProductColors()
|
||||
const router = useRouter()
|
||||
const detail = useDetailPane()
|
||||
// Opening an app takes you to it: navigate, then close the pane you launched from
|
||||
// (it is a directory, not a destination — leaving it open would cover the page it
|
||||
// just opened).
|
||||
const openEntry = (entry: CatalogEntry) => {
|
||||
openProduct(entry, (path) => router.push(path))
|
||||
detail.close()
|
||||
}
|
||||
const [query, setQuery] = useState('')
|
||||
const [filter, setFilter] = useState<Filter>('all')
|
||||
// Real org usage signal. `null` = not (yet) known; a Set (even empty) = a real
|
||||
@@ -236,6 +279,7 @@ export function AddProductPanel() {
|
||||
color={colorOf(entry.id)}
|
||||
pinned={isPinned(entry.id)}
|
||||
inUse={inUse?.has(entry.id) ?? false}
|
||||
onOpen={() => openEntry(entry)}
|
||||
onToggle={() => toggle(entry.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1,27 +1,67 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Bridges the console session + App-Router navigation into the shared analytics
|
||||
* client (`@hanzo/event`). Rendered once, inside both `SessionProvider` and
|
||||
* `AnalyticsProvider` (see `Provider.tsx`), it renders nothing.
|
||||
* The console's telemetry surface — the ONE Hanzo telemetry provider, plus the one
|
||||
* thing it deliberately leaves to the app.
|
||||
*
|
||||
* - `usePageview` emits a pageview on every path change (the provider fires the
|
||||
* FIRST pageview itself, so this only covers subsequent client navigations).
|
||||
* - `identify` binds the person to the STABLE `owner/name` actor id — the same id
|
||||
* the API client already uses (`setCurrentActor`), never the email — once the
|
||||
* session resolves. The org tenant is stamped server-side from the session, so
|
||||
* we send the user id only. Anonymous placeholder sessions are skipped.
|
||||
* `TelemetrySurface` mounts `@hanzogui/telemetry`, which owns the whole plane for
|
||||
* this subtree: pageviews (including SPA route changes), `window.onerror` +
|
||||
* `unhandledrejection`, React render errors (its internal boundary REPORTS and
|
||||
* re-throws, so the app's own error UI still decides what the user sees), lazily
|
||||
* imported interaction capture, and DNT/GPC consent. It replaces the hand-rolled
|
||||
* `<AnalyticsProvider>` + bridge + boundary combo; it mounts @hanzo/event's
|
||||
* `AnalyticsProvider` internally with its own client, so every existing
|
||||
* `useAnalytics()` call site keeps working against that ONE client and one stream.
|
||||
* `product="console"` is all the configuration there is — @hanzo/event's DSN
|
||||
* registry resolves the hanzo-console Sentry project from it, so the error plane
|
||||
* needs no `dsn` prop and no env var.
|
||||
*
|
||||
* It reads `usePathname()` ITSELF rather than taking a `path` prop from `Provider`:
|
||||
* `Provider` memoizes its tree on `children`, so a path read up there would be
|
||||
* baked into the cached element and go stale on the first client navigation.
|
||||
*
|
||||
* `AnalyticsBridge` is the one thing TelemetryProvider does NOT do — `identify`.
|
||||
* It binds the person to the STABLE `owner/name` actor id (the same id the API
|
||||
* client uses via `setCurrentActor`), never the email, once the session resolves.
|
||||
* The org tenant is stamped server-side from the session, so we send the user id
|
||||
* only, and anonymous placeholder sessions are skipped. It renders nothing and
|
||||
* emits NO pageview — the provider owns those, and a second emitter would
|
||||
* double-count every route.
|
||||
*/
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useEffect, useRef, type ReactNode } from 'react'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useAnalytics, usePageview } from '@hanzo/event/react'
|
||||
import { TelemetryProvider, useTelemetry } from '@hanzogui/telemetry'
|
||||
|
||||
import { iamAccessToken } from '~/lib/auth/iam'
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
import { type Account } from '~/lib/api/types'
|
||||
|
||||
/** The user attributes worth carrying alongside the id, from the IAM claims the
|
||||
* session already decoded. A key is OMITTED rather than sent undefined, so an
|
||||
* absent claim never overwrites a trait a prior identify established. */
|
||||
export function identityTraits(account: Account): Record<string, unknown> {
|
||||
const traits: Record<string, unknown> = {}
|
||||
if (account.email) traits.email = account.email
|
||||
const name = account.displayName ?? account.name
|
||||
if (name) traits.name = name
|
||||
return traits
|
||||
}
|
||||
|
||||
export function TelemetrySurface({ children }: { children: ReactNode }) {
|
||||
const path = usePathname()
|
||||
// @hanzo/iam (PKCE) is the console's ONE credential and it is a BEARER — the same
|
||||
// token `lib/api/client.ts` puts on every call — so telemetry authenticates the
|
||||
// same way rather than relying on a cookie the ingest host would never receive.
|
||||
return (
|
||||
<TelemetryProvider product="console" path={path} getToken={iamAccessToken}>
|
||||
{children}
|
||||
</TelemetryProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export function AnalyticsBridge() {
|
||||
const analytics = useAnalytics()
|
||||
const telemetry = useTelemetry()
|
||||
const { account } = useSession()
|
||||
usePageview(usePathname())
|
||||
|
||||
const identified = useRef('')
|
||||
useEffect(() => {
|
||||
@@ -29,8 +69,8 @@ export function AnalyticsBridge() {
|
||||
const personId = `${account.owner}/${account.name}`
|
||||
if (identified.current === personId) return
|
||||
identified.current = personId
|
||||
analytics.identify(personId)
|
||||
}, [account, analytics])
|
||||
telemetry.identify(personId)
|
||||
}, [account, telemetry])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -22,12 +22,13 @@ import { useIam } from '@hanzo/iam/react'
|
||||
import { Button, Text, YStack } from '@hanzo/gui'
|
||||
|
||||
import { Loader } from '~/components/ui/Loader'
|
||||
import { takeReturnTo } from '~/lib/auth/iam'
|
||||
import { takeReturnTo, startReauth } from '~/lib/auth/iam'
|
||||
import { classifyCallback, type CallbackVerdict } from '~/lib/auth/callback-error'
|
||||
|
||||
export function AuthCallback() {
|
||||
const router = useRouter()
|
||||
const { handleCallback } = useIam()
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [verdict, setVerdict] = useState<CallbackVerdict | null>(null)
|
||||
// The OAuth `code` is SINGLE-USE and the SDK removes the PKCE verifier BEFORE the
|
||||
// token fetch, so the exchange must fire EXACTLY ONCE. Without this guard, React
|
||||
// StrictMode's double-invoke (or any `handleCallback` identity change re-running the
|
||||
@@ -47,20 +48,37 @@ export function AuthCallback() {
|
||||
window.location.assign(takeReturnTo())
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setError('Sign-in failed.')
|
||||
if (cancelled) return
|
||||
// The SDK is the AUTHORITY on whether a callback is a sign-in — it validates
|
||||
// `state` before honouring an error branch, so an attacker-supplied
|
||||
// /callback?error=… cannot mint a session. It reports every refusal the same
|
||||
// way, though, so this screen used to say "Sign-in failed." to someone who had
|
||||
// merely cancelled a consent screen. Read the code for WORDING only; the
|
||||
// decision was already made above.
|
||||
setVerdict(
|
||||
classifyCallback(typeof window === 'undefined' ? '' : window.location.search) ?? {
|
||||
kind: 'failed',
|
||||
message: 'Sign-in failed.',
|
||||
},
|
||||
)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [handleCallback])
|
||||
|
||||
if (error) {
|
||||
if (verdict) {
|
||||
// A refusal is not always a fault. Only a genuine failure is worded as one, and
|
||||
// the two benign outcomes lead with the action that actually resolves them.
|
||||
const retry = verdict.kind === 'failed' ? 'Back to sign in' : 'Sign in'
|
||||
return (
|
||||
<YStack flex={1} minH="100vh" items="center" justify="center" gap="$3">
|
||||
<Text color="$color12" fontWeight="600">
|
||||
{error}
|
||||
{verdict.message}
|
||||
</Text>
|
||||
<Button onPress={() => router.replace('/signin')}>Back to sign in</Button>
|
||||
<Button onPress={() => (verdict.kind === 'failed' ? router.replace('/signin') : startReauth())}>
|
||||
{retry}
|
||||
</Button>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Context switcher — WHERE you are: the organization and the project, in ONE
|
||||
* control, at the TOP-LEFT where the tenant's mark already sits.
|
||||
*
|
||||
* The console used to answer "who and where am I" from three different corners:
|
||||
* the org at the top of the rail, the account (which also switched org) at its
|
||||
* foot, and the project chip in the top-right beside the network. Org and
|
||||
* project are one question — which tenant, and which slice of it — so they are
|
||||
* one control, and it sits with the org mark that already anchors the top-left.
|
||||
*
|
||||
* The ACCOUNT keeps the other question ("who am I": identity, team, personal
|
||||
* settings, the way out) at the foot of the rail. The NETWORK stays its own
|
||||
* control in the top-right, because it is a global MODE rather than a place —
|
||||
* and its tier dot is a destructive-environment guard, not decoration.
|
||||
*
|
||||
* There is still exactly ONE org switch. `switchOrg` is passed by reference from
|
||||
* `~/lib/org-scope` (the seam that persists the scope and reloads so every
|
||||
* module refetches under the new `X-Org-Id`, which is where tenant scoping and
|
||||
* its billing attribution already live). This control does not mint a second
|
||||
* one, add a header of its own, or make a billing call — `org-state.test.ts`
|
||||
* pins that identity. Cross-tenant reach is the SAME admin-gated, server-paged
|
||||
* list the full-page picker uses; a regular user never fires it and sees only
|
||||
* their own org.
|
||||
*/
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button, Popover, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { ChevronsUpDown, FolderGit2, Plus } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { useScope } from '~/lib/scope-context'
|
||||
import { useOrgIdentity } from '~/components/ui/BrandLogo'
|
||||
import { useIsSuperAdmin } from '~/lib/auth/admin'
|
||||
import { IamAdminApi, type Organization } from '~/lib/api'
|
||||
import { ORG_PAGE_SIZE, orgQuery } from '~/lib/org-list'
|
||||
import { currentOrg, leaveOrg, switchOrg } from '~/lib/org-scope'
|
||||
import { contextLabel, scopedOrgRow, titleCase } from '~/lib/account/org-state'
|
||||
import { FieldText } from '~/components/ui/Field'
|
||||
import { MenuRow } from '~/components/ui/MenuRow'
|
||||
import { paper } from '~/components/ui/paper'
|
||||
|
||||
export function ContextSwitcher() {
|
||||
const router = useRouter()
|
||||
const org = useOrgIdentity()
|
||||
const scoped = currentOrg()
|
||||
const isSuperAdmin = useIsSuperAdmin()
|
||||
const { scope, projects, loadingProjects, selectProject } = useScope()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [orgs, setOrgs] = useState<Organization[] | null>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
// IAM's display name when it has one; otherwise the slug, titled the same way
|
||||
// `scopedOrgRow` titles it — one rule, so the trigger and the list agree.
|
||||
const orgLabel = org.displayName || titleCase(org.name || scoped)
|
||||
|
||||
// The cross-tenant list is admin-gated at the proxy; a regular user would 403
|
||||
// it, so they are never asked to — their own org is the honest answer. An admin
|
||||
// searches the SERVER (the list is paged and far longer than one page), which is
|
||||
// the only way to reach a tenant nobody is a member of.
|
||||
const loadOrgs = useCallback(
|
||||
async (q: string) => {
|
||||
if (!isSuperAdmin) return setOrgs(scopedOrgRow(scoped) as Organization[])
|
||||
const res = await IamAdminApi.organizations(orgQuery(0, q, ORG_PAGE_SIZE))
|
||||
setOrgs(res.rows ?? [])
|
||||
},
|
||||
[isSuperAdmin, scoped],
|
||||
)
|
||||
|
||||
const onOpenChange = useCallback(
|
||||
(next: boolean) => {
|
||||
setOpen(next)
|
||||
if (next && orgs === null) void loadOrgs('')
|
||||
},
|
||||
[orgs, loadOrgs],
|
||||
)
|
||||
|
||||
const search = useCallback(
|
||||
(q: string) => {
|
||||
setQuery(q)
|
||||
void loadOrgs(q)
|
||||
},
|
||||
[loadOrgs],
|
||||
)
|
||||
|
||||
const pick = useCallback(
|
||||
(fn: () => void) => () => {
|
||||
setOpen(false)
|
||||
fn()
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const orgRows = useMemo(() => orgs ?? [], [orgs])
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={onOpenChange} placement="bottom-start">
|
||||
<Popover.Trigger asChild>
|
||||
<Button
|
||||
size="$3"
|
||||
chromeless
|
||||
justify="flex-start"
|
||||
px="$2"
|
||||
data-testid="switcher-context"
|
||||
iconAfter={<ChevronsUpDown size={13} opacity={0.6} />}
|
||||
aria-label={`Organization and project — ${contextLabel(orgLabel, scope.project)}`}
|
||||
>
|
||||
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1} flex={1}>
|
||||
{contextLabel(orgLabel, scope.project)}
|
||||
</Text>
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content {...paper} p="$2" width={280}>
|
||||
<YStack gap="$0.5">
|
||||
<Text px="$2" py="$1" fontSize="$1" color="$color10" fontWeight="500">
|
||||
Organization
|
||||
</Text>
|
||||
|
||||
{/* Admins only: the cross-tenant list is server-paged and longer than
|
||||
one page, so reaching a tenant nobody is a member of means SEARCHING
|
||||
it, not scrolling. A regular user has one org and no field. */}
|
||||
{isSuperAdmin ? (
|
||||
<YStack px="$1" pb="$1">
|
||||
<FieldText
|
||||
value={query}
|
||||
onChange={search}
|
||||
placeholder="Find an organization"
|
||||
ariaLabel="Find an organization"
|
||||
/>
|
||||
</YStack>
|
||||
) : null}
|
||||
|
||||
<YStack role="radiogroup" aria-label="Organizations" gap="$0.5">
|
||||
{orgRows.map((o) => (
|
||||
<MenuRow
|
||||
key={o.name}
|
||||
label={o.displayName || o.name}
|
||||
active={scoped === o.name}
|
||||
onPress={pick(() => {
|
||||
if (o.name !== scoped) switchOrg(o.name)
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</YStack>
|
||||
|
||||
{orgRows.length === 0 ? (
|
||||
<Text px="$2" py="$1.5" fontSize="$2" color="$color10">
|
||||
{orgs === null ? 'Loading…' : 'No organization matches that.'}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<MenuRow label="All organizations" icon={<Plus size={14} />} onPress={pick(leaveOrg)} />
|
||||
|
||||
<XStack height={1} bg="$borderColor" my="$1" />
|
||||
|
||||
<Text px="$2" py="$1" fontSize="$1" color="$color10" fontWeight="500">
|
||||
Project
|
||||
</Text>
|
||||
|
||||
<YStack role="radiogroup" aria-label="Projects" gap="$0.5">
|
||||
{/* Org-level scope — no X-Project-Id sent. */}
|
||||
<MenuRow
|
||||
label="All projects"
|
||||
sub="Org-level"
|
||||
active={!scope.project}
|
||||
onPress={pick(() => selectProject(undefined))}
|
||||
/>
|
||||
|
||||
{projects.map((p) => (
|
||||
<MenuRow
|
||||
key={p.name}
|
||||
label={p.displayName || p.name}
|
||||
active={scope.project === p.name}
|
||||
onPress={pick(() => selectProject(p.name))}
|
||||
/>
|
||||
))}
|
||||
</YStack>
|
||||
|
||||
{projects.length === 0 && !loadingProjects ? (
|
||||
<Text px="$2" py="$1.5" fontSize="$2" color="$color10">
|
||||
No projects yet.
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<MenuRow
|
||||
label="New project"
|
||||
icon={<FolderGit2 size={14} />}
|
||||
onPress={pick(() => router.push('/projects'))}
|
||||
/>
|
||||
</YStack>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -17,6 +17,14 @@
|
||||
* `<lg` the assistant is ALWAYS the floating bubble/sheet regardless of the dock
|
||||
* choice; `docked` only reserves the right column at `lg+`.
|
||||
*
|
||||
* The assistant has ONE entry point and it lives HERE: `AssistantFab`, a floating
|
||||
* control fixed bottom-right over every dashboard page. It used to be two small
|
||||
* buttons in the topbar (a brand-H and a mic), which put the assistant in a third
|
||||
* place — beside the search box, competing with the org/theme/alert chrome — while
|
||||
* this module owned every other shape it can take. Chat and voice are the same
|
||||
* surface opened two ways, so they sit together, in the corner the assistant
|
||||
* actually appears in.
|
||||
*
|
||||
* Every shape REUSES the one working chat surface (`ChatConversation` → `AiApi.chat`
|
||||
* → the keyless `/ai` proxy → /v1/chat/completions). Nothing about AI is rebuilt
|
||||
* here; this is purely the container. "History" deep-links to the full `/chat` page.
|
||||
@@ -26,12 +34,13 @@
|
||||
import { createContext, useCallback, useContext, useState, type ReactNode } from 'react'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { Button, Dialog, Text, VisuallyHidden, XStack, YStack } from '@hanzo/gui'
|
||||
import { PanelRight, PanelRightClose, Sparkles, X } from '@hanzogui/lucide-icons-2'
|
||||
import { Mic, PanelRight, PanelRightClose, Sparkles, X } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { ChatConversation } from '~/components/products/chat/ChatConversation'
|
||||
// NB: the old `BrandMark` bubble import was removed with the floating circle — the
|
||||
// assistant now opens from the topbar's small brand-H + mic controls.
|
||||
import { BrandMark } from '~/components/ui/BrandLogo'
|
||||
import { usePreferences } from '~/lib/products/preferences'
|
||||
import { voiceSupported } from '~/lib/voice'
|
||||
import { Z } from '~/lib/z'
|
||||
|
||||
type FloatingChatApi = {
|
||||
isOpen: boolean
|
||||
@@ -204,6 +213,74 @@ function ChatSheet({
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The floating assistant control — bottom-right, on every dashboard page.
|
||||
*
|
||||
* Two ways into one surface, side by side: the brand mark opens the assistant (the
|
||||
* docked right column on a laptop, the full sheet on a phone) and the mic opens it
|
||||
* listening. The mic renders only where the browser can actually listen, so there is
|
||||
* never a dead control.
|
||||
*
|
||||
* It sits ABOVE the Developers dock at `lg+` (that dock's collapsed bar is 44px and
|
||||
* exists only there), and it is suppressed exactly where the assistant is already on
|
||||
* screen: while the sheet is open, on the pages that ARE a composer (`/chat`,
|
||||
* `/playground`), and — at `lg+` only — while the assistant is docked as a column.
|
||||
* The `lg+` half of that rule is a CSS media prop rather than a JS branch, so SSR and
|
||||
* first paint agree.
|
||||
*/
|
||||
function AssistantFab({
|
||||
docked,
|
||||
onOpen,
|
||||
onVoice,
|
||||
}: {
|
||||
docked: boolean
|
||||
onOpen: () => void
|
||||
onVoice: () => void
|
||||
}) {
|
||||
const [voiceOk] = useState(() => voiceSupported())
|
||||
return (
|
||||
<XStack
|
||||
testID="assistant-fab"
|
||||
position="fixed"
|
||||
r={20}
|
||||
b={20}
|
||||
$lg={{ b: 64, display: docked ? 'none' : 'flex' }}
|
||||
items="center"
|
||||
gap="$2"
|
||||
style={{ zIndex: Z.raised }}
|
||||
>
|
||||
{voiceOk ? (
|
||||
<Button
|
||||
className="hz-paper"
|
||||
size="$3"
|
||||
circular
|
||||
width={44}
|
||||
height={44}
|
||||
bg="$color2"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
icon={<Mic size={18} />}
|
||||
onPress={onVoice}
|
||||
aria-label="Talk to Hanzo"
|
||||
/>
|
||||
) : null}
|
||||
<Button
|
||||
className="hz-paper"
|
||||
size="$4"
|
||||
circular
|
||||
width={52}
|
||||
height={52}
|
||||
bg="$color2"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
icon={<BrandMark size={22} />}
|
||||
onPress={onOpen}
|
||||
aria-label="Ask Hanzo"
|
||||
/>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The DOCKED assistant — a permanent right column. Rendered by `Dashboard`
|
||||
* inside the layout's reserved right rail (lg+ only), so it reserves space beside
|
||||
@@ -292,16 +369,14 @@ export function Chat({ children }: { children: ReactNode }) {
|
||||
<Ctx.Provider value={{ isOpen, open, close, toggle, docked, setDocked, ask, seed, openChat, startVoice, voiceSignal }}>
|
||||
{children}
|
||||
|
||||
{/* The bubble — fixed bottom-right over every page. Hidden while open (the
|
||||
sheet's own close is the single dismiss). Hidden on the chat/playground
|
||||
surfaces (would overlap the page composer). At lg+ it is ALSO hidden when
|
||||
docked (the permanent column is the surface); on phones it always shows,
|
||||
since docking has no room there. */}
|
||||
{/* NO floating bubble — the big circle that covered page content is gone. The
|
||||
assistant is opened from the topbar (the small brand-H "chat" control + the
|
||||
"talk to Hanzo" mic), so the user's OWN brand leads the chrome and AI help is
|
||||
one small press away. `open`/`toggle`/`ask` still drive it programmatically
|
||||
(e.g. the Code hub's "Ask AI"). */}
|
||||
{/* The assistant's ONE entry point — bottom-right, over every page. Hidden
|
||||
while the sheet is open (its own close is the single dismiss) and on the
|
||||
pages that ARE a composer; at lg+ also hidden while docked (the permanent
|
||||
column is the surface). `open`/`toggle`/`ask` still drive the assistant
|
||||
programmatically (e.g. the Code hub's "Ask AI"). */}
|
||||
{isOpen || onChatSurface ? null : (
|
||||
<AssistantFab docked={docked} onOpen={openChat} onVoice={startVoice} />
|
||||
)}
|
||||
|
||||
{/* The floating sheet. Suppressed on the full chat/playground surfaces (the page
|
||||
IS the composer) and, at lg+, while docked (the right column is the surface);
|
||||
|
||||
@@ -20,11 +20,27 @@ import { Button, Card, Input, Spinner, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Building2, ArrowRight, Sparkles } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
import { slugifyOrg, validateOrgName } from '~/lib/server/onboarding'
|
||||
import { readOnboardRefusal, slugifyOrg, validateOrgName } from '~/lib/server/onboarding'
|
||||
import { v1Url } from '~/lib/api/client'
|
||||
import { FadeIn } from '~/components/ui/FadeIn'
|
||||
|
||||
type Phase = 'form' | 'done'
|
||||
type Phase = 'form' | 'done' | 'exists'
|
||||
|
||||
// currentOwner asks the server which organization this account is in. IAM answers
|
||||
// the legacy envelope ({status,msg,data}) on this address, so read through `data`
|
||||
// and fall back to a bare body. Any failure returns null and the caller falls back
|
||||
// to showing the server's own message — a recovery that guesses is worse than none.
|
||||
async function currentOwner(): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch(v1Url('iam/account'), { credentials: 'include' })
|
||||
if (!res.ok) return null
|
||||
const body = (await res.json()) as { data?: { owner?: string }; owner?: string } | null
|
||||
const owner = body?.data?.owner ?? body?.owner
|
||||
return owner && owner !== 'admin' ? owner : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function OrgOnboarding() {
|
||||
const { signIn, signOut } = useSession()
|
||||
@@ -32,6 +48,9 @@ export function OrgOnboarding() {
|
||||
const [busy, setBusy] = useState<false | 'create' | 'personal'>(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [phase, setPhase] = useState<Phase>('form')
|
||||
// The org the SERVER says this account already admins, discovered only after a
|
||||
// refused create. See the 409 branch in onboard().
|
||||
const [existingOrg, setExistingOrg] = useState<string | null>(null)
|
||||
|
||||
const slug = slugifyOrg(name)
|
||||
const named = validateOrgName(name)
|
||||
@@ -55,7 +74,28 @@ export function OrgOnboarding() {
|
||||
}
|
||||
const json = (await res.json().catch(() => null)) as { org?: string; error?: string } | null
|
||||
if (!res.ok || !json?.org) {
|
||||
setError(json?.error || `Could not create the organization (HTTP ${res.status}).`)
|
||||
// 409 = the FIRST-RUN GATE, not a name collision. Onboarding MOVES the caller
|
||||
// into the org it founds, so founding a second one would orphan the org this
|
||||
// account already admins — IAM refuses (provision.go: "you already have an
|
||||
// organization"). This screen is only ever rendered when the client resolved
|
||||
// an EMPTY owner, and an owner that is empty because a read failed looks
|
||||
// exactly like a brand-new account. So the refusal is the first reliable
|
||||
// signal that the session was wrong, and the only honest thing to do with it
|
||||
// is recover: ask the server which org this account is actually in and offer
|
||||
// the way in. Leaving the customer on a form that can never submit — with a
|
||||
// message about organizations when they just typed a name — is how "it said
|
||||
// the name was taken" happens.
|
||||
const refusal = readOnboardRefusal(
|
||||
res.status,
|
||||
json?.error,
|
||||
res.status === 409 ? await currentOwner() : null,
|
||||
)
|
||||
if (refusal.action === 'recover') {
|
||||
setExistingOrg(refusal.org)
|
||||
setPhase('exists')
|
||||
} else {
|
||||
setError(refusal.error)
|
||||
}
|
||||
setBusy(false)
|
||||
return
|
||||
}
|
||||
@@ -69,6 +109,32 @@ export function OrgOnboarding() {
|
||||
signIn()
|
||||
}
|
||||
|
||||
if (phase === 'exists' && existingOrg) {
|
||||
return (
|
||||
<Center>
|
||||
<FadeIn style={CENTER_STYLE}>
|
||||
<Card p="$5" gap="$4" width={440} borderWidth={1} borderColor="$borderColor" bg="$color1" items="center">
|
||||
<Building2 size={20} />
|
||||
<YStack gap="$1" items="center">
|
||||
<Text fontSize="$6" fontWeight="800">
|
||||
You{'\u2019'}re already in {existingOrg}
|
||||
</Text>
|
||||
<Text fontSize="$3" color="$color11" text="center">
|
||||
This account already belongs to an organization, so there was nothing to
|
||||
create. Sign in again to continue there.
|
||||
</Text>
|
||||
</YStack>
|
||||
{/* Explicit, never automatic. Re-authenticating on our own would loop
|
||||
forever against whatever left the session without an owner. */}
|
||||
<Button size="$3" onPress={() => signIn()}>
|
||||
Continue to {existingOrg}
|
||||
</Button>
|
||||
</Card>
|
||||
</FadeIn>
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
if (phase === 'done') {
|
||||
return (
|
||||
<Center>
|
||||
|
||||
@@ -8,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>
|
||||
)
|
||||
}
|
||||
@@ -1,32 +1,35 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Scope switcher — the project + network pickers that scope every module.
|
||||
* Scope switcher — the NETWORK picker, and only that.
|
||||
*
|
||||
* Two chips next to the org switcher: the active PROJECT (or "All projects" for
|
||||
* org-level scope) and the active NETWORK. The network picker offers the stock
|
||||
* tiers (Mainnet/Testnet/Devnet — the live Hanzo networks), a Local option for a
|
||||
* self-hosted cloud binary, and any custom networks the user adds (their own
|
||||
* networkID / EVM chainID / RPC / API). Selecting a network writes through
|
||||
* `useScope`, which updates the module-level scope the API client reads — so it
|
||||
* re-scopes every module (via `X-Environment`) AND retargets chain/RPC/API at once.
|
||||
* A "New project" affordance routes to the Projects module; we never fabricate one.
|
||||
* The network is a global MODE, not a place, so it stays its own control in the
|
||||
* top-right while org and project condense into the top-left `ContextSwitcher`.
|
||||
* Its tier dot is a destructive-environment guard (mainnet live green, testnet
|
||||
* caution amber), which is why it keeps a distinct, always-visible chip rather
|
||||
* than folding into a menu you have to open to read.
|
||||
*
|
||||
* The picker offers the stock tiers (Mainnet/Testnet/Devnet — the live Hanzo
|
||||
* networks), a Local option for a self-hosted cloud binary, and any custom
|
||||
* networks the user adds (their own networkID / EVM chainID / RPC / API).
|
||||
* Selecting a network writes through `useScope`, which updates the module-level
|
||||
* scope the API client reads — so it re-scopes every module (via
|
||||
* `X-Environment`) AND retargets chain/RPC/API at once.
|
||||
*/
|
||||
import { useMemo, useState, type ReactNode } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Button, Popover, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Check, ChevronsUpDown, FolderGit2, Layers, Plus, Trash } from '@hanzogui/lucide-icons-2'
|
||||
import { Check, ChevronsUpDown, Layers, Plus, Trash } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { useScope } from '~/lib/scope-context'
|
||||
import { STOCK_ENVIRONMENTS } from '~/lib/scope'
|
||||
import { isStockNetwork, parseCustomNetwork, type Network } from '~/lib/network'
|
||||
import { FieldText } from '~/components/ui/Field'
|
||||
import { MenuRow, type DotColor } from '~/components/ui/MenuRow'
|
||||
import { paper } from '~/components/ui/paper'
|
||||
|
||||
/** A small dot keyed to the network tier. Monochrome by default; only the genuine
|
||||
* states carry a hue — mainnet is live (green), testnet is a caution (amber). Every
|
||||
* other tier is a neutral off the design ladder (Tamagui $colorN). */
|
||||
type DotColor = '$green10' | '$yellow10' | '$color10' | '$color9' | '$color8'
|
||||
const NET_DOT: Record<string, DotColor> = {
|
||||
mainnet: '$green10',
|
||||
testnet: '$yellow10',
|
||||
@@ -41,59 +44,6 @@ const titleCase = (s: string) => (s ? s[0].toUpperCase() + s.slice(1) : s)
|
||||
const idSub = (n: Network): string =>
|
||||
n.networkID === n.evmChainID ? `Chain ${n.evmChainID}` : `Net ${n.networkID} · Chain ${n.evmChainID}`
|
||||
|
||||
function ProjectPicker() {
|
||||
const router = useRouter()
|
||||
const { scope, projects, loadingProjects, selectProject } = useScope()
|
||||
const label = scope.project ? scope.project : 'All projects'
|
||||
|
||||
return (
|
||||
<Popover placement="bottom-end">
|
||||
<Popover.Trigger asChild>
|
||||
<Button size="$2" chromeless icon={<FolderGit2 size={14} />} iconAfter={<ChevronsUpDown size={13} />}>
|
||||
{label}
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content {...paper} p="$2" width={260}>
|
||||
<YStack gap="$0.5">
|
||||
<Text px="$2" py="$1" fontSize="$1" color="$color10" fontWeight="500">
|
||||
Project
|
||||
</Text>
|
||||
|
||||
{/* Org-level scope — no X-Project-Id sent. */}
|
||||
<Row
|
||||
label="All projects"
|
||||
sub="Org-level"
|
||||
active={!scope.project}
|
||||
onPress={() => selectProject(undefined)}
|
||||
/>
|
||||
|
||||
{projects.map((p) => (
|
||||
<Row
|
||||
key={p.name}
|
||||
label={p.displayName || p.name}
|
||||
active={scope.project === p.name}
|
||||
onPress={() => selectProject(p.name)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{projects.length === 0 && !loadingProjects ? (
|
||||
<Text px="$2" py="$1.5" fontSize="$2" color="$color10">
|
||||
No projects yet.
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<XStack height={1} bg="$borderColor" my="$1" />
|
||||
<Row
|
||||
label="New project"
|
||||
icon={<Plus size={14} />}
|
||||
onPress={() => router.push('/projects')}
|
||||
/>
|
||||
</YStack>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
const EMPTY_FORM = { label: '', networkID: '', evmChainID: '', rpcEndpoint: '', apiEndpoint: '' }
|
||||
|
||||
function AddNetworkForm({
|
||||
@@ -200,7 +150,7 @@ function NetworkPicker() {
|
||||
Project environments
|
||||
</Text>
|
||||
{extraEnvs.map((env) => (
|
||||
<Row
|
||||
<MenuRow
|
||||
key={env}
|
||||
label={titleCase(env)}
|
||||
sub="Custom"
|
||||
@@ -216,7 +166,7 @@ function NetworkPicker() {
|
||||
{adding ? (
|
||||
<AddNetworkForm taken={takenCustomIds} onAdd={add} onCancel={() => setAdding(false)} />
|
||||
) : (
|
||||
<Row label="Add custom network" icon={<Plus size={14} />} onPress={() => setAdding(true)} />
|
||||
<MenuRow label="Add custom network" icon={<Plus size={14} />} onPress={() => setAdding(true)} />
|
||||
)}
|
||||
</YStack>
|
||||
</Popover.Content>
|
||||
@@ -263,54 +213,10 @@ function NetworkRow({
|
||||
)
|
||||
}
|
||||
|
||||
/** One selectable row in a picker popover. */
|
||||
function Row({
|
||||
label,
|
||||
sub,
|
||||
dot,
|
||||
icon,
|
||||
active,
|
||||
onPress,
|
||||
}: {
|
||||
label: string
|
||||
sub?: string
|
||||
dot?: DotColor
|
||||
icon?: ReactNode
|
||||
active?: boolean
|
||||
onPress: () => void
|
||||
}) {
|
||||
return (
|
||||
<XStack
|
||||
onPress={onPress}
|
||||
cursor="pointer"
|
||||
items="center"
|
||||
gap="$2"
|
||||
px="$2"
|
||||
py="$2"
|
||||
rounded="$3"
|
||||
hoverStyle={{ bg: '$color4' }}
|
||||
>
|
||||
{dot ? <YStack width={8} height={8} rounded="$10" bg={dot} /> : icon}
|
||||
<YStack flex={1}>
|
||||
<Text fontSize="$2" color="$color12" numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
{sub ? (
|
||||
<Text fontSize="$1" color="$color10">
|
||||
{sub}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
{active ? <Check size={14} /> : null}
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
/** Project + network pickers as a unit (topbar). */
|
||||
/** The network picker (topbar). Org + project live in `ContextSwitcher`, top-left. */
|
||||
export function ScopeSwitcher() {
|
||||
return (
|
||||
<XStack items="center" gap="$1">
|
||||
<ProjectPicker />
|
||||
<XStack items="center" gap="$1" data-testid="switcher-network">
|
||||
<NetworkPicker />
|
||||
</XStack>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { identityTraits } from './Analytics'
|
||||
import { type Account } from '~/lib/api/types'
|
||||
|
||||
const account = (over: Partial<Account> = {}): Account => ({
|
||||
owner: 'hanzo',
|
||||
name: 'z',
|
||||
userId: 'sub-1',
|
||||
...over,
|
||||
})
|
||||
|
||||
describe('identityTraits', () => {
|
||||
it('carries the email and the human name off the IAM claims', () => {
|
||||
expect(
|
||||
identityTraits(account({ email: 'z@hanzo.ai', displayName: 'Z Hanzo' })),
|
||||
).toEqual({ email: 'z@hanzo.ai', name: 'Z Hanzo' })
|
||||
})
|
||||
|
||||
it('falls back to the login handle when no display name was claimed', () => {
|
||||
expect(identityTraits(account({ email: 'z@hanzo.ai' }))).toEqual({
|
||||
email: 'z@hanzo.ai',
|
||||
name: 'z',
|
||||
})
|
||||
})
|
||||
|
||||
// An absent claim must be ABSENT, not `undefined`: a trait sent as undefined
|
||||
// is a trait written, and it would blank a value an earlier identify had set.
|
||||
it('omits a key it has no claim for rather than sending undefined', () => {
|
||||
const traits = identityTraits(account({ displayName: 'Z Hanzo' }))
|
||||
expect(traits).toEqual({ name: 'Z Hanzo' })
|
||||
expect('email' in traits).toBe(false)
|
||||
})
|
||||
|
||||
// The tenant is stamped server-side from the validated bearer. A tenant the
|
||||
// client can name is a tenant the client can get wrong.
|
||||
it('never sends the org', () => {
|
||||
const traits = identityTraits(
|
||||
account({ email: 'z@hanzo.ai', organization: 'hanzo', owner: 'hanzo' }),
|
||||
)
|
||||
expect(traits).not.toHaveProperty('org')
|
||||
expect(traits).not.toHaveProperty('organization')
|
||||
expect(traits).not.toHaveProperty('owner')
|
||||
})
|
||||
})
|
||||
@@ -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}
|
||||
|
||||
@@ -14,26 +14,46 @@ import { PrimaryButton } from '~/components/ui/PrimaryButton'
|
||||
import { ONBOARDING_STEPS, type StepId, type StepStatus } from '~/lib/onboarding/steps'
|
||||
|
||||
/** Title + subtitle header + body for one step. */
|
||||
/**
|
||||
* The content area's reserved height. Every step's body occupies at least this
|
||||
* much, so `actions` lands at the SAME y on every step and Continue never moves
|
||||
* under the pointer between steps — the whole reason actions is a slot on the
|
||||
* shell rather than the last child a step happens to render.
|
||||
*
|
||||
* A taller step still grows (the area is flex), so this reserves space without
|
||||
* capping it.
|
||||
*/
|
||||
const CONTENT_MIN_HEIGHT = 320
|
||||
|
||||
export function StepShell({
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
actions,
|
||||
}: {
|
||||
title: string
|
||||
subtitle: string
|
||||
children: ReactNode
|
||||
/**
|
||||
* The step's StepActions. It is a SLOT, not a child, so the shell decides
|
||||
* where it sits — one placement for every step, decided in one place.
|
||||
*/
|
||||
actions?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<YStack gap="$4" flex={1} minW={0}>
|
||||
<YStack gap="$1.5">
|
||||
<Text fontSize="$8" fontWeight="800" color="$color12">
|
||||
<Text testID="onboarding-step-title" fontSize="$8" fontWeight="800" color="$color12">
|
||||
{title}
|
||||
</Text>
|
||||
<Text fontSize="$4" color="$color11">
|
||||
{subtitle}
|
||||
</Text>
|
||||
</YStack>
|
||||
{children}
|
||||
<YStack gap="$4" flex={1} minH={CONTENT_MIN_HEIGHT}>
|
||||
{children}
|
||||
</YStack>
|
||||
{actions}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -61,7 +81,7 @@ export function StepActions({
|
||||
busy?: boolean
|
||||
}) {
|
||||
return (
|
||||
<XStack gap="$3" items="center" justify="space-between" flexWrap="wrap" pt="$2">
|
||||
<XStack testID="onboarding-actions" gap="$3" items="center" justify="space-between" flexWrap="wrap" pt="$2">
|
||||
<XStack>
|
||||
{onBack ? (
|
||||
<Button size="$3" chromeless disabled={busy} icon={<ArrowLeft size={16} />} onPress={onBack}>
|
||||
|
||||
@@ -121,7 +121,19 @@ export function AiAccessStep({ state, patch, next, skip, back, isFirst }: StepPr
|
||||
const connectedLabels = connections.map((c) => c.provider).join(', ')
|
||||
|
||||
return (
|
||||
<StepShell title="AI access" subtitle="Choose how you want to power AI. You can change or combine these anytime in AI Accounts.">
|
||||
<StepShell
|
||||
title="AI access"
|
||||
subtitle="Choose how you want to power AI. You can change or combine these anytime in AI Accounts."
|
||||
actions={
|
||||
<StepActions
|
||||
onBack={isFirst ? undefined : back}
|
||||
onSkip={skip}
|
||||
skipLabel="Decide later"
|
||||
onContinue={next}
|
||||
continueLabel="Continue"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ChoiceCard
|
||||
icon={<Wand2 size={20} />}
|
||||
title="Let Hanzo power it"
|
||||
@@ -247,14 +259,6 @@ export function AiAccessStep({ state, patch, next, skip, back, isFirst }: StepPr
|
||||
{err}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<StepActions
|
||||
onBack={isFirst ? undefined : back}
|
||||
onSkip={skip}
|
||||
skipLabel="Decide later"
|
||||
onContinue={next}
|
||||
continueLabel="Continue"
|
||||
/>
|
||||
</StepShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -33,7 +33,17 @@ export function ConsentStep({ state, patch, next, back, isFirst }: StepProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<StepShell title="Data & consent" subtitle={`A couple of choices about how ${config.brandName} handles your data.`}>
|
||||
<StepShell
|
||||
title="Data & consent"
|
||||
subtitle={`A couple of choices about how ${config.brandName} handles your data.`}
|
||||
actions={
|
||||
// No Skip here on purpose: accepting the Terms is not optional, so an
|
||||
// affordance that skips past them would be dishonest. Continue stays
|
||||
// disabled until the box is ticked. The data-sharing choice beside it IS
|
||||
// optional and defaults to off — leaving it alone is the skip.
|
||||
<StepActions onBack={isFirst ? undefined : back} onContinue={commit} continueDisabled={!agreed} />
|
||||
}
|
||||
>
|
||||
<Card p="$4" gap="$3" borderWidth={1} borderColor="$borderColor">
|
||||
<XStack gap="$3" items="flex-start" justify="space-between">
|
||||
<XStack gap="$3" items="center" flex={1} minW={0}>
|
||||
@@ -80,7 +90,6 @@ export function ConsentStep({ state, patch, next, back, isFirst }: StepProps) {
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<StepActions onBack={isFirst ? undefined : back} onContinue={commit} continueDisabled={!agreed} />
|
||||
</StepShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
* browser — the console never sees a PAN. REAL: `BillingApi.paymentConfig` mounts the
|
||||
* element (`useSquareCard`), `createPaymentMethod({token})` vaults the card (the
|
||||
* commerce handler grants/extends the trial credit as a side-effect, $1 verify-then-
|
||||
* void, no charge), `welcome()` claims the fixed starter grant (idempotent), and
|
||||
* `balance()` shows the granted balance. Skippable — credits can be added later.
|
||||
* void, no charge), and `balance()` shows the granted balance. Skippable — credits
|
||||
* can be added later.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Card, Spinner, Text, XStack, YStack } from '@hanzo/gui'
|
||||
@@ -70,9 +70,9 @@ export function CreditsStep({ next, skip, back, isFirst }: StepProps) {
|
||||
try {
|
||||
const token = await card.tokenize()
|
||||
await BillingApi.createPaymentMethod({ type: 'card', token })
|
||||
// Claim the fixed starter grant too (idempotent server-side); the card-added
|
||||
// handler also extends the trial — both are safe to run.
|
||||
await BillingApi.welcome().catch(() => undefined)
|
||||
// The trial credit is granted SERVER-SIDE as a side-effect of vaulting the card.
|
||||
// The browser never mints its own credit — the only credit mint is the
|
||||
// mint-gated POST /v1/billing/credit, which a tenant session cannot call.
|
||||
const bal = await BillingApi.balance().catch(() => balance)
|
||||
if (!mounted.current) return
|
||||
setBalance(bal)
|
||||
@@ -91,7 +91,21 @@ export function CreditsStep({ next, skip, back, isFirst }: StepProps) {
|
||||
const split = balanceSplitLabel(balance)
|
||||
|
||||
return (
|
||||
<StepShell title="Free trial credits" subtitle="Add a card to unlock free trial credits. No charge now — it just keeps your account ready when the trial ends.">
|
||||
<StepShell
|
||||
title="Free trial credits"
|
||||
subtitle="Add a card to unlock free trial credits. No charge now — it just keeps your account ready when the trial ends."
|
||||
actions={
|
||||
<StepActions
|
||||
onBack={isFirst ? undefined : back}
|
||||
onSkip={phase === 'ready' && !isUnlocked ? skip : undefined}
|
||||
skipLabel="Skip for now"
|
||||
onContinue={next}
|
||||
continueLabel="Continue"
|
||||
continueDisabled={phase === 'ready' && !isUnlocked}
|
||||
busy={adding}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{phase === 'loading' ? (
|
||||
<Card p="$5" items="center" borderWidth={1} borderColor="$borderColor">
|
||||
<Spinner size="large" color="$color11" />
|
||||
@@ -121,7 +135,7 @@ export function CreditsStep({ next, skip, back, isFirst }: StepProps) {
|
||||
<XStack gap="$2" items="center">
|
||||
<CreditCard size={18} color="var(--color10)" />
|
||||
<Text fontSize="$4" fontWeight="700" color="$color12">
|
||||
Payments aren't set up on this deployment
|
||||
Payments aren't set up for this organization
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$3" color="$color11">
|
||||
@@ -195,16 +209,6 @@ export function CreditsStep({ next, skip, back, isFirst }: StepProps) {
|
||||
</XStack>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<StepActions
|
||||
onBack={isFirst ? undefined : back}
|
||||
onSkip={phase === 'ready' && !isUnlocked ? skip : undefined}
|
||||
skipLabel="Skip for now"
|
||||
onContinue={next}
|
||||
continueLabel="Continue"
|
||||
continueDisabled={phase === 'ready' && !isUnlocked}
|
||||
busy={adding}
|
||||
/>
|
||||
</StepShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import { useToast } from '~/components/ui/Toast'
|
||||
import { StepShell, StepActions } from '~/components/onboarding/parts'
|
||||
import type { StepProps } from '~/components/onboarding/types'
|
||||
|
||||
export function TeamStep({ next, back, isFirst }: StepProps) {
|
||||
export function TeamStep({ next, skip, back, isFirst }: StepProps) {
|
||||
const { account } = useSession()
|
||||
const toast = useToast()
|
||||
const org = account?.owner || currentOrg()
|
||||
@@ -63,7 +63,20 @@ export function TeamStep({ next, back, isFirst }: StepProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<StepShell title="Your workspace" subtitle="This is where your projects, usage, and billing live. Name it now, or keep the default.">
|
||||
<StepShell
|
||||
title="Your workspace"
|
||||
subtitle="This is where your projects, usage, and billing live. Name it now, or keep the default."
|
||||
actions={
|
||||
<StepActions
|
||||
onBack={isFirst ? undefined : back}
|
||||
onSkip={skip}
|
||||
skipLabel="Keep the default"
|
||||
onContinue={() => void commit()}
|
||||
continueLabel="Continue"
|
||||
busy={busy}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Card p="$4" gap="$3" borderWidth={1} borderColor="$borderColor">
|
||||
<XStack gap="$3" items="center">
|
||||
<YStack width={44} height={44} rounded="$4" items="center" justify="center" bg="$color3">
|
||||
@@ -95,13 +108,6 @@ export function TeamStep({ next, back, isFirst }: StepProps) {
|
||||
Invite teammates and switch workspaces anytime from the top bar.
|
||||
</Text>
|
||||
</XStack>
|
||||
|
||||
<StepActions
|
||||
onBack={isFirst ? undefined : back}
|
||||
onContinue={() => void commit()}
|
||||
continueLabel="Continue"
|
||||
busy={busy}
|
||||
/>
|
||||
</StepShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Annotation Queues — list of review queues (HIP-0106), native on @hanzo/gui.
|
||||
*
|
||||
* An annotation queue is a named work queue of traces/observations to review and
|
||||
* score against a set of score configs. Reads the REAL `/v1/o11y/annotation-queues`
|
||||
* score against a set of rubrics. Reads the REAL `/v1/o11y/reviews`
|
||||
* surface; when the runtime is not initialized (503) or unrouted (404) it shows an
|
||||
* honest RuntimeNotice — never fabricated queues. Read-only list here; items are
|
||||
* worked in the annotation flow.
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* API Keys — create, copy, rotate, and revoke the per-user `hk-` Cloud API key.
|
||||
* API Keys — create, copy, rotate, and revoke the per-user `sk-` Cloud API key.
|
||||
*
|
||||
* The key is minted server-side (`/keys` route → IAM, app-on-behalf as the
|
||||
* confidential console client); the browser only sends its session cookie and
|
||||
* never holds a long-lived secret beyond the one-time reveal at creation. This
|
||||
* is the real credential the user presents as `Authorization: Bearer hk-…` to
|
||||
* is the real credential the user presents as `Authorization: Bearer sk-…` to
|
||||
* the SDKs, CLI, and the api.hanzo.ai gateway.
|
||||
*
|
||||
* `ApiKeysView` is the bare surface so Settings can embed it as a tab;
|
||||
@@ -190,7 +190,7 @@ export function ApiKeysView() {
|
||||
Cloud API key
|
||||
</Text>
|
||||
<Text fontSize="$2" color="$color10" style={{ fontFamily: 'monospace' }}>
|
||||
{status?.keyPrefix ? `${status.keyPrefix}…` : 'hk-…'}
|
||||
{status?.keyPrefix ? `${status.keyPrefix}…` : 'sk-…'}
|
||||
</Text>
|
||||
{fmtKeyDate(status?.createdAt) ? (
|
||||
<Text fontSize="$1" color="$color10">
|
||||
@@ -233,7 +233,7 @@ export function ApiKeysView() {
|
||||
</XStack>
|
||||
<Text fontSize="$3" color="$color11">
|
||||
One key for your account, scoped to your organization. Use it as{' '}
|
||||
<Text fontSize="$2" style={{ fontFamily: 'monospace' }}>Authorization: Bearer hk-…</Text> with the SDKs,
|
||||
<Text fontSize="$2" style={{ fontFamily: 'monospace' }}>Authorization: Bearer sk-…</Text> with the SDKs,
|
||||
CLI, and the api.hanzo.ai gateway.
|
||||
</Text>
|
||||
<Button
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* Reports — cost breakdown by service (model/provider), filterable + charts.
|
||||
* Accounts — which account pays and in what order: attach an account to the
|
||||
* org or to one project, and reorder the chain commerce resolves.
|
||||
* Budgets — create/list spend budgets (real `/v1/billing/spend-alerts`).
|
||||
* Budgets — create/list spend budgets (real `/v1/billing/alerts`).
|
||||
* Invoices — invoice history + download (`/v1/billing/invoices`).
|
||||
* Subscriptions — the org's plans/status/renewal (reuses `SubscriptionsModule`).
|
||||
* Payment — saved, masked payment methods (reuses `PaymentMethodsModule`).
|
||||
|
||||
@@ -528,7 +528,7 @@ export function CapTableModule({ params }: { params: Record<string, string> }) {
|
||||
) : null}
|
||||
|
||||
{active === 'classes' ? (
|
||||
classes.phase === 'error' ? <BackendStateCard state={classes.error} onRetry={refreshClasses} hint="endpoint · GET /v1/captable/share-classes" />
|
||||
classes.phase === 'error' ? <BackendStateCard state={classes.error} onRetry={refreshClasses} hint="endpoint · GET /v1/captable/classes" />
|
||||
: classes.phase === 'ready' && classes.data.length === 0 ? (
|
||||
<EmptyState icon={Layers} title="No share classes yet" description="Create a share class (Common, Preferred) — certificates and rounds reference it." primary={{ label: 'New share class', onPress: () => setDialog({ kind: 'class' }) }} />
|
||||
) : <DataTable<ShareClass> columns={classCols} rows={classList} loading={classes.phase === 'loading'} rowKey={(c) => c.id} empty="No share classes yet." />
|
||||
|
||||
@@ -167,7 +167,10 @@ function LinkOut({ href, label }: { href: string; label?: string }) {
|
||||
<XStack
|
||||
items="center"
|
||||
gap="$1"
|
||||
tag="a"
|
||||
// `render`, not `tag`: gui 8 renamed the host-element prop, and gui drops a prop
|
||||
// it does not know without erroring — `tag="a"` type-checked, built, and shipped
|
||||
// a <div>, so this chip was an inert link that nothing reported.
|
||||
render="a"
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
{...({ href, target: '_blank', rel: 'noopener noreferrer' } as any)}
|
||||
>
|
||||
|
||||
@@ -124,10 +124,12 @@ export function ContactModule() {
|
||||
|
||||
function ChannelCard({ channel }: { channel: Channel }) {
|
||||
const { icon: Icon, title, body, href, cta } = channel
|
||||
// The Card IS the flex item — and the link, via tag="a" (Stacks pass href/target
|
||||
// The Card IS the flex item — and the link, via render="a" (Stacks pass href/target
|
||||
// through on web). Keeps layout on one node; no non-flex Anchor wrapper.
|
||||
// `render`, not gui 7's `tag`: gui drops an unknown prop silently, so `tag` rendered
|
||||
// a <div> and every contact channel — mailto included — was a dead card.
|
||||
const link = href
|
||||
? { tag: 'a' as const, href, target: href.startsWith('mailto:') ? undefined : '_blank', rel: 'noopener noreferrer', cursor: 'pointer' as const, textDecorationLine: 'none' as const }
|
||||
? { render: 'a' as const, href, target: href.startsWith('mailto:') ? undefined : '_blank', rel: 'noopener noreferrer', cursor: 'pointer' as const, textDecorationLine: 'none' as const }
|
||||
: {}
|
||||
return (
|
||||
<Card
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* feature). Wired to the REAL cloud `/v1/evals/*` facade, which proxies the
|
||||
* console's public dataset API:
|
||||
* - POST /v1/evals/datasets create a dataset
|
||||
* - POST /v1/evals/dataset-items add an item (input + expected output)
|
||||
* - POST /v1/evals/datasets/:name/items add an item (input + expected output)
|
||||
*
|
||||
* The gateway does not mount a dataset LIST route yet, so the list area attempts
|
||||
* the forward-compatible GET and renders an honest "not available here yet" card
|
||||
@@ -122,8 +122,7 @@ export function DatasetsModule(_props: { params: Record<string, string> }) {
|
||||
setAdding(true)
|
||||
setAddMsg(null)
|
||||
try {
|
||||
await EvalsApi.createDatasetItem({
|
||||
datasetName: itemDataset.trim(),
|
||||
await EvalsApi.createDatasetItem(itemDataset.trim(), {
|
||||
input: parseMaybeJson(input),
|
||||
expectedOutput: parseMaybeJson(expected),
|
||||
})
|
||||
@@ -265,7 +264,7 @@ export function DatasetItemsModule(_props: { params: Record<string, string> }) {
|
||||
|
||||
const load = useCallback(() => {
|
||||
setList({ phase: 'loading' })
|
||||
// Native dataset-items require a datasetName; fetch the org's datasets, then
|
||||
// A dataset item only exists inside a set; fetch the org's datasets, then
|
||||
// their items in parallel, and flatten — real rows, never fabricated.
|
||||
EvalsApi.listDatasets()
|
||||
.then((datasets) =>
|
||||
@@ -307,7 +306,7 @@ export function DatasetItemsModule(_props: { params: Record<string, string> }) {
|
||||
}
|
||||
/>
|
||||
{list.phase === 'error' ? (
|
||||
<BackendStateCard state={list.error} onRetry={load} hint="endpoint · GET /v1/evals/dataset-items" />
|
||||
<BackendStateCard state={list.error} onRetry={load} hint="endpoint · GET /v1/evals/datasets/:name/items" />
|
||||
) : (
|
||||
<DataTable
|
||||
columns={itemColumns}
|
||||
|
||||
@@ -1,252 +0,0 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* ML Pipelines (Kubeflow) — the read-only orchestration + control-plane view over
|
||||
* the live cloud mlsvc bridge (hanzoai/cloud `clients/ml`), which fronts the
|
||||
* Kubeflow-family CRDs as REST through the console's OWN `/training` proxy.
|
||||
*
|
||||
* This is the Kubeflow *orchestration* lens (distinct from Fine-tuning, which is
|
||||
* the "train a model on my data" wizard): a real control-plane health strip
|
||||
* (`GET /v1/train/health` — which Kubeflow operators/CRDs are actually served),
|
||||
* then the two orchestration objects the bridge exposes —
|
||||
* - Pipelines = Katib Experiments (`GET /v1/train/experiments`)
|
||||
* - Runs = trainer TrainJobs (`GET /v1/train/jobs`)
|
||||
* both fetched via the shared `TrainApi` (one client, no duplication).
|
||||
*
|
||||
* Honest by construction: EVERY row is a real backend object. A load failure
|
||||
* renders a truthful `BackendStateCard` (503 not-initialized · 404 not-routed ·
|
||||
* 401/403 session/access) instead of placeholder rows; a reachable-but-empty
|
||||
* plane renders an honest empty state. Nothing is fabricated — the console has
|
||||
* no Kubeflow Notebook/Pipeline-CRD surface, so none is invented here.
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Button, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Blocks, Play, RefreshCw, Workflow } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { TrainApi, type TrainExperiment, type TrainJob } from '~/lib/api'
|
||||
import { KubeflowApi, KUBEFLOW_OPERATORS, type KubeflowControlPlane } from '~/lib/api/kubeflow'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { DataTable, type Column } from '~/components/ui/DataTable'
|
||||
import { StatusTag } from '~/components/ui/StatusTag'
|
||||
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
|
||||
|
||||
type Async<T> = { phase: 'loading' } | { phase: 'error'; error: BackendState } | { phase: 'ready'; data: T }
|
||||
|
||||
const TABS = [
|
||||
{ id: 'pipelines', label: 'Pipelines', icon: Workflow },
|
||||
{ id: 'runs', label: 'Runs', icon: Play },
|
||||
] as const
|
||||
type TabId = (typeof TABS)[number]['id']
|
||||
|
||||
const DASH = '—'
|
||||
const fmtDate = (v?: string) => {
|
||||
if (!v) return DASH
|
||||
const d = new Date(v)
|
||||
return Number.isNaN(d.getTime()) ? v : d.toLocaleString()
|
||||
}
|
||||
const fmtNum = (n?: number, digits = 3) => (typeof n === 'number' && Number.isFinite(n) ? n.toFixed(digits) : DASH)
|
||||
|
||||
// ── Control-plane strip ───────────────────────────────────────────────────────
|
||||
// A compact, honest one-liner: whether the Kubeflow control plane is reporting,
|
||||
// and which operators (Trainer/Katib) are live. The lists below carry the
|
||||
// primary state, so this stays understated — never a big error card.
|
||||
function ControlPlaneStrip({ cp }: { cp: Async<KubeflowControlPlane> }) {
|
||||
if (cp.phase !== 'ready') return null
|
||||
const { healthy, k8s, error } = cp.data
|
||||
const reporting = k8s
|
||||
const dot = healthy ? '$green10' : reporting ? '$yellow10' : '$color8'
|
||||
const label = healthy
|
||||
? 'Kubeflow control plane · connected'
|
||||
: reporting
|
||||
? 'Kubeflow control plane · degraded'
|
||||
: 'Kubeflow control plane · not reporting'
|
||||
|
||||
return (
|
||||
<XStack
|
||||
items="center"
|
||||
gap="$3"
|
||||
px="$3"
|
||||
py="$2"
|
||||
rounded="$4"
|
||||
bg="$color2"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
flexWrap="wrap"
|
||||
>
|
||||
<XStack items="center" gap="$2">
|
||||
<YStack width={8} height={8} rounded="$10" bg={dot} />
|
||||
<Text fontSize="$2" color="$color11">
|
||||
{label}
|
||||
</Text>
|
||||
</XStack>
|
||||
{reporting ? (
|
||||
<XStack items="center" gap="$2" flexWrap="wrap">
|
||||
{KUBEFLOW_OPERATORS.map((op) => {
|
||||
const up = cp.data.crds[op.crd] === true
|
||||
return (
|
||||
<XStack key={op.crd} items="center" gap="$1.5">
|
||||
<YStack width={6} height={6} rounded="$10" bg={up ? '$green10' : '$color8'} />
|
||||
<Text fontSize="$1" color="$color10">
|
||||
{op.label}
|
||||
</Text>
|
||||
<Text fontSize="$1" color="$color9" style={{ fontFamily: 'monospace' }}>
|
||||
{op.crd}
|
||||
</Text>
|
||||
</XStack>
|
||||
)
|
||||
})}
|
||||
</XStack>
|
||||
) : error ? (
|
||||
<Text fontSize="$1" color="$color10" numberOfLines={1}>
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Column sets ───────────────────────────────────────────────────────────────
|
||||
|
||||
const pipelineColumns: Column<TrainExperiment>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Pipeline',
|
||||
render: (e) => (
|
||||
<YStack minW={0}>
|
||||
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>
|
||||
{e.name || e.id}
|
||||
</Text>
|
||||
<Text fontSize="$1" color="$color10" numberOfLines={1} style={{ fontFamily: 'monospace' }}>
|
||||
{e.id}
|
||||
</Text>
|
||||
</YStack>
|
||||
),
|
||||
},
|
||||
{ key: 'job', header: 'Job', width: 160, render: (e) => <Text fontSize="$3" color="$color11" numberOfLines={1}>{e.jobId || DASH}</Text> },
|
||||
{ key: 'trials', header: 'Trials', width: 90, render: (e) => <Text fontSize="$3" color="$color11">{e.metrics.length || DASH}</Text> },
|
||||
{ key: 'val', header: 'val_loss', width: 100, render: (e) => <Text fontSize="$3" color="$color11">{fmtNum(e.finalValLoss)}</Text> },
|
||||
{ key: 'status', header: 'Status', width: 110, render: (e) => <StatusTag status={e.status ?? 'unknown'} /> },
|
||||
{ key: 'created', header: 'Created', width: 170, render: (e) => <Text fontSize="$3" color="$color11" numberOfLines={1}>{fmtDate(e.createdAt)}</Text> },
|
||||
]
|
||||
|
||||
const runColumns: Column<TrainJob>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Run',
|
||||
render: (j) => (
|
||||
<YStack minW={0}>
|
||||
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>
|
||||
{j.name || j.id}
|
||||
</Text>
|
||||
<Text fontSize="$1" color="$color10" numberOfLines={1} style={{ fontFamily: 'monospace' }}>
|
||||
{j.id}
|
||||
</Text>
|
||||
</YStack>
|
||||
),
|
||||
},
|
||||
{ key: 'base', header: 'Base model', width: 180, render: (j) => <Text fontSize="$3" color="$color11" numberOfLines={1}>{j.baseModel || DASH}</Text> },
|
||||
{ key: 'type', header: 'Type', width: 110, render: (j) => <Text fontSize="$3" color="$color11" numberOfLines={1}>{j.type || DASH}</Text> },
|
||||
{ key: 'gpu', header: 'GPU', width: 120, render: (j) => <Text fontSize="$3" color="$color11" numberOfLines={1}>{j.gpu ? `${j.gpu}${j.gpuCount && j.gpuCount > 1 ? ` ×${j.gpuCount}` : ''}` : DASH}</Text> },
|
||||
{ key: 'status', header: 'Status', width: 110, render: (j) => <StatusTag status={j.status} /> },
|
||||
{ key: 'created', header: 'Created', width: 170, render: (j) => <Text fontSize="$3" color="$color11" numberOfLines={1}>{fmtDate(j.createdAt)}</Text> },
|
||||
]
|
||||
|
||||
// ── Module ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function KubeflowModule(_props: { params: Record<string, string> }) {
|
||||
const [cp, setCp] = useState<Async<KubeflowControlPlane>>({ phase: 'loading' })
|
||||
const [pipelines, setPipelines] = useState<Async<TrainExperiment[]>>({ phase: 'loading' })
|
||||
const [runs, setRuns] = useState<Async<TrainJob[]>>({ phase: 'loading' })
|
||||
const [tab, setTab] = useState<TabId>('pipelines')
|
||||
|
||||
const load = useCallback(() => {
|
||||
setCp({ phase: 'loading' })
|
||||
setPipelines({ phase: 'loading' })
|
||||
setRuns({ phase: 'loading' })
|
||||
// controlPlane() never throws — the .catch is a belt-and-suspenders no-op.
|
||||
KubeflowApi.controlPlane()
|
||||
.then((data) => setCp({ phase: 'ready', data }))
|
||||
.catch((e) => setCp({ phase: 'ready', data: { healthy: false, k8s: false, crds: {}, error: e instanceof Error ? e.message : String(e) } }))
|
||||
TrainApi.experiments()
|
||||
.then((data) => setPipelines({ phase: 'ready', data }))
|
||||
.catch((e) => setPipelines({ phase: 'error', error: classifyBackend(e) }))
|
||||
TrainApi.listJobs()
|
||||
.then((data) => setRuns({ phase: 'ready', data }))
|
||||
.catch((e) => setRuns({ phase: 'error', error: classifyBackend(e) }))
|
||||
}, [])
|
||||
|
||||
useEffect(() => load(), [load])
|
||||
|
||||
const count = useCallback(
|
||||
(t: TabId): number | null => {
|
||||
const src = t === 'pipelines' ? pipelines : runs
|
||||
return src.phase === 'ready' ? src.data.length : null
|
||||
},
|
||||
[pipelines, runs],
|
||||
)
|
||||
|
||||
const active = tab === 'pipelines' ? pipelines : runs
|
||||
const hint = useMemo(
|
||||
() =>
|
||||
tab === 'pipelines'
|
||||
? 'Pipelines are Katib Experiments from the Kubeflow bridge (GET /v1/train/experiments).'
|
||||
: 'Runs are trainer TrainJobs from the Kubeflow bridge (GET /v1/train/jobs).',
|
||||
[tab],
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="ML Pipelines"
|
||||
subtitle="Orchestrated training and evaluation pipelines on Kubeflow — Katib experiments and trainer runs."
|
||||
actions={
|
||||
<Button icon={<RefreshCw size={16} />} onPress={load}>
|
||||
Refresh
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<ControlPlaneStrip cp={cp} />
|
||||
|
||||
<XStack gap="$1" flexWrap="wrap">
|
||||
{TABS.map((t) => {
|
||||
const n = count(t.id)
|
||||
const Icon = t.icon
|
||||
return (
|
||||
<Button
|
||||
key={t.id}
|
||||
size="$2"
|
||||
bg={t.id === tab ? '$color5' : 'transparent'}
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
icon={<Icon size={14} />}
|
||||
onPress={() => setTab(t.id)}
|
||||
>
|
||||
{n === null ? t.label : `${t.label} · ${n}`}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</XStack>
|
||||
|
||||
{active.phase === 'error' ? (
|
||||
<BackendStateCard state={active.error} onRetry={load} hint={hint} />
|
||||
) : tab === 'pipelines' ? (
|
||||
<DataTable
|
||||
columns={pipelineColumns}
|
||||
rows={pipelines.phase === 'ready' ? pipelines.data : []}
|
||||
loading={pipelines.phase === 'loading'}
|
||||
rowKey={(e) => e.id}
|
||||
empty="No pipelines yet. Katib experiments you orchestrate appear here."
|
||||
/>
|
||||
) : (
|
||||
<DataTable
|
||||
columns={runColumns}
|
||||
rows={runs.phase === 'ready' ? runs.data : []}
|
||||
loading={runs.phase === 'loading'}
|
||||
rowKey={(j) => j.id}
|
||||
empty="No runs yet. Trainer TrainJobs appear here as they are submitted."
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -6,9 +6,9 @@
|
||||
*
|
||||
* FULL CRUD over the unified cloud binary via the same-origin user-bearer `/v1`
|
||||
* proxy, org resolved from the Bearer owner:
|
||||
* - GET /v1/load-balancers list
|
||||
* - POST /v1/load-balancers create (name + type + region)
|
||||
* - DELETE /v1/load-balancers/:id delete
|
||||
* - GET /v1/balancers list
|
||||
* - POST /v1/balancers create (name + type + region)
|
||||
* - DELETE /v1/balancers/:id delete
|
||||
*
|
||||
* When the backend doesn't serve the surface the list load fails and the honest
|
||||
* not-configured / unavailable card renders instead of an empty grid; create/delete
|
||||
@@ -66,7 +66,7 @@ export function LoadBalancerModule(_props: { params: Record<string, string> }) {
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const r = await restGet<unknown>(cloudProxyV1Url('load-balancers'))
|
||||
const r = await restGet<unknown>(cloudProxyV1Url('balancers'))
|
||||
setRows(lbsOf(r))
|
||||
setLoadError(null)
|
||||
} catch (e) {
|
||||
@@ -89,7 +89,7 @@ export function LoadBalancerModule(_props: { params: Record<string, string> }) {
|
||||
setCreating(true)
|
||||
setActionMsg(null)
|
||||
try {
|
||||
await restPost(cloudProxyV1Url('load-balancers'), { name: name.trim(), type, region })
|
||||
await restPost(cloudProxyV1Url('balancers'), { name: name.trim(), type, region })
|
||||
setActionMsg({ tone: 'ok', text: `Created load balancer "${name.trim()}".` })
|
||||
setName('')
|
||||
await load()
|
||||
@@ -104,7 +104,7 @@ export function LoadBalancerModule(_props: { params: Record<string, string> }) {
|
||||
if (typeof window !== 'undefined' && !window.confirm(`Delete load balancer "${lb.name || lb.id}"? This cannot be undone.`)) return
|
||||
setActionMsg(null)
|
||||
try {
|
||||
await restDelete(cloudProxyV1Url(`load-balancers/${enc(lb.id)}`))
|
||||
await restDelete(cloudProxyV1Url(`balancers/${enc(lb.id)}`))
|
||||
setActionMsg({ tone: 'ok', text: `Deleted load balancer "${lb.name || lb.id}".` })
|
||||
await load()
|
||||
} catch (e) {
|
||||
|
||||
@@ -40,11 +40,13 @@ import {
|
||||
groupByFamily,
|
||||
filterFamilies,
|
||||
familyOf,
|
||||
suggestedModels,
|
||||
totalModels,
|
||||
displayLabel,
|
||||
DEFAULT_MODEL,
|
||||
type FamilyGroup,
|
||||
} from '~/lib/api/families'
|
||||
import { useRecentModels } from '~/lib/models/recent'
|
||||
import { ProviderLogo } from '~/components/ui/ProviderLogo'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { Filters } from '~/components/ui/Filters'
|
||||
@@ -391,6 +393,50 @@ function CatalogSkeleton() {
|
||||
)
|
||||
}
|
||||
|
||||
/** One tappable model chip — the family mark + the model's display name. */
|
||||
function ModelChip({ m, onOpen }: { m: CatalogEntry; onOpen: () => void }) {
|
||||
return (
|
||||
<Button size="$2" icon={<ProviderLogo provider={familyOf(m).logo} size={15} />} onPress={onOpen}>
|
||||
{displayLabel(m)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
/** Recent + Suggested — the reach-first chips above the full family list. Recents
|
||||
* are the user's own trail; suggestions are one live rung per pinned family. An
|
||||
* empty section renders nothing — never a fabricated chip. */
|
||||
function ShortcutStrip({
|
||||
recents,
|
||||
suggested,
|
||||
onOpen,
|
||||
}: {
|
||||
recents: CatalogEntry[]
|
||||
suggested: CatalogEntry[]
|
||||
onOpen: (m: CatalogEntry) => void
|
||||
}) {
|
||||
if (!recents.length && !suggested.length) return null
|
||||
return (
|
||||
<XStack items="center" gap="$2" flexWrap="wrap">
|
||||
{recents.length ? (
|
||||
<Text fontSize="$1" color="$color10" fontWeight="500">
|
||||
Recent
|
||||
</Text>
|
||||
) : null}
|
||||
{recents.map((m) => (
|
||||
<ModelChip key={modelId(m)} m={m} onOpen={() => onOpen(m)} />
|
||||
))}
|
||||
{suggested.length ? (
|
||||
<Text fontSize="$1" color="$color10" fontWeight="500" ml={recents.length ? '$2' : undefined}>
|
||||
Suggested
|
||||
</Text>
|
||||
) : null}
|
||||
{suggested.map((m) => (
|
||||
<ModelChip key={modelId(m)} m={m} onOpen={() => onOpen(m)} />
|
||||
))}
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
type LoadState =
|
||||
| { phase: 'loading' }
|
||||
| { phase: 'error'; err: ApiError }
|
||||
@@ -402,7 +448,7 @@ export function ModelCatalogModule(_props: { params: Record<string, string> }) {
|
||||
const [plans, setPlans] = useState<Plan[]>([])
|
||||
const [selected, setSelected] = useState<CatalogEntry | null>(null)
|
||||
// The user's own view of this catalog, persisted with the rest of their
|
||||
// preferences — 85+ models across a dozen families is a list you narrow once and
|
||||
// preferences — the catalog across a dozen families is a list you narrow once and
|
||||
// want to find narrowed the same way, on any device. It is never hidden state:
|
||||
// the bar renders the query and offers Reset the moment anything is active.
|
||||
const list = useList('models')
|
||||
@@ -425,6 +471,23 @@ export function ModelCatalogModule(_props: { params: Record<string, string> }) {
|
||||
const groups = useMemo(() => groupByFamily(models), [models])
|
||||
const visible = useMemo(() => filterFamilies(groups, query), [groups, query])
|
||||
|
||||
// Opening a model records the use, so the Recent chips are the user's real
|
||||
// trail — the same trail chat writes when a turn is sent.
|
||||
const { recent, record } = useRecentModels()
|
||||
const openModel = useCallback(
|
||||
(m: CatalogEntry) => {
|
||||
record(modelId(m))
|
||||
setSelected(m)
|
||||
},
|
||||
[record],
|
||||
)
|
||||
const shortcuts = useMemo(() => {
|
||||
const byId = new Map(models.map((x) => [modelId(x).toLowerCase(), x]))
|
||||
// A recent id whose model left the catalog silently drops — honest absence.
|
||||
const recents = recent.map((id) => byId.get(id.toLowerCase())).filter((x): x is CatalogEntry => x !== undefined)
|
||||
return { recents, suggested: suggestedModels(groups, recents.map((x) => modelId(x))) }
|
||||
}, [models, groups, recent])
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const shown = totalModels(visible)
|
||||
const live = visible.reduce((n, g) => n + g.available, 0)
|
||||
@@ -467,6 +530,12 @@ export function ModelCatalogModule(_props: { params: Record<string, string> }) {
|
||||
{/* Search across all families — the ONE list bar, not a fourth search box. */}
|
||||
<Filters list={list} placeholder="Search models across every family…" />
|
||||
|
||||
{/* Reach first: the user's recent models, then one suggestion per house/
|
||||
flagship family — hidden while searching (the query owns the page). */}
|
||||
{state.phase === 'ready' && !query ? (
|
||||
<ShortcutStrip recents={shortcuts.recents} suggested={shortcuts.suggested} onOpen={openModel} />
|
||||
) : null}
|
||||
|
||||
{state.phase === 'loading' ? (
|
||||
<CatalogSkeleton />
|
||||
) : visible.length === 0 ? (
|
||||
@@ -480,7 +549,7 @@ export function ModelCatalogModule(_props: { params: Record<string, string> }) {
|
||||
<YStack gap="$2.5">
|
||||
{visible.map((g, i) => (
|
||||
<FadeIn key={g.id} index={i} step={40}>
|
||||
<FamilySection group={g} onOpen={setSelected} />
|
||||
<FamilySection group={g} onOpen={openModel} />
|
||||
</FadeIn>
|
||||
))}
|
||||
</YStack>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
* on this deployment (`available`) or already connected are listed, so every card
|
||||
* has a live Connect/Disconnect action — no dead-end.
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { Button, Card, Spinner, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Cable, Plug, RefreshCw, GitBranch } from '@hanzogui/lucide-icons-2'
|
||||
@@ -151,21 +151,30 @@ export function OrgIntegrationsModule(_props: { params: Record<string, string> }
|
||||
|
||||
// Handle the OAuth callback return. The provider callback 302s the browser to
|
||||
// /integrations?connected=<id>&account=<label> (success) or ?error=<id>&reason=<msg>.
|
||||
// Read the primitives (stable by value) so this runs once per return; the guard +
|
||||
// the router.replace that strips the params keep it from re-toasting on refetch.
|
||||
//
|
||||
// The latch is what makes this ONE-SHOT, and it is not belt-and-braces. Stripping
|
||||
// the params with router.replace cannot do it alone: the replace is asynchronous, so
|
||||
// every render between raising the toast and the URL actually changing still reads
|
||||
// `connected` and would toast again. This effect also calls load(), whose state
|
||||
// updates cause exactly those renders. A latch says "this return was handled" once
|
||||
// and stays true regardless of how many times the effect is re-entered, or why.
|
||||
const connectedId = search.get('connected')
|
||||
const erroredId = search.get('error')
|
||||
const account = search.get('account')
|
||||
const reason = search.get('reason')
|
||||
const handledReturn = useRef(false)
|
||||
useEffect(() => {
|
||||
if (!connectedId && !erroredId) return
|
||||
if (handledReturn.current) return
|
||||
handledReturn.current = true
|
||||
if (connectedId) {
|
||||
toast.success(`Connected ${connectedId}`, account ? `Account: ${account}` : undefined)
|
||||
load()
|
||||
} else if (erroredId) {
|
||||
toast.error(`Could not connect ${erroredId}`, reason || 'The connection could not be completed.')
|
||||
}
|
||||
// Strip the callback params so a refresh/refetch doesn't re-toast.
|
||||
// The latch covers this mount; stripping the params covers the NEXT one, so a
|
||||
// refresh or a back-navigation does not replay a connection that already happened.
|
||||
router.replace('/integrations')
|
||||
}, [connectedId, erroredId, account, reason, toast, router, load])
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
* - ADD: the card is entered ONLY in Square's cross-origin iframe (the shared
|
||||
* `useSquareCard` capability, same as Add-credits) and tokenized IN THE BROWSER.
|
||||
* We POST only the opaque single-use nonce (`token`) — the RAW PAN never touches
|
||||
* our code, servers, or logs (PCI SAQ-A). `POST /v1/billing/payment-methods`.
|
||||
* - REMOVE: a per-row detach with a confirm — `DELETE /v1/billing/payment-methods/:id`.
|
||||
* our code, servers, or logs (PCI SAQ-A). `POST /v1/billing/methods`.
|
||||
* - REMOVE: a per-row detach with a confirm — `DELETE /v1/billing/methods/:id`.
|
||||
* Commerce authorizes the delete against the caller's server-pinned subject.
|
||||
*
|
||||
* Read-only + masked by construction on display: commerce returns ONLY a
|
||||
@@ -262,15 +262,15 @@ export function PaymentMethodsModule(_props: { params: Record<string, string> })
|
||||
</Text>
|
||||
</Card>
|
||||
) : cfg.phase === 'error' ? (
|
||||
<BackendStateCard state={cfg.error} hint="endpoint · GET /v1/billing/payment-config" />
|
||||
<BackendStateCard state={cfg.error} hint="endpoint · GET /v1/billing/settings" />
|
||||
) : !configured ? (
|
||||
<Card p="$4" gap="$2" borderWidth={1} borderColor="$borderColor" maxW={560}>
|
||||
<Text fontSize="$4" fontWeight="700" color="$color12">
|
||||
Adding a card isn’t available on this deployment yet
|
||||
No card processor is configured for this organization
|
||||
</Text>
|
||||
<Text fontSize="$3" color="$color11">
|
||||
No card processor is configured for this organization. You can manage payment methods in the
|
||||
billing portal, or contact support to enable in-console cards.
|
||||
Adding a card in-console needs a payment processor connected to your organization. You can
|
||||
manage payment methods in the billing portal, or contact support to enable in-console cards.
|
||||
</Text>
|
||||
<XStack>
|
||||
<Button size="$3" iconAfter={<ExternalLink size={14} />} onPress={openBillingPortal}>
|
||||
@@ -348,7 +348,7 @@ export function PaymentMethodsModule(_props: { params: Record<string, string> })
|
||||
) : null}
|
||||
|
||||
{methods.phase === 'error' ? (
|
||||
<BackendStateCard state={methods.error} onRetry={load} hint="endpoint · GET /v1/billing/payment-methods" />
|
||||
<BackendStateCard state={methods.error} onRetry={load} hint="endpoint · GET /v1/billing/methods" />
|
||||
) : (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
|
||||
@@ -6,18 +6,18 @@
|
||||
* Reads the REAL account from the session (`get-account`): identity, email, org,
|
||||
* and role. Identity mutations (name, password, 2FA) are owned by Hanzo IAM — this
|
||||
* deep-links there rather than re-implementing them (never a plaintext password
|
||||
* here). The API Keys tab embeds the shared per-user `hk-` credential surface.
|
||||
* here). The API Keys tab embeds the shared per-user `sk-` credential surface.
|
||||
* Reached from the footer wallet's user row; also carries Sign out.
|
||||
*/
|
||||
import { SubNav } from '~/components/ui/SubNav'
|
||||
import { productSubpageSlug } from '~/lib/products/match'
|
||||
import { useState } from 'react'
|
||||
import { useRef, useState } from 'react'
|
||||
import { Avatar, Button, Card, Input, Spinner, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Check, Copy, ExternalLink, KeyRound, LogOut, ShieldCheck } from '@hanzogui/lucide-icons-2'
|
||||
import { Camera, Check, Copy, ExternalLink, KeyRound, LogOut, ShieldCheck } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { config } from '~/config'
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
import { ApiError } from '~/lib/api'
|
||||
import { AccountApi, ApiError } from '~/lib/api'
|
||||
import { MfaApi, type MfaSetup } from '~/lib/api/mfa'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { FieldRow } from '~/components/ui/Field'
|
||||
@@ -53,24 +53,124 @@ function ManageInIam({ label = 'Edit in IAM' }: { label?: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The profile photo, and the control that changes it.
|
||||
*
|
||||
* The photo used to be read-only here — the card rendered `avatar` and offered
|
||||
* "Edit in IAM", which links to an IAM that has no way to set one either (its
|
||||
* only writers are federation and SCIM). So a password signup had a monogram and
|
||||
* no way out of it.
|
||||
*
|
||||
* DOWNSCALED IN THE BROWSER before upload. A phone original is several MB and
|
||||
* would be served to every viewer of every page that shows this face; 512px is
|
||||
* larger than any surface renders it. The server still caps the body — this is
|
||||
* the courtesy, not the guard. A source the canvas cannot decode is sent
|
||||
* verbatim rather than dropped, and the server's format check is the authority.
|
||||
*/
|
||||
const PHOTO_EDGE = 512
|
||||
|
||||
async function downscale(file: File): Promise<Blob> {
|
||||
const bitmap = await createImageBitmap(file)
|
||||
try {
|
||||
const scale = Math.min(1, PHOTO_EDGE / Math.max(bitmap.width, bitmap.height))
|
||||
if (scale === 1) return file
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = Math.round(bitmap.width * scale)
|
||||
canvas.height = Math.round(bitmap.height * scale)
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return file
|
||||
ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height)
|
||||
// PNG keeps transparency and is on the server's allow-list. A canvas that
|
||||
// refuses to encode yields null — send the original rather than nothing.
|
||||
const out = await new Promise<Blob | null>((r) => canvas.toBlob(r, 'image/png'))
|
||||
return out ?? file
|
||||
} finally {
|
||||
bitmap.close()
|
||||
}
|
||||
}
|
||||
|
||||
function PhotoCard() {
|
||||
const { account, reload } = useSession()
|
||||
const name = account?.displayName || account?.name || 'Account'
|
||||
const [photo, setPhoto] = useState<string | undefined>(
|
||||
typeof account?.avatar === 'string' ? account.avatar : undefined,
|
||||
)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
const picker = useRef<HTMLInputElement>(null)
|
||||
|
||||
const choose = async (file: File | undefined) => {
|
||||
if (!file) return
|
||||
setBusy(true)
|
||||
setErr(null)
|
||||
try {
|
||||
let body: File = file
|
||||
try {
|
||||
const small = await downscale(file)
|
||||
body = small === file ? file : new File([small], 'photo.png', { type: 'image/png' })
|
||||
} catch {
|
||||
/* undecodable here — let the server judge the original */
|
||||
}
|
||||
const url = await AccountApi.setAvatar(body)
|
||||
// Show the new photo immediately; the URL is content-addressed, so this can
|
||||
// never be a stale cache of the old one.
|
||||
setPhoto(url)
|
||||
// And re-read the session so every other surface in this tab agrees.
|
||||
void reload()
|
||||
} catch (e) {
|
||||
setErr(e instanceof ApiError ? e.message : 'Could not update your photo.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
if (picker.current) picker.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<YStack gap="$2">
|
||||
<XStack gap="$3" items="center">
|
||||
<Avatar circular size={56}>
|
||||
{photo ? <Avatar.Image accessibilityLabel={name} src={photo} /> : null}
|
||||
<Avatar.Fallback bg="$color5" items="center" justify="center">
|
||||
<Text fontSize="$5" fontWeight="800" color="$color12">{initials(name)}</Text>
|
||||
</Avatar.Fallback>
|
||||
</Avatar>
|
||||
<YStack flex={1}>
|
||||
<Text fontSize="$6" fontWeight="800" color="$color12">{name}</Text>
|
||||
<Text fontSize="$3" color="$color11">{account?.email || '—'}</Text>
|
||||
</YStack>
|
||||
<Button
|
||||
size="$2"
|
||||
disabled={busy}
|
||||
icon={busy ? <Spinner size="small" /> : <Camera size={15} />}
|
||||
onPress={() => picker.current?.click()}
|
||||
>
|
||||
{photo ? 'Change photo' : 'Add photo'}
|
||||
</Button>
|
||||
</XStack>
|
||||
{/* The file input is the real control; the Button is its label. Kept in the
|
||||
DOM (not display:none) so assistive tech can still reach it. */}
|
||||
<input
|
||||
ref={picker}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
aria-label="Profile photo"
|
||||
style={{ position: 'absolute', width: 1, height: 1, opacity: 0, pointerEvents: 'none' }}
|
||||
onChange={(e) => void choose(e.target.files?.[0])}
|
||||
/>
|
||||
{err ? <Text fontSize="$2" color="$red10">{err}</Text> : null}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
function AccountTab() {
|
||||
const { account, signOut } = useSession()
|
||||
const name = account?.displayName || account?.name || 'Account'
|
||||
const avatar = typeof account?.avatar === 'string' ? account.avatar : undefined
|
||||
|
||||
return (
|
||||
<YStack gap="$5">
|
||||
<Card p="$4" gap="$4" borderWidth={1} borderColor="$borderColor" maxWidth={720}>
|
||||
<XStack gap="$3" items="center">
|
||||
<Avatar circular size={56}>
|
||||
{avatar ? <Avatar.Image accessibilityLabel={name} src={avatar} /> : null}
|
||||
<Avatar.Fallback bg="$color5" items="center" justify="center">
|
||||
<Text fontSize="$5" fontWeight="800" color="$color12">{initials(name)}</Text>
|
||||
</Avatar.Fallback>
|
||||
</Avatar>
|
||||
<YStack flex={1}>
|
||||
<Text fontSize="$6" fontWeight="800" color="$color12">{name}</Text>
|
||||
<Text fontSize="$3" color="$color11">{account?.email || '—'}</Text>
|
||||
<PhotoCard />
|
||||
</YStack>
|
||||
<ManageInIam />
|
||||
</XStack>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Score Configs — list of score definitions (HIP-0106), native on @hanzo/gui.
|
||||
*
|
||||
* A score config defines a score's data type and its valid range (numeric) or
|
||||
* categories (categorical/boolean). Reads the REAL `/v1/o11y/score-configs`
|
||||
* categories (categorical/boolean). Reads the REAL `/v1/evals/rubrics`
|
||||
* surface; when the runtime is not initialized (503) or unrouted (404) it shows
|
||||
* an honest RuntimeNotice — never fabricated configs. Read-only here; configs are
|
||||
* authored where scores are recorded.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@
|
||||
* - POST /v1/webhooks create → the row incl. `secret` (reveal-once)
|
||||
* - PATCH /v1/webhooks/:id enable/disable + edit
|
||||
* - DELETE /v1/webhooks/:id remove
|
||||
* - POST /v1/webhooks/:id/rotate-secret → { secret } (reveal-once)
|
||||
* - POST /v1/webhooks/:id/secret → { secret } (reveal-once)
|
||||
* - POST /v1/webhooks/:id/test → { delivered, httpStatus, durationMs, error? }
|
||||
* - GET /v1/webhooks/:id/deliveries → recent attempts (newest-first)
|
||||
*
|
||||
@@ -436,7 +436,7 @@ export function WebhooksModule({ params }: { params: Record<string, string> }) {
|
||||
setBusyId(w.id)
|
||||
setActionMsg(null)
|
||||
try {
|
||||
const res = await restPost<{ secret?: string }>(cloudProxyV1Url(`webhooks/${enc(w.id)}/rotate-secret`))
|
||||
const res = await restPost<{ secret?: string }>(cloudProxyV1Url(`webhooks/${enc(w.id)}/secret`))
|
||||
const secret = str(res?.secret)
|
||||
if (secret) setRevealed({ title: 'New signing secret', url: w.url, secret })
|
||||
setActionMsg({ tone: 'ok', text: 'Signing secret rotated.' })
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* 2. CAPS — oversight + override of ANY org's usage caps: pick a target org, list its
|
||||
* spend caps (real threshold / period-spend meter / hard-cap vs alert / rate limit /
|
||||
* when it resets), and create / edit / delete a cap on that org's behalf. Backed by
|
||||
* `GET/POST/PATCH/DELETE /v1/admin/spend-caps?org=<slug>`.
|
||||
* `GET/POST/PATCH/DELETE /v1/admin/caps?org=<slug>`.
|
||||
*
|
||||
* All reads/writes terminate at the GLOBAL-ADMIN-GATED `app/admin/aggregate` proxy
|
||||
* (`getAdminGate`, fail-closed 403, then a minted user bearer + same-origin CSRF), and
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
import { useIsSuperAdmin } from '~/lib/auth/admin'
|
||||
import { ApiError } from '~/lib/api'
|
||||
import { AdminPromosApi, type PlatformPromo } from '~/lib/api/admin-promos'
|
||||
import { AdminSpendCapsApi, type AdminSpendCap } from '~/lib/api/admin-spend-caps'
|
||||
import { AdminCapsApi, type AdminCap } from '~/lib/api/admin-caps'
|
||||
import { fmtInt, fmtUsd } from '~/lib/api/functions'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { EmptyState } from '~/components/ui/EmptyState'
|
||||
@@ -341,7 +341,7 @@ function CapFields({ form, setForm, disabled }: { form: BudgetForm; setForm: (f:
|
||||
)
|
||||
}
|
||||
|
||||
function CapCard({ org, cap, onChanged }: { org: string; cap: AdminSpendCap; onChanged: () => void }) {
|
||||
function CapCard({ org, cap, onChanged }: { org: string; cap: AdminCap; onChanged: () => void }) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [form, setForm] = useState<BudgetForm>(() => formForAlert(cap))
|
||||
const [saving, setSaving] = useState(false)
|
||||
@@ -367,7 +367,7 @@ function CapCard({ org, cap, onChanged }: { org: string; cap: AdminSpendCap; onC
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
await AdminSpendCapsApi.update(org, cap.id, {
|
||||
await AdminCapsApi.update(org, cap.id, {
|
||||
title: v.title,
|
||||
thresholdCents: v.thresholdCents,
|
||||
project: v.project,
|
||||
@@ -391,7 +391,7 @@ function CapCard({ org, cap, onChanged }: { org: string; cap: AdminSpendCap; onC
|
||||
setRemoving(true)
|
||||
setError(null)
|
||||
try {
|
||||
await AdminSpendCapsApi.remove(org, cap.id)
|
||||
await AdminCapsApi.remove(org, cap.id)
|
||||
onChanged()
|
||||
} catch (e) {
|
||||
const a = asApiError(e)
|
||||
@@ -499,7 +499,7 @@ function AddCapForm({ org, onDone, onCancel }: { org: string; onDone: () => void
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
await AdminSpendCapsApi.create(org, {
|
||||
await AdminCapsApi.create(org, {
|
||||
title: v.title,
|
||||
thresholdCents: v.thresholdCents,
|
||||
project: v.project,
|
||||
@@ -542,7 +542,7 @@ function AddCapForm({ org, onDone, onCancel }: { org: string; onDone: () => void
|
||||
function CapsTab() {
|
||||
const [orgInput, setOrgInput] = useState('')
|
||||
const [org, setOrg] = useState('')
|
||||
const [state, setState] = useState<Async<AdminSpendCap[]> | null>(null)
|
||||
const [state, setState] = useState<Async<AdminCap[]> | null>(null)
|
||||
const [adding, setAdding] = useState(false)
|
||||
|
||||
const load = useCallback((slug: string) => {
|
||||
@@ -551,7 +551,7 @@ function CapsTab() {
|
||||
setOrg(s)
|
||||
setAdding(false)
|
||||
setState({ phase: 'loading' })
|
||||
AdminSpendCapsApi.list(s)
|
||||
AdminCapsApi.list(s)
|
||||
.then((data) => setState({ phase: 'ready', data }))
|
||||
.catch((e) => setState({ phase: 'error', err: asApiError(e) }))
|
||||
}, [])
|
||||
|
||||
@@ -154,7 +154,7 @@ export function InfraModule({ params }: { params: Record<string, string> }) {
|
||||
if (tab === 'clusters') return <ClustersTab data={data} loading={loading} />
|
||||
if (tab === 'nodes') return <NodesTab data={data} loading={loading} reload={() => void load(true)} toast={toast} />
|
||||
if (tab === 'volumes') return <VolumesTab data={data} loading={loading} reload={() => void load(true)} toast={toast} />
|
||||
if (tab === 'load-balancers') return <LoadBalancersTab data={data} loading={loading} />
|
||||
if (tab === 'balancers') return <LoadBalancersTab data={data} loading={loading} />
|
||||
if (tab === 'audit') return <AuditTab data={data} loading={loading} />
|
||||
return <OverviewTab data={data} loading={loading} />
|
||||
})()
|
||||
@@ -448,7 +448,7 @@ const TONE_COLOR = { green: '$green11', yellow: '$yellow11', red: '$red11', neut
|
||||
|
||||
// fillOf joins the block-storage read onto an inventory volume. The two backends
|
||||
// answer different questions about the SAME object — /v1/admin/infra knows whether a
|
||||
// volume is referenced (and therefore safe to delete), /v1/admin/block-storage knows
|
||||
// volume is referenced (and therefore safe to delete), /v1/admin/volumes knows
|
||||
// how full it is — so the board reads both and shows one row. Two boards for one
|
||||
// noun is what this replaces; two READS for one row is fine, and each degrades on
|
||||
// its own (no fill data → an honest em-dash, never a fabricated 0%).
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
/**
|
||||
* Budgets & limits — view and set per-scope SPEND CAPS and RATE LIMITS over the REAL
|
||||
* commerce spend-alerts API (`GET/POST/PATCH/DELETE /v1/billing/spend-alerts`, the
|
||||
* commerce alerts API (`GET/POST/PATCH/DELETE /v1/billing/alerts`, the
|
||||
* user-group endpoints billing.hanzo.ai itself uses), scoped to the caller's OWN
|
||||
* subject by the `/billing` proxy (server-pinned — a caller only ever sees/edits their
|
||||
* own budgets).
|
||||
@@ -586,7 +586,7 @@ export function BillingBudgets(_props: { params: Record<string, string> }) {
|
||||
/>
|
||||
|
||||
{state.phase === 'error' ? (
|
||||
<BackendStateCard state={state.error} onRetry={load} hint="endpoint · GET /v1/billing/spend-alerts" />
|
||||
<BackendStateCard state={state.error} onRetry={load} hint="endpoint · GET /v1/billing/alerts" />
|
||||
) : state.phase === 'loading' ? (
|
||||
<LoadingCards />
|
||||
) : (
|
||||
|
||||
@@ -130,15 +130,15 @@ export function BillingCredits(_props: { params: Record<string, string> }) {
|
||||
</Text>
|
||||
</Card>
|
||||
) : cfg.phase === 'error' ? (
|
||||
<BackendStateCard state={cfg.error} hint="endpoint · GET /v1/billing/payment-config" />
|
||||
<BackendStateCard state={cfg.error} hint="endpoint · GET /v1/billing/settings" />
|
||||
) : !configured ? (
|
||||
<Card p="$4" gap="$2" borderWidth={1} borderColor="$borderColor">
|
||||
<Text fontSize="$4" fontWeight="700" color="$color12">
|
||||
Card top-up isn’t available on this deployment yet
|
||||
No card processor is configured for this organization
|
||||
</Text>
|
||||
<Text fontSize="$3" color="$color11">
|
||||
No card processor is configured for this organization. You can top up with crypto below,
|
||||
or contact support to enable card payments.
|
||||
Card top-up needs a payment processor connected to your organization. You can top up with
|
||||
crypto below, or contact support to enable card payments.
|
||||
</Text>
|
||||
</Card>
|
||||
) : submit.state === 'done' ? (
|
||||
|
||||
@@ -25,6 +25,7 @@ import { EVENTS } from '@hanzo/event'
|
||||
|
||||
import { AiApi, PlaygroundApi, type ChatMessage } from '~/lib/api'
|
||||
import { DEFAULT_MODEL } from '~/lib/api/families'
|
||||
import { useRecentModels } from '~/lib/models/recent'
|
||||
import { hanzoAssistantSystemPrompt, ASSISTANT_DOCS_STORE } from '~/lib/assistant'
|
||||
import { useVoice } from '~/lib/voice'
|
||||
import { useIsSuperAdmin } from '~/lib/auth/admin'
|
||||
@@ -187,6 +188,7 @@ export function ChatConversation({
|
||||
voiceSignal?: number
|
||||
}) {
|
||||
const analytics = useAnalytics()
|
||||
const { record } = useRecentModels()
|
||||
const [model, setModel] = useState('')
|
||||
const [messages, setMessages] = useState<(ChatMessage & { time?: string })[]>([])
|
||||
const [input, setInput] = useState('')
|
||||
@@ -201,9 +203,9 @@ export function ChatConversation({
|
||||
const showAdmin = useIsSuperAdmin()
|
||||
const system = useMemo(() => hanzoAssistantSystemPrompt({ showAdmin }), [showAdmin])
|
||||
|
||||
// Default to the trial-safe Zen default once the catalog loads: prefer the
|
||||
// Default to the trial-safe house default once the catalog loads: prefer the
|
||||
// NON-PREMIUM `DEFAULT_MODEL` (the same default the Models page pills and the
|
||||
// Playground pick), then any Zen model, then the first — so a $5-trial user's
|
||||
// Playground pick), then any Enso model, then the first — so a $5-trial user's
|
||||
// first message never 402s on a premium default.
|
||||
useEffect(() => {
|
||||
let live = true
|
||||
@@ -214,7 +216,7 @@ export function ChatConversation({
|
||||
(m) =>
|
||||
m ||
|
||||
ids.find((x) => x.toLowerCase() === DEFAULT_MODEL) ||
|
||||
ids.find((x) => /zen/i.test(x)) ||
|
||||
ids.find((x) => /enso/i.test(x)) ||
|
||||
ids[0],
|
||||
)
|
||||
})
|
||||
@@ -319,6 +321,8 @@ export function ChatConversation({
|
||||
// First turn of a conversation starts a chat; every turn is a message sent.
|
||||
if (history.length === 0) analytics.capture(EVENTS.CHAT_STARTED)
|
||||
analytics.capture(EVENTS.CHAT_MESSAGE_SENT)
|
||||
// A sent turn is a real use — it feeds the Models page's Recent chips.
|
||||
if (model) record(model)
|
||||
setMessages((m) => [...m, { role: 'user', content: q, time: turnTime() }])
|
||||
setInput('')
|
||||
const reply = await streamReply(q, history)
|
||||
@@ -404,7 +408,7 @@ export function ChatConversation({
|
||||
) : (
|
||||
<PageHeader
|
||||
title="Chat"
|
||||
subtitle={`Talk to ${model || 'Zen'} and other models — real completions through the gateway.`}
|
||||
subtitle={`Talk to ${model || 'Enso'} and other models — real completions through the gateway.`}
|
||||
actions={
|
||||
<XStack gap="$2">
|
||||
<Button size="$2" icon={<History size={15} />} onPress={onShowHistory}>
|
||||
@@ -435,7 +439,7 @@ export function ChatConversation({
|
||||
</Text>
|
||||
<Text fontSize="$3" color="$color11" text="center" lineHeight={22}>
|
||||
Ask about {config.brandName} — models, GPUs, data, deploys, billing — or anything
|
||||
else. Answers come from the live {model || 'Zen'} gateway, billed to your
|
||||
else. Answers come from the live {model || 'Enso'} gateway, billed to your
|
||||
organization.
|
||||
</Text>
|
||||
</YStack>
|
||||
@@ -512,7 +516,7 @@ export function ChatConversation({
|
||||
ref={inputRef as never}
|
||||
value={input}
|
||||
onChangeText={setInput}
|
||||
placeholder={`Message ${model || 'the assistant'}…`}
|
||||
placeholder="Ask anything. Do anything."
|
||||
numberOfLines={compact ? 2 : 3}
|
||||
disabled={sending}
|
||||
borderWidth={0}
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* CommerceResource — the ONE list surface every Commerce store page renders (Products,
|
||||
* Orders, Customers, Inventory, Promotions). It owns the fetch → honest loading / empty
|
||||
* / error lifecycle (identical to the Subscriptions/Functions modules) so each page is
|
||||
* just a title + a `load()` + its columns; there is one way to render a store list.
|
||||
*
|
||||
* `load` is held in a ref so a page can pass an inline `() => CommerceApi.products()`
|
||||
* without re-triggering the mount effect — the list fetches once and on explicit
|
||||
* Refresh. Every row is real (from the `/commerce` user-bearer proxy, org-scoped
|
||||
* server-side); an empty store shows the honest empty copy, never a placeholder row.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { Button, XStack } from '@hanzo/gui'
|
||||
import { RefreshCw } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { DataTable, type Column } from '~/components/ui/DataTable'
|
||||
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
|
||||
import type { CommerceList } from '~/lib/api/commerce'
|
||||
|
||||
type Async<T> = { phase: 'loading' } | { phase: 'error'; error: BackendState } | { phase: 'ready'; data: T }
|
||||
|
||||
export function CommerceResource<T>({
|
||||
title,
|
||||
subtitle,
|
||||
load,
|
||||
columns,
|
||||
rowKey,
|
||||
empty,
|
||||
hint,
|
||||
actions,
|
||||
}: {
|
||||
title: string
|
||||
subtitle: string
|
||||
/** Real per-org fetch (via the `/commerce` proxy). Identity may change per render. */
|
||||
load: () => Promise<CommerceList<T>>
|
||||
columns: Column<T>[]
|
||||
rowKey: (row: T) => string
|
||||
empty: string
|
||||
/** BackendStateCard hint — the real endpoint, e.g. `GET /v1/product`. */
|
||||
hint: string
|
||||
/** Optional extra header actions (given the reload fn), left of Refresh. */
|
||||
actions?: (reload: () => void) => ReactNode
|
||||
}) {
|
||||
const [state, setState] = useState<Async<T[]>>({ phase: 'loading' })
|
||||
const loadRef = useRef(load)
|
||||
loadRef.current = load
|
||||
|
||||
const reload = useCallback(() => {
|
||||
setState({ phase: 'loading' })
|
||||
loadRef
|
||||
.current()
|
||||
.then((r) => setState({ phase: 'ready', data: r.rows }))
|
||||
.catch((e) => setState({ phase: 'error', error: classifyBackend(e) }))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
reload()
|
||||
}, [reload])
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
actions={
|
||||
<XStack gap="$2">
|
||||
{actions?.(reload)}
|
||||
<Button size="$2" icon={<RefreshCw size={15} />} onPress={reload}>
|
||||
Refresh
|
||||
</Button>
|
||||
</XStack>
|
||||
}
|
||||
/>
|
||||
|
||||
{state.phase === 'error' ? (
|
||||
<BackendStateCard state={state.error} onRetry={reload} hint={hint} />
|
||||
) : (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={state.phase === 'ready' ? state.data : []}
|
||||
loading={state.phase === 'loading'}
|
||||
rowKey={rowKey}
|
||||
empty={empty}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -35,7 +35,7 @@ import { FieldRow, FieldText } from '~/components/ui/Field'
|
||||
import { StatusTag } from '~/components/ui/StatusTag'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
|
||||
import { CommerceResource } from './CommerceResource'
|
||||
import { CommerceResource } from '@hanzo/ui/product'
|
||||
import { discountValue, humanizeStatus } from './logic'
|
||||
|
||||
const Cell = ({ children }: { children: ReactNode }) => (
|
||||
|
||||
@@ -66,8 +66,8 @@ function EndpointDetailBody({ endpoint, records, hasLedger }: { endpoint: Endpoi
|
||||
|
||||
const call =
|
||||
endpoint.kind === 'deployed' && endpoint.url
|
||||
? `curl ${endpoint.url}/v1/models/${endpoint.name}:predict \\\n -H "Authorization: Bearer hk-..." \\\n -d '{"instances": [ ... ]}'`
|
||||
: `curl https://api.${config.iamOrgName === 'hanzo' ? 'hanzo.ai' : `${config.iamOrgName}.cloud`}/v1/chat/completions \\\n -H "Authorization: Bearer hk-..." \\\n -d '{"model": "${endpoint.name}", "messages": [{"role":"user","content":"Hello"}]}'`
|
||||
? `curl ${endpoint.url}/v1/models/${endpoint.name}:predict \\\n -H "Authorization: Bearer sk-..." \\\n -d '{"instances": [ ... ]}'`
|
||||
: `curl https://api.${config.iamOrgName === 'hanzo' ? 'hanzo.ai' : `${config.iamOrgName}.cloud`}/v1/chat/completions \\\n -H "Authorization: Bearer sk-..." \\\n -d '{"model": "${endpoint.name}", "messages": [{"role":"user","content":"Hello"}]}'`
|
||||
|
||||
return (
|
||||
<ScrollView>
|
||||
|
||||
@@ -87,7 +87,7 @@ export function LaunchDrawer({
|
||||
const [phase, setPhase] = useState<'idle' | 'launching'>('idle')
|
||||
const [error, setError] = useState<{ msg: string; needsPay: boolean } | null>(null)
|
||||
// Card-on-file gate — GPUs ONLY. `null` = still checking; a CPU machine is never
|
||||
// card-gated (treated as satisfied). FAIL CLOSED: if the payment-methods read errors
|
||||
// card-gated (treated as satisfied). FAIL CLOSED: if the saved-methods read errors
|
||||
// we treat it as "no card" so a GPU launch is BLOCKED (never a silent credit fallback).
|
||||
const [hasCard, setHasCard] = useState<boolean | null>(kind === 'gpu' ? null : true)
|
||||
// The org's spendable CREDIT balance (cents) — funds a CPU/non-GPU launch. `null`
|
||||
|
||||
@@ -75,7 +75,7 @@ export const OVERVIEW_SPECS: Record<string, OverviewSpec> = {
|
||||
health: { kind: 'platform-app', service: 'gateway' },
|
||||
facts: [
|
||||
{ label: 'Base URL', value: 'api.hanzo.ai/v1' },
|
||||
{ label: 'Auth', value: 'IAM bearer / hk- key' },
|
||||
{ label: 'Auth', value: 'IAM bearer / sk- key' },
|
||||
{ label: 'Surface', value: 'OpenAI-compatible /v1' },
|
||||
],
|
||||
actions: [
|
||||
@@ -237,7 +237,7 @@ export const OVERVIEW_SPECS: Record<string, OverviewSpec> = {
|
||||
facts: [
|
||||
{ label: 'Base URL', value: 'api.hanzo.ai/v1' },
|
||||
{ label: 'Spec', value: 'OpenAPI 3' },
|
||||
{ label: 'Auth', value: 'Bearer (IAM / hk-)' },
|
||||
{ label: 'Auth', value: 'Bearer (IAM / sk-)' },
|
||||
],
|
||||
actions: [
|
||||
{ label: 'Create API key', to: '/api-keys', icon: 'key' },
|
||||
@@ -250,8 +250,8 @@ export const OVERVIEW_SPECS: Record<string, OverviewSpec> = {
|
||||
},
|
||||
{
|
||||
heading: 'Authentication',
|
||||
body: 'Every request carries an Authorization: Bearer header — an IAM user token or an hk- Cloud API key minted on the API Keys page.',
|
||||
code: 'Authorization: Bearer hk-...',
|
||||
body: 'Every request carries an Authorization: Bearer header — an IAM user token or an sk- Cloud API key minted on the API Keys page.',
|
||||
code: 'Authorization: Bearer sk-...',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -344,7 +344,7 @@ export const OVERVIEW_SPECS: Record<string, OverviewSpec> = {
|
||||
facts: [
|
||||
{ label: 'Output', value: 'Markdown · text · JSON' },
|
||||
{ label: 'Rendering', value: 'JS-aware' },
|
||||
{ label: 'Auth', value: 'Bearer (IAM / hk-)' },
|
||||
{ label: 'Auth', value: 'Bearer (IAM / sk-)' },
|
||||
],
|
||||
actions: [
|
||||
{ label: 'Create API key', to: '/api-keys', icon: 'key' },
|
||||
|
||||
@@ -413,7 +413,7 @@ const fmt = (n: number, dp = 0): string => new Intl.NumberFormat('en', { notatio
|
||||
|
||||
/**
|
||||
* Metrics tab — REAL per-service RED metrics from the cloud o11y surface
|
||||
* (`GET /v1/o11y/metrics?product=<slug>`): requests, error rate, and p95 latency
|
||||
* (`GET /v1/o11y/product/metrics?product=<slug>`): requests, error rate, and p95 latency
|
||||
* over a selectable window, each with its live time-series sparkline. Honest states:
|
||||
* loading; o11y-not-connected (503/404/401/403); and "connected · no telemetry yet"
|
||||
* when o11y answered but this service emits none. Per-service CPU/memory are not
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
* sent — every field present is a real field the OpenAI-compatible gateway
|
||||
* accepts, and an omitted field is genuinely omitted (gateway default), never a
|
||||
* fabricated value. The cURL targets the public gateway with a bearer placeholder
|
||||
* (`$HANZO_API_KEY`), so a developer can copy, paste a real `hk-` key, and run it.
|
||||
* (`$HANZO_API_KEY`), so a developer can copy, paste a real `sk-` key, and run it.
|
||||
*/
|
||||
import type { RunParams } from './params'
|
||||
|
||||
/** The public gateway endpoint a copied cURL targets (an `hk-` key works here). */
|
||||
/** The public gateway endpoint a copied cURL targets (an `sk-` key works here). */
|
||||
export const GATEWAY_URL = 'https://api.hanzo.ai/v1/chat/completions'
|
||||
|
||||
/** A message as shown in the preview (text or multimodal content). */
|
||||
|
||||
@@ -185,7 +185,7 @@ export function o11yServiceFor(entry: CatalogEntry): string | null {
|
||||
|
||||
/**
|
||||
* AI products that call the unified gateway — their real, product-specific config is
|
||||
* the gateway endpoint + credential (an IAM bearer or an `hk-` key). Used for the
|
||||
* the gateway endpoint + credential (an IAM bearer or an `sk-` key). Used for the
|
||||
* Settings config of the ones that don't carry a bespoke overview spec.
|
||||
*/
|
||||
const GATEWAY_AI_PRODUCTS = new Set<string>([
|
||||
@@ -237,7 +237,7 @@ export function settingsConfigFor(entry: CatalogEntry): SettingsConfig {
|
||||
return {
|
||||
facts: [
|
||||
{ label: 'Endpoint', value: 'api.hanzo.ai/v1' },
|
||||
{ label: 'Auth', value: 'Bearer — IAM token or hk- key' },
|
||||
{ label: 'Auth', value: 'Bearer — IAM token or sk- key' },
|
||||
{ label: 'Compatibility', value: 'OpenAI-compatible' },
|
||||
],
|
||||
links: [apiKeys, openSelf],
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Issue detail — the Linear-style pane (create OR view/edit) for one work item. It
|
||||
* owns the write surface (title/description/status/priority/assignee/labels), the
|
||||
* one-click status control, and the AGENT + GIT linkage that makes an issue
|
||||
* actionable: hand an issue to the coding agent (assignee + `agent` label → the
|
||||
* cloud coding seam picks it up and opens a linked PR), see the issue↔branch↔PR
|
||||
* chain (`linkedPRs`), and jump to the native git.hanzo.ai repo or the upstream
|
||||
* GitHub source. Honest states throughout — no fabricated activity, real writes over
|
||||
* `/v1/tracker`.
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Bot, ExternalLink, GitBranch, Github, Trash2, GitPullRequest, Layers } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import {
|
||||
TrackerApi,
|
||||
STATUSES,
|
||||
PRIORITIES,
|
||||
type Issue,
|
||||
type Project,
|
||||
type Status,
|
||||
type Priority,
|
||||
type NewIssue,
|
||||
} from '~/lib/api/tracker'
|
||||
import { FieldRow, FieldText, FieldTextArea, FieldSelect } from '~/components/ui/Field'
|
||||
import { PrimaryButton } from '~/components/ui/PrimaryButton'
|
||||
import { asApiError } from '~/components/ui/States'
|
||||
import { useToast } from '~/components/ui/Toast'
|
||||
import {
|
||||
STATUS_LABEL,
|
||||
PRIORITY_LABEL,
|
||||
KIND_LABEL,
|
||||
SOURCE_LABEL,
|
||||
parseLabels,
|
||||
githubUrl,
|
||||
gitHanzoUrl,
|
||||
linkedPRs,
|
||||
} from './logic'
|
||||
import { StatusIcon, KindIcon, SourceBadge, Identifier, PriorityIcon } from './atoms'
|
||||
|
||||
/** The default coding-agent ref an issue is handed to (assignee). */
|
||||
const AGENT_REF = 'hanzo'
|
||||
|
||||
function openTab(url: string | null) {
|
||||
if (url && typeof window !== 'undefined') window.open(url, '_blank', 'noopener')
|
||||
}
|
||||
|
||||
/** A labeled external-link button (GitHub / native git / PR). */
|
||||
function LinkButton({ icon, label, url }: { icon: React.ReactElement; label: string; url: string | null }) {
|
||||
if (!url) return null
|
||||
return (
|
||||
<Button size="$2" icon={icon} iconAfter={<ExternalLink size={12} opacity={0.5} />} onPress={() => openTab(url)}>
|
||||
{label}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
/** A read-only key/value meta row in the detail sidebar. */
|
||||
function Meta({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<XStack items="center" justify="space-between" gap="$3">
|
||||
<Text fontSize="$2" color="$color10">
|
||||
{label}
|
||||
</Text>
|
||||
{children}
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
/** A compact linked-PR / child-issue row inside the detail. */
|
||||
function LinkRow({ issue, onOpen }: { issue: Issue; onOpen?: (i: Issue) => void }) {
|
||||
return (
|
||||
<XStack
|
||||
items="center"
|
||||
gap="$2"
|
||||
px="$2"
|
||||
py="$1.5"
|
||||
rounded="$3"
|
||||
hoverStyle={{ bg: '$color3' }}
|
||||
cursor={onOpen ? 'pointer' : 'default'}
|
||||
onPress={() => onOpen?.(issue)}
|
||||
>
|
||||
{issue.kind === 'pr' ? <KindIcon kind="pr" size={14} /> : <StatusIcon status={issue.status} size={14} />}
|
||||
<Identifier id={issue.identifier} />
|
||||
<Text flex={1} fontSize="$2" color="$color12" numberOfLines={1}>
|
||||
{issue.title}
|
||||
</Text>
|
||||
<SourceBadge source={issue.source} />
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
export function IssueDetail({
|
||||
projectKey,
|
||||
issue,
|
||||
allIssues,
|
||||
onClose,
|
||||
onSaved,
|
||||
onDeleted,
|
||||
onOpenIssue,
|
||||
}: {
|
||||
projectKey: string
|
||||
/** null = create; an issue = view/edit. */
|
||||
issue: Issue | null
|
||||
/** the full working set, for deriving linked PRs / epic children. */
|
||||
allIssues: Issue[]
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
onDeleted: () => void
|
||||
onOpenIssue?: (i: Issue) => void
|
||||
}) {
|
||||
const toast = useToast()
|
||||
const [title, setTitle] = useState(issue?.title ?? '')
|
||||
const [description, setDescription] = useState(issue?.description ?? '')
|
||||
const [status, setStatus] = useState<Status>(issue?.status ?? 'backlog')
|
||||
const [priority, setPriority] = useState<Priority>(issue?.priority ?? 'none')
|
||||
const [assignee, setAssignee] = useState(issue?.assignee ?? '')
|
||||
const [labels, setLabels] = useState((issue?.labels ?? []).join(', '))
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const body = (): NewIssue => ({
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
status,
|
||||
priority,
|
||||
assignee: assignee.trim() || undefined,
|
||||
labels: parseLabels(labels),
|
||||
})
|
||||
|
||||
const save = async () => {
|
||||
if (!title.trim()) {
|
||||
toast.error('Title required', 'Give the issue a title.')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
try {
|
||||
if (issue) {
|
||||
await TrackerApi.updateIssue(projectKey, issue.number, body())
|
||||
toast.success('Issue updated', issue.identifier)
|
||||
} else {
|
||||
const created = await TrackerApi.createIssue(projectKey, body())
|
||||
toast.success('Issue created', created.identifier)
|
||||
}
|
||||
onSaved()
|
||||
} catch (e) {
|
||||
toast.error(issue ? 'Could not update issue' : 'Could not create issue', asApiError(e).message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
// One-click status (edit only) — PATCH just the status; the row moves group/column.
|
||||
const quickStatus = async (s: Status) => {
|
||||
setStatus(s)
|
||||
if (!issue) return
|
||||
try {
|
||||
await TrackerApi.updateIssue(projectKey, issue.number, { status: s })
|
||||
toast.success('Status updated', `${issue.identifier} → ${STATUS_LABEL[s]}`)
|
||||
onSaved()
|
||||
} catch (e) {
|
||||
toast.error('Could not update status', asApiError(e).message)
|
||||
}
|
||||
}
|
||||
|
||||
// Hand the issue to the coding agent: assignee + the `agent` label. The cloud coding
|
||||
// seam (clients/coding) picks it up, works on a native git.hanzo.ai branch, and opens
|
||||
// a linked PR row (Kind:"pr", Source:"agent", same Repo) — surfaced below once it lands.
|
||||
const handToAgent = async () => {
|
||||
if (!issue) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await TrackerApi.assignAgent(projectKey, issue.number, AGENT_REF, issue.labels)
|
||||
setAssignee(AGENT_REF)
|
||||
toast.success('Handed to agent', `${issue.identifier} → @${AGENT_REF}. A linked PR opens when the run completes.`)
|
||||
onSaved()
|
||||
} catch (e) {
|
||||
toast.error('Could not assign agent', asApiError(e).message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const remove = async () => {
|
||||
if (!issue) return
|
||||
if (typeof window !== 'undefined' && !window.confirm(`Delete ${issue.identifier}? This cannot be undone.`)) return
|
||||
try {
|
||||
await TrackerApi.deleteIssue(projectKey, issue.number)
|
||||
toast.success('Issue deleted', issue.identifier)
|
||||
onDeleted()
|
||||
} catch (e) {
|
||||
toast.error('Could not delete issue', asApiError(e).message)
|
||||
}
|
||||
}
|
||||
|
||||
const prs = issue ? linkedPRs(issue, allIssues) : []
|
||||
const children =
|
||||
issue && issue.kind === 'epic'
|
||||
? allIssues.filter((i) => i.id !== issue.id && (i.extRef === issue.identifier || i.extRef === issue.id))
|
||||
: []
|
||||
const gh = issue ? githubUrl(issue) : null
|
||||
const git = issue ? gitHanzoUrl(issue.repo) : null
|
||||
const isAgentPr = issue?.kind === 'pr' && issue.source === 'agent'
|
||||
const handedToAgent = (issue?.assignee ?? '').trim().toLowerCase() === AGENT_REF || issue?.labels.includes('agent')
|
||||
|
||||
return (
|
||||
<YStack gap="$3">
|
||||
{issue ? (
|
||||
<>
|
||||
{/* Identity header */}
|
||||
<XStack items="center" gap="$2" flexWrap="wrap">
|
||||
<Identifier id={issue.identifier} />
|
||||
<XStack items="center" gap="$1.5" px="$1.5" py="$0.5" rounded="$3" bg="$color3">
|
||||
<KindIcon kind={issue.kind} size={13} />
|
||||
<Text fontSize="$1" color="$color11" fontWeight="600">
|
||||
{KIND_LABEL[issue.kind]}
|
||||
</Text>
|
||||
</XStack>
|
||||
<SourceBadge source={issue.source} />
|
||||
</XStack>
|
||||
|
||||
{/* One-click status */}
|
||||
<YStack gap="$1.5">
|
||||
<Text fontSize="$2" color="$color10">
|
||||
Status
|
||||
</Text>
|
||||
<XStack gap="$1.5" flexWrap="wrap">
|
||||
{STATUSES.map((s) => (
|
||||
<Button
|
||||
key={s}
|
||||
size="$2"
|
||||
theme={status === s ? 'light' : undefined}
|
||||
chromeless={status !== s}
|
||||
icon={<StatusIcon status={s} size={13} />}
|
||||
onPress={() => void quickStatus(s)}
|
||||
>
|
||||
{STATUS_LABEL[s]}
|
||||
</Button>
|
||||
))}
|
||||
</XStack>
|
||||
</YStack>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<FieldRow label="Title">
|
||||
<FieldText value={title} onChange={setTitle} placeholder="Fix the login redirect" disabled={busy} />
|
||||
</FieldRow>
|
||||
<FieldRow label="Description">
|
||||
<FieldTextArea value={description} onChange={setDescription} rows={5} disabled={busy} />
|
||||
</FieldRow>
|
||||
<XStack gap="$2" flexWrap="wrap">
|
||||
<YStack flex={1} minW={160}>
|
||||
<FieldRow label="Status">
|
||||
<FieldSelect value={status} options={[...STATUSES]} onChange={(v) => setStatus(v as Status)} disabled={busy} />
|
||||
</FieldRow>
|
||||
</YStack>
|
||||
<YStack flex={1} minW={160}>
|
||||
<FieldRow label="Priority">
|
||||
<FieldSelect value={priority} options={[...PRIORITIES]} onChange={(v) => setPriority(v as Priority)} disabled={busy} />
|
||||
</FieldRow>
|
||||
</YStack>
|
||||
</XStack>
|
||||
<FieldRow label="Assignee">
|
||||
<FieldText value={assignee} onChange={setAssignee} placeholder="ada@hanzo.ai" disabled={busy} />
|
||||
</FieldRow>
|
||||
<FieldRow label="Labels">
|
||||
<FieldText value={labels} onChange={setLabels} placeholder="bug, auth, p1" disabled={busy} />
|
||||
</FieldRow>
|
||||
|
||||
{/* Save / cancel / delete */}
|
||||
<XStack gap="$2" justify="space-between" flexWrap="wrap">
|
||||
<XStack gap="$2">
|
||||
<PrimaryButton disabled={busy} onPress={() => void save()}>
|
||||
{busy ? 'Saving…' : issue ? 'Save changes' : 'Create issue'}
|
||||
</PrimaryButton>
|
||||
<Button chromeless disabled={busy} onPress={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
</XStack>
|
||||
{issue ? (
|
||||
<Button theme="red" chromeless icon={<Trash2 size={15} />} disabled={busy} onPress={() => void remove()} aria-label="Delete issue" />
|
||||
) : null}
|
||||
</XStack>
|
||||
|
||||
{/* ── Agent + git linkage (edit only) ─────────────────────────────────── */}
|
||||
{issue ? (
|
||||
<Card p="$3" gap="$3" bg="$color1" borderWidth={1} borderColor="$borderColor">
|
||||
<XStack items="center" gap="$2">
|
||||
<Bot size={15} />
|
||||
<Text fontSize="$3" fontWeight="700" color="$color12">
|
||||
Agent & git
|
||||
</Text>
|
||||
</XStack>
|
||||
|
||||
{issue.repo ? (
|
||||
<Meta label="Repo">
|
||||
<Text fontSize="$2" color="$color12" className="hz-mono">
|
||||
{issue.repo}
|
||||
</Text>
|
||||
</Meta>
|
||||
) : null}
|
||||
{isAgentPr && issue.extRef ? (
|
||||
<Meta label="Branch">
|
||||
<XStack items="center" gap="$1.5">
|
||||
<GitBranch size={13} />
|
||||
<Text fontSize="$2" color="$color12" className="hz-mono" numberOfLines={1}>
|
||||
{issue.extRef}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Meta>
|
||||
) : null}
|
||||
|
||||
<XStack gap="$2" flexWrap="wrap">
|
||||
<LinkButton icon={<GitBranch size={14} />} label="Open in git.hanzo.ai" url={git} />
|
||||
<LinkButton icon={<Github size={14} />} label="View on GitHub" url={gh} />
|
||||
</XStack>
|
||||
|
||||
{/* Hand to agent — only for non-PR work items. */}
|
||||
{issue.kind !== 'pr' ? (
|
||||
handedToAgent ? (
|
||||
<XStack items="center" gap="$2" px="$2.5" py="$2" rounded="$3" bg="$color2">
|
||||
<Bot size={14} />
|
||||
<Text fontSize="$2" color="$color11" flex={1}>
|
||||
Handed to the coding agent. A linked PR appears here when the run opens one.
|
||||
</Text>
|
||||
</XStack>
|
||||
) : (
|
||||
<Button icon={<Bot size={15} />} disabled={busy} onPress={() => void handToAgent()}>
|
||||
Hand to coding agent
|
||||
</Button>
|
||||
)
|
||||
) : null}
|
||||
|
||||
{/* Linked PRs */}
|
||||
{prs.length > 0 ? (
|
||||
<YStack gap="$1">
|
||||
<XStack items="center" gap="$1.5">
|
||||
<GitPullRequest size={13} />
|
||||
<Text fontSize="$2" color="$color10" fontWeight="600">
|
||||
Linked pull requests · {prs.length}
|
||||
</Text>
|
||||
</XStack>
|
||||
{prs.map((pr) => (
|
||||
<LinkRow key={pr.id} issue={pr} onOpen={onOpenIssue} />
|
||||
))}
|
||||
</YStack>
|
||||
) : null}
|
||||
|
||||
{/* Epic children */}
|
||||
{issue.kind === 'epic' ? (
|
||||
<YStack gap="$1">
|
||||
<XStack items="center" gap="$1.5">
|
||||
<Layers size={13} />
|
||||
<Text fontSize="$2" color="$color10" fontWeight="600">
|
||||
Child issues · {children.length}
|
||||
</Text>
|
||||
</XStack>
|
||||
{children.length === 0 ? (
|
||||
<Text fontSize="$1" color="$color9" px="$2">
|
||||
No children yet. Set a child issue's ExtRef to {issue.identifier} to link it here.
|
||||
</Text>
|
||||
) : (
|
||||
children.map((c) => <LinkRow key={c.id} issue={c} onOpen={onOpenIssue} />)
|
||||
)}
|
||||
</YStack>
|
||||
) : null}
|
||||
</Card>
|
||||
) : null}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Issue list + board renderers — the two Linear-grade layouts over the SAME grouped
|
||||
* data. `GroupedList` is the sectioned list (the proof view: rows grouped by the
|
||||
* chosen axis); `Board` is the horizontal status-column kanban. Both render the dense
|
||||
* `IssueRow`/`IssueCard` and support a `selectedId` highlight so the module's
|
||||
* keyboard cursor (j/k/↑/↓, Enter) is visible. Pure presentation over props — all
|
||||
* grouping/sorting is decided in `logic.ts`.
|
||||
*/
|
||||
import { Card, ScrollView, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import type { Issue, Project } from '~/lib/api/tracker'
|
||||
import { type IssueGroup, relTime, teamName } from './logic'
|
||||
import { StatusIcon, PriorityIcon, Identifier, KindIcon, SourceBadge, LabelChips, Avatar } from './atoms'
|
||||
|
||||
export type RowProps = {
|
||||
issue: Issue
|
||||
projects: Project[]
|
||||
selected?: boolean
|
||||
showTeam?: boolean
|
||||
onOpen: (i: Issue) => void
|
||||
}
|
||||
|
||||
/** One dense list row — Linear's single-line issue row. */
|
||||
export function IssueRow({ issue, projects, selected, showTeam, onOpen }: RowProps) {
|
||||
return (
|
||||
<XStack
|
||||
items="center"
|
||||
gap="$2.5"
|
||||
px="$3"
|
||||
height={40}
|
||||
bg={selected ? '$color4' : 'transparent'}
|
||||
borderLeftWidth={2}
|
||||
borderColor={selected ? '$color8' : 'transparent'}
|
||||
hoverStyle={{ bg: '$color3' }}
|
||||
cursor="pointer"
|
||||
onPress={() => onOpen(issue)}
|
||||
data-issue={issue.id}
|
||||
>
|
||||
<StatusIcon status={issue.status} />
|
||||
<PriorityIcon priority={issue.priority} size={14} />
|
||||
<YStack width={72}>
|
||||
<Identifier id={issue.identifier} />
|
||||
</YStack>
|
||||
{issue.kind !== 'issue' ? <KindIcon kind={issue.kind} /> : null}
|
||||
<Text flex={1} fontSize="$3" color="$color12" numberOfLines={1}>
|
||||
{issue.title || 'Untitled'}
|
||||
</Text>
|
||||
{showTeam ? (
|
||||
<Text display="none" $md={{ display: 'flex' }} fontSize="$1" color="$color9" className="hz-mono">
|
||||
{teamName(issue.projectKey, projects)}
|
||||
</Text>
|
||||
) : null}
|
||||
<XStack display="none" $md={{ display: 'flex' }} items="center" gap="$2">
|
||||
<SourceBadge source={issue.source} />
|
||||
<LabelChips labels={issue.labels} max={2} />
|
||||
</XStack>
|
||||
<Text display="none" $lg={{ display: 'flex' }} fontSize="$1" color="$color9" width={44} style={{ textAlign: 'right' }}>
|
||||
{relTime(issue.updatedAt)}
|
||||
</Text>
|
||||
<Avatar name={issue.assignee} />
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
/** A board card — the same issue, stacked for a column. */
|
||||
export function IssueCard({ issue, projects, selected, showTeam, onOpen }: RowProps) {
|
||||
return (
|
||||
<Card
|
||||
p="$2.5"
|
||||
gap="$2"
|
||||
bg="$color1"
|
||||
borderWidth={1}
|
||||
borderColor={selected ? '$color8' : '$borderColor'}
|
||||
hoverStyle={{ borderColor: '$color8' }}
|
||||
cursor="pointer"
|
||||
onPress={() => onOpen(issue)}
|
||||
data-issue={issue.id}
|
||||
>
|
||||
<XStack items="center" justify="space-between" gap="$2">
|
||||
<XStack items="center" gap="$1.5" flex={1} minW={0}>
|
||||
<Identifier id={issue.identifier} />
|
||||
{issue.kind !== 'issue' ? <KindIcon kind={issue.kind} size={12} /> : null}
|
||||
</XStack>
|
||||
<PriorityIcon priority={issue.priority} size={13} />
|
||||
</XStack>
|
||||
<Text fontSize="$3" color="$color12" numberOfLines={3}>
|
||||
{issue.title || 'Untitled'}
|
||||
</Text>
|
||||
<XStack items="center" justify="space-between" gap="$2">
|
||||
<XStack items="center" gap="$1.5" flex={1} minW={0} flexWrap="wrap">
|
||||
<SourceBadge source={issue.source} />
|
||||
<LabelChips labels={issue.labels} max={2} />
|
||||
{showTeam ? (
|
||||
<Text fontSize="$1" color="$color9" className="hz-mono">
|
||||
{teamName(issue.projectKey, projects)}
|
||||
</Text>
|
||||
) : null}
|
||||
</XStack>
|
||||
<Avatar name={issue.assignee} size={18} />
|
||||
</XStack>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
/** A section header — the group label, its glyph, and a count. */
|
||||
function GroupHeader({ group, glyph }: { group: IssueGroup; glyph?: React.ReactNode }) {
|
||||
return (
|
||||
<XStack items="center" gap="$2" px="$3" py="$1.5" bg="$color2" borderBottomWidth={1} borderColor="$borderColor">
|
||||
{glyph}
|
||||
<Text fontSize="$2" fontWeight="700" color="$color12">
|
||||
{group.label || 'Issues'}
|
||||
</Text>
|
||||
<Text fontSize="$2" color="$color9">
|
||||
{group.issues.length}
|
||||
</Text>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
/** The sectioned list — every group with its rows. Empty groups are hidden unless
|
||||
* `keepEmpty` (a status board keeps its empty columns; a dynamic bucket does not). */
|
||||
export function GroupedList({
|
||||
groups,
|
||||
projects,
|
||||
selectedId,
|
||||
showTeam,
|
||||
keepEmpty,
|
||||
glyphFor,
|
||||
onOpen,
|
||||
}: {
|
||||
groups: IssueGroup[]
|
||||
projects: Project[]
|
||||
selectedId?: string
|
||||
showTeam?: boolean
|
||||
keepEmpty?: boolean
|
||||
glyphFor?: (g: IssueGroup) => React.ReactNode
|
||||
onOpen: (i: Issue) => void
|
||||
}) {
|
||||
const visible = keepEmpty ? groups : groups.filter((g) => g.issues.length > 0)
|
||||
return (
|
||||
<YStack borderWidth={1} borderColor="$borderColor" rounded="$4" overflow="hidden">
|
||||
{visible.map((g, gi) => (
|
||||
<YStack key={g.key} borderTopWidth={gi === 0 ? 0 : 1} borderColor="$borderColor">
|
||||
<GroupHeader group={g} glyph={glyphFor?.(g)} />
|
||||
{g.issues.length === 0 ? (
|
||||
<Text fontSize="$2" color="$color9" px="$3" py="$2">
|
||||
No issues
|
||||
</Text>
|
||||
) : (
|
||||
g.issues.map((i) => (
|
||||
<YStack key={i.id} borderTopWidth={1} borderColor="$color2">
|
||||
<IssueRow issue={i} projects={projects} selected={i.id === selectedId} showTeam={showTeam} onOpen={onOpen} />
|
||||
</YStack>
|
||||
))
|
||||
)}
|
||||
</YStack>
|
||||
))}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
/** The horizontal board — status columns of cards (always the five columns). */
|
||||
export function Board({
|
||||
columns,
|
||||
projects,
|
||||
selectedId,
|
||||
showTeam,
|
||||
glyphFor,
|
||||
onOpen,
|
||||
}: {
|
||||
columns: IssueGroup[]
|
||||
projects: Project[]
|
||||
selectedId?: string
|
||||
showTeam?: boolean
|
||||
glyphFor?: (g: IssueGroup) => React.ReactNode
|
||||
onOpen: (i: Issue) => void
|
||||
}) {
|
||||
return (
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator>
|
||||
<XStack gap="$3" py="$1" items="flex-start">
|
||||
{columns.map((col) => (
|
||||
<YStack key={col.key} width={300} minW={300} gap="$2">
|
||||
<XStack items="center" gap="$2" px="$1">
|
||||
{glyphFor?.(col)}
|
||||
<Text flex={1} fontSize="$2" fontWeight="700" color="$color12">
|
||||
{col.label}
|
||||
</Text>
|
||||
<Text fontSize="$2" color="$color9">
|
||||
{col.issues.length}
|
||||
</Text>
|
||||
</XStack>
|
||||
<YStack gap="$2">
|
||||
{col.issues.length === 0 ? (
|
||||
<YStack borderWidth={1} borderColor="$borderColor" borderStyle="dashed" rounded="$4" py="$4" items="center">
|
||||
<Text fontSize="$2" color="$color9">
|
||||
No issues
|
||||
</Text>
|
||||
</YStack>
|
||||
) : (
|
||||
col.issues.map((i) => (
|
||||
<IssueCard
|
||||
key={i.id}
|
||||
issue={i}
|
||||
projects={projects}
|
||||
selected={i.id === selectedId}
|
||||
showTeam={showTeam}
|
||||
onOpen={onOpen}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</YStack>
|
||||
</YStack>
|
||||
))}
|
||||
</XStack>
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Tracker toolbar — the Linear-grade control strip above every issue view: List⇄Board
|
||||
* toggle, a Group-by menu, a Filter menu (status / priority / kind / source / assignee
|
||||
* / label), active-filter chips with one-click clear, and a `/`-focusable search box.
|
||||
* Every control is a pure value in/out (state lives in the module), so the toolbar is
|
||||
* a dumb, reusable renderer. @hanzo/gui Popover menus, shorthands only.
|
||||
*/
|
||||
import { forwardRef } from 'react'
|
||||
import { Button, Input, Popover, ScrollView, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import {
|
||||
ListChecks,
|
||||
LayoutGrid,
|
||||
ChevronDown,
|
||||
Filter as FilterIcon,
|
||||
Search,
|
||||
X,
|
||||
} from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { STATUSES, PRIORITIES, KINDS, SOURCES } from '~/lib/api/tracker'
|
||||
import {
|
||||
type GroupBy,
|
||||
type IssueFilters,
|
||||
GROUP_BY,
|
||||
GROUP_BY_LABEL,
|
||||
STATUS_LABEL,
|
||||
PRIORITY_LABEL,
|
||||
KIND_LABEL,
|
||||
SOURCE_LABEL,
|
||||
countFilters,
|
||||
} from './logic'
|
||||
import { StatusIcon, PriorityIcon } from './atoms'
|
||||
|
||||
type Opt = { value: string; label: string; glyph?: React.ReactElement }
|
||||
|
||||
/** A Popover single-select menu — a labeled trigger, a scrollable option list, an
|
||||
* optional clear. Selecting closes via the controlled `Popover` (uncontrolled here
|
||||
* is fine — Radix closes on outer press). */
|
||||
function Menu({
|
||||
trigger,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
clearLabel,
|
||||
}: {
|
||||
trigger: React.ReactNode
|
||||
options: Opt[]
|
||||
value?: string
|
||||
onChange: (v: string) => void
|
||||
clearLabel?: string
|
||||
}) {
|
||||
return (
|
||||
<Popover placement="bottom-start">
|
||||
<Popover.Trigger asChild>{trigger}</Popover.Trigger>
|
||||
<Popover.Content bordered elevate p="$1.5" width={220} bg="$color2" borderColor="$borderColor">
|
||||
<ScrollView maxH={320}>
|
||||
<YStack gap="$0.5">
|
||||
{clearLabel ? (
|
||||
<Button
|
||||
size="$2"
|
||||
chromeless
|
||||
justify="flex-start"
|
||||
onPress={() => onChange('')}
|
||||
bg={!value ? '$color4' : 'transparent'}
|
||||
>
|
||||
<Text fontSize="$2" color="$color11">
|
||||
{clearLabel}
|
||||
</Text>
|
||||
</Button>
|
||||
) : null}
|
||||
{options.map((o) => (
|
||||
<Button
|
||||
key={o.value}
|
||||
size="$2"
|
||||
chromeless
|
||||
justify="flex-start"
|
||||
icon={o.glyph ?? undefined}
|
||||
onPress={() => onChange(o.value)}
|
||||
bg={value === o.value ? '$color4' : 'transparent'}
|
||||
>
|
||||
<Text fontSize="$2" color="$color12">
|
||||
{o.label}
|
||||
</Text>
|
||||
</Button>
|
||||
))}
|
||||
</YStack>
|
||||
</ScrollView>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
/** A quiet filter chip: "Status: Todo ✕". */
|
||||
function Chip({ label, onClear }: { label: string; onClear: () => void }) {
|
||||
return (
|
||||
<XStack items="center" gap="$1.5" px="$2" py="$1" rounded="$3" bg="$color3" borderWidth={1} borderColor="$borderColor">
|
||||
<Text fontSize="$1" color="$color11" fontWeight="500">
|
||||
{label}
|
||||
</Text>
|
||||
<Text onPress={onClear} cursor="pointer" color="$color10" hoverStyle={{ color: '$color12' }} aria-label={`Clear ${label}`}>
|
||||
<X size={12} />
|
||||
</Text>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
export type ToolbarProps = {
|
||||
view: 'list' | 'board'
|
||||
onView: (v: 'list' | 'board') => void
|
||||
groupBy: GroupBy
|
||||
onGroupBy: (g: GroupBy) => void
|
||||
filters: IssueFilters
|
||||
onFilters: (f: IssueFilters) => void
|
||||
query: string
|
||||
onQuery: (q: string) => void
|
||||
/** dynamic option lists derived from the current issue set. */
|
||||
assignees: string[]
|
||||
labels: string[]
|
||||
/** right-aligned actions (Refresh / New / Sync). */
|
||||
actions?: React.ReactNode
|
||||
/** hide the board toggle (roadmap/cycles are list-only). */
|
||||
listOnly?: boolean
|
||||
}
|
||||
|
||||
export const Toolbar = forwardRef<HTMLInputElement, ToolbarProps>(function Toolbar(
|
||||
{ view, onView, groupBy, onGroupBy, filters, onFilters, query, onQuery, assignees, labels, actions, listOnly },
|
||||
searchRef,
|
||||
) {
|
||||
const set = (patch: Partial<IssueFilters>) => onFilters({ ...filters, ...patch })
|
||||
const n = countFilters({ ...filters, q: '' }) // q shown in the search box, not as a chip
|
||||
|
||||
return (
|
||||
<YStack gap="$2.5">
|
||||
<XStack items="center" gap="$2" flexWrap="wrap">
|
||||
{/* Search — `/` focuses this (see module). */}
|
||||
<XStack
|
||||
items="center"
|
||||
gap="$2"
|
||||
px="$2.5"
|
||||
height={34}
|
||||
minW={220}
|
||||
flex={1}
|
||||
$md={{ flex: 0, width: 280 }}
|
||||
rounded="$3"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
bg="$color2"
|
||||
>
|
||||
<Search size={14} opacity={0.6} />
|
||||
<Input
|
||||
ref={searchRef as never}
|
||||
flex={1}
|
||||
unstyled
|
||||
value={query}
|
||||
onChangeText={onQuery}
|
||||
placeholder="Search issues… ( / )"
|
||||
fontSize="$3"
|
||||
color="$color12"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
{query ? (
|
||||
<Button size="$1" chromeless icon={<X size={13} />} onPress={() => onQuery('')} aria-label="Clear search" />
|
||||
) : null}
|
||||
</XStack>
|
||||
|
||||
{/* Group-by */}
|
||||
<Menu
|
||||
trigger={
|
||||
<Button size="$2" icon={<ChevronDown size={14} />} iconAfter={undefined}>
|
||||
Group: {GROUP_BY_LABEL[groupBy]}
|
||||
</Button>
|
||||
}
|
||||
value={groupBy}
|
||||
options={GROUP_BY.map((g) => ({ value: g, label: GROUP_BY_LABEL[g] }))}
|
||||
onChange={(v) => onGroupBy((v || 'status') as GroupBy)}
|
||||
/>
|
||||
|
||||
{/* Filter */}
|
||||
<Menu
|
||||
trigger={
|
||||
<Button size="$2" icon={<FilterIcon size={14} />} theme={n > 0 ? 'light' : undefined}>
|
||||
Filter{n > 0 ? ` · ${n}` : ''}
|
||||
</Button>
|
||||
}
|
||||
value={filters.status}
|
||||
clearLabel="Any status"
|
||||
options={STATUSES.map((s) => ({ value: s, label: STATUS_LABEL[s], glyph: <StatusIcon status={s} size={14} /> }))}
|
||||
onChange={(v) => set({ status: (v || undefined) as IssueFilters['status'] })}
|
||||
/>
|
||||
<Menu
|
||||
trigger={<Button size="$2">Priority</Button>}
|
||||
value={filters.priority}
|
||||
clearLabel="Any priority"
|
||||
options={PRIORITIES.map((p) => ({ value: p, label: PRIORITY_LABEL[p], glyph: <PriorityIcon priority={p} size={13} /> }))}
|
||||
onChange={(v) => set({ priority: (v || undefined) as IssueFilters['priority'] })}
|
||||
/>
|
||||
<Menu
|
||||
trigger={<Button size="$2">Kind</Button>}
|
||||
value={filters.kind}
|
||||
clearLabel="Any kind"
|
||||
options={KINDS.map((k) => ({ value: k, label: KIND_LABEL[k] }))}
|
||||
onChange={(v) => set({ kind: (v || undefined) as IssueFilters['kind'] })}
|
||||
/>
|
||||
<Menu
|
||||
trigger={<Button size="$2">Source</Button>}
|
||||
value={filters.source}
|
||||
clearLabel="Any source"
|
||||
options={SOURCES.map((s) => ({ value: s, label: SOURCE_LABEL[s] }))}
|
||||
onChange={(v) => set({ source: (v || undefined) as IssueFilters['source'] })}
|
||||
/>
|
||||
{assignees.length > 0 ? (
|
||||
<Menu
|
||||
trigger={<Button size="$2">Assignee</Button>}
|
||||
value={filters.assignee}
|
||||
clearLabel="Anyone"
|
||||
options={assignees.map((a) => ({ value: a, label: a }))}
|
||||
onChange={(v) => set({ assignee: v || undefined })}
|
||||
/>
|
||||
) : null}
|
||||
{labels.length > 0 ? (
|
||||
<Menu
|
||||
trigger={<Button size="$2">Label</Button>}
|
||||
value={filters.label}
|
||||
clearLabel="Any label"
|
||||
options={labels.map((l) => ({ value: l, label: l }))}
|
||||
onChange={(v) => set({ label: v || undefined })}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* View toggle */}
|
||||
{!listOnly ? (
|
||||
<XStack gap="$1" ml="auto">
|
||||
<Button
|
||||
size="$2"
|
||||
theme={view === 'list' ? 'light' : undefined}
|
||||
chromeless={view !== 'list'}
|
||||
icon={<ListChecks size={15} />}
|
||||
onPress={() => onView('list')}
|
||||
aria-label="List view"
|
||||
/>
|
||||
<Button
|
||||
size="$2"
|
||||
theme={view === 'board' ? 'light' : undefined}
|
||||
chromeless={view !== 'board'}
|
||||
icon={<LayoutGrid size={15} />}
|
||||
onPress={() => onView('board')}
|
||||
aria-label="Board view"
|
||||
/>
|
||||
</XStack>
|
||||
) : null}
|
||||
|
||||
{actions ? (
|
||||
<XStack gap="$2" items="center" ml={listOnly ? 'auto' : undefined}>
|
||||
{actions}
|
||||
</XStack>
|
||||
) : null}
|
||||
</XStack>
|
||||
|
||||
{/* Active-filter chips */}
|
||||
{n > 0 ? (
|
||||
<XStack gap="$1.5" flexWrap="wrap" items="center">
|
||||
{filters.status ? <Chip label={`Status: ${STATUS_LABEL[filters.status]}`} onClear={() => set({ status: undefined })} /> : null}
|
||||
{filters.priority ? <Chip label={`Priority: ${PRIORITY_LABEL[filters.priority]}`} onClear={() => set({ priority: undefined })} /> : null}
|
||||
{filters.kind ? <Chip label={`Kind: ${KIND_LABEL[filters.kind]}`} onClear={() => set({ kind: undefined })} /> : null}
|
||||
{filters.source ? <Chip label={`Source: ${SOURCE_LABEL[filters.source]}`} onClear={() => set({ source: undefined })} /> : null}
|
||||
{filters.assignee ? <Chip label={`Assignee: ${filters.assignee}`} onClear={() => set({ assignee: undefined })} /> : null}
|
||||
{filters.label ? <Chip label={`Label: ${filters.label}`} onClear={() => set({ label: undefined })} /> : null}
|
||||
{filters.team ? <Chip label={`Team: ${filters.team}`} onClear={() => set({ team: undefined })} /> : null}
|
||||
<Button size="$1" chromeless onPress={() => onFilters({ q: filters.q })} aria-label="Clear all filters">
|
||||
<Text fontSize="$1" color="$color10" fontWeight="600">
|
||||
Clear all
|
||||
</Text>
|
||||
</Button>
|
||||
</XStack>
|
||||
) : null}
|
||||
</YStack>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,195 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Tracker command palette — a focused quick-switcher over the tracker's own actions +
|
||||
* navigation + issues. In the STANDALONE tracker shell (tracker.<brand>) it owns ⌘K
|
||||
* via a capture-phase listener that stops the event before the console's global
|
||||
* palette sees it, so each shell has exactly one ⌘K owner (the embedded /tracker view
|
||||
* defers to the global palette instead — the module only mounts this when
|
||||
* `config.shell === 'tracker'`). Type to filter commands + jump straight to an issue
|
||||
* by identifier or title; ↑/↓ to move, Enter to run, Esc to close.
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Input, ScrollView, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import type { ComponentType } from 'react'
|
||||
|
||||
export type Command = {
|
||||
id: string
|
||||
label: string
|
||||
hint?: string
|
||||
group: string
|
||||
icon?: ComponentType<{ size?: number }>
|
||||
run: () => void
|
||||
}
|
||||
|
||||
export function TrackerCommand({
|
||||
open,
|
||||
onClose,
|
||||
commands,
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
commands: Command[]
|
||||
}) {
|
||||
const [q, setQ] = useState('')
|
||||
const [active, setActive] = useState(0)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setQ('')
|
||||
setActive(0)
|
||||
// focus after mount
|
||||
const t = setTimeout(() => inputRef.current?.focus(), 10)
|
||||
return () => clearTimeout(t)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const needle = q.trim().toLowerCase()
|
||||
if (!needle) return commands
|
||||
return commands.filter((c) => `${c.label} ${c.hint ?? ''} ${c.group}`.toLowerCase().includes(needle))
|
||||
}, [q, commands])
|
||||
|
||||
useEffect(() => {
|
||||
if (active >= filtered.length) setActive(0)
|
||||
}, [filtered.length, active])
|
||||
|
||||
const run = (c?: Command) => {
|
||||
if (!c) return
|
||||
onClose()
|
||||
c.run()
|
||||
}
|
||||
|
||||
// Arrow/Enter/Escape on a document listener (Tamagui Input doesn't reliably forward
|
||||
// onKeyDown); capture phase so it wins while the palette is open.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
onClose()
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setActive((i) => Math.min(i + 1, filtered.length - 1))
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setActive((i) => Math.max(i - 1, 0))
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
run(filtered[active])
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', onKey, true)
|
||||
return () => document.removeEventListener('keydown', onKey, true)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, filtered, active])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
// Group order preserved as first-seen.
|
||||
const groups: { name: string; items: Command[] }[] = []
|
||||
for (const c of filtered) {
|
||||
let g = groups.find((x) => x.name === c.group)
|
||||
if (!g) groups.push((g = { name: c.group, items: [] }))
|
||||
g.items.push(c)
|
||||
}
|
||||
let idx = -1
|
||||
|
||||
return (
|
||||
<YStack position="fixed" t={0} l={0} r={0} b={0} items="center" style={{ zIndex: 2000 }} pt="$10">
|
||||
{/* Backdrop as its own layer BEHIND the card — a card click never bubbles to it. */}
|
||||
<YStack
|
||||
position="absolute"
|
||||
t={0}
|
||||
l={0}
|
||||
r={0}
|
||||
b={0}
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.45)' }}
|
||||
onPress={onClose}
|
||||
/>
|
||||
<YStack
|
||||
width="92%"
|
||||
maxW={620}
|
||||
bg="$color1"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
rounded="$5"
|
||||
overflow="hidden"
|
||||
className="hz-elevation-4"
|
||||
>
|
||||
<XStack items="center" gap="$2" px="$3" height={48} borderBottomWidth={1} borderColor="$borderColor">
|
||||
<Text fontSize="$4" color="$color9">
|
||||
⌘
|
||||
</Text>
|
||||
<Input
|
||||
ref={inputRef as never}
|
||||
flex={1}
|
||||
unstyled
|
||||
value={q}
|
||||
onChangeText={setQ}
|
||||
placeholder="Type a command or jump to an issue…"
|
||||
fontSize="$4"
|
||||
color="$color12"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
<Text fontSize="$1" color="$color9">
|
||||
Esc
|
||||
</Text>
|
||||
</XStack>
|
||||
<ScrollView maxH={420}>
|
||||
<YStack py="$1.5">
|
||||
{groups.length === 0 ? (
|
||||
<Text fontSize="$2" color="$color9" px="$3" py="$3">
|
||||
No matching commands.
|
||||
</Text>
|
||||
) : (
|
||||
groups.map((g) => (
|
||||
<YStack key={g.name} pb="$1">
|
||||
<Text px="$3" py="$1" fontSize="$1" color="$color9" fontWeight="700" textTransform="uppercase" letterSpacing={0.4}>
|
||||
{g.name}
|
||||
</Text>
|
||||
{g.items.map((c) => {
|
||||
idx += 1
|
||||
const on = idx === active
|
||||
const Icon = c.icon
|
||||
return (
|
||||
<XStack
|
||||
key={c.id}
|
||||
items="center"
|
||||
gap="$2.5"
|
||||
px="$3"
|
||||
height={38}
|
||||
bg={on ? '$color4' : 'transparent'}
|
||||
hoverStyle={{ bg: '$color3' }}
|
||||
cursor="pointer"
|
||||
onPress={() => run(c)}
|
||||
>
|
||||
{Icon ? (
|
||||
<Text color="$color10">
|
||||
<Icon size={15} />
|
||||
</Text>
|
||||
) : (
|
||||
<YStack width={15} />
|
||||
)}
|
||||
<Text flex={1} fontSize="$3" color="$color12" numberOfLines={1}>
|
||||
{c.label}
|
||||
</Text>
|
||||
{c.hint ? (
|
||||
<Text fontSize="$1" color="$color9" className="hz-mono" numberOfLines={1}>
|
||||
{c.hint}
|
||||
</Text>
|
||||
) : null}
|
||||
</XStack>
|
||||
)
|
||||
})}
|
||||
</YStack>
|
||||
))
|
||||
)}
|
||||
</YStack>
|
||||
</ScrollView>
|
||||
</YStack>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Tracker presentational atoms — the tiny, reusable marks shared by every tracker
|
||||
* view (list rows, board cards, the detail pane). Pure @hanzo/gui v5 shorthands, no
|
||||
* state, no network. Colors come from `logic.ts` token maps (as-const so the literal
|
||||
* satisfies the GUI color union), so a status/priority reads the same everywhere.
|
||||
*/
|
||||
import { Text, XStack, YStack } from '@hanzo/gui'
|
||||
import {
|
||||
CircleDashed,
|
||||
Circle,
|
||||
CircleDot,
|
||||
CircleCheck,
|
||||
CircleSlash,
|
||||
GitPullRequest,
|
||||
Layers,
|
||||
Bot,
|
||||
Github,
|
||||
SignalHigh,
|
||||
SignalMedium,
|
||||
SignalLow,
|
||||
AlertTriangle,
|
||||
Minus,
|
||||
} from '@hanzogui/lucide-icons-2'
|
||||
import { type Status, type Priority, type Kind, type Source } from '~/lib/api/tracker'
|
||||
import {
|
||||
STATUS_LABEL,
|
||||
STATUS_DOT,
|
||||
PRIORITY_LABEL,
|
||||
PRIORITY_COLOR,
|
||||
KIND_LABEL,
|
||||
SOURCE_LABEL,
|
||||
} from './logic'
|
||||
|
||||
// @hanzogui/lucide-icons-2 icons are Tamagui-wrapped: they accept a `color` token
|
||||
// (e.g. "$red10") + `size`, exactly like DashboardShell's `<ChevronRight color=…/>`.
|
||||
// `typeof Circle` IS that icon type, so a token color prop type-checks (a plain
|
||||
// `string` would not — the color prop is a ColorToken union, not string).
|
||||
type Glyph = typeof Circle
|
||||
|
||||
// ── Status ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const STATUS_ICON: Record<Status, Glyph> = {
|
||||
backlog: CircleDashed,
|
||||
todo: Circle,
|
||||
in_progress: CircleDot,
|
||||
done: CircleCheck,
|
||||
canceled: CircleSlash,
|
||||
}
|
||||
|
||||
/** The colored status glyph (Linear's leading state icon). */
|
||||
export function StatusIcon({ status, size = 15 }: { status: Status; size?: number }) {
|
||||
const Icon = STATUS_ICON[status]
|
||||
return <Icon size={size} color={STATUS_DOT[status]} />
|
||||
}
|
||||
|
||||
/** A small round status dot (used where a full glyph is too heavy). */
|
||||
export function StatusDot({ status, size = 9 }: { status: Status; size?: number }) {
|
||||
return <YStack width={size} height={size} rounded="$10" bg={STATUS_DOT[status]} />
|
||||
}
|
||||
|
||||
export function StatusLabel({ status }: { status: Status }) {
|
||||
return (
|
||||
<XStack items="center" gap="$1.5">
|
||||
<StatusIcon status={status} />
|
||||
<Text fontSize="$2" color="$color11">
|
||||
{STATUS_LABEL[status]}
|
||||
</Text>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Priority ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const PRIORITY_ICON: Record<Priority, Glyph> = {
|
||||
urgent: AlertTriangle,
|
||||
high: SignalHigh,
|
||||
medium: SignalMedium,
|
||||
low: SignalLow,
|
||||
none: Minus,
|
||||
}
|
||||
|
||||
/** The priority bars glyph (urgent = alert), colored per level. */
|
||||
export function PriorityIcon({ priority, size = 15 }: { priority: Priority; size?: number }) {
|
||||
const Icon = PRIORITY_ICON[priority]
|
||||
return <Icon size={size} color={PRIORITY_COLOR[priority]} />
|
||||
}
|
||||
|
||||
export function PriorityTag({ priority }: { priority: Priority }) {
|
||||
if (priority === 'none') {
|
||||
return (
|
||||
<Text fontSize="$2" color="$color9">
|
||||
—
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<XStack items="center" gap="$1.5">
|
||||
<PriorityIcon priority={priority} size={13} />
|
||||
<Text fontSize="$1" color="$color11" fontWeight="500">
|
||||
{PRIORITY_LABEL[priority]}
|
||||
</Text>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Kind / Source badges ─────────────────────────────────────────────────────
|
||||
|
||||
const KIND_ICON: Record<Kind, Glyph> = {
|
||||
issue: Circle,
|
||||
pr: GitPullRequest,
|
||||
epic: Layers,
|
||||
}
|
||||
|
||||
/** A kind glyph — a PR/epic reads distinctly from a plain issue. */
|
||||
export function KindIcon({ kind, size = 14 }: { kind: Kind; size?: number }) {
|
||||
const Icon = KIND_ICON[kind]
|
||||
// Inline so the literal color token type-checks against the icon's ColorToken prop.
|
||||
return <Icon size={size} color={kind === 'pr' ? '$green10' : kind === 'epic' ? '$purple10' : '$color9'} />
|
||||
}
|
||||
|
||||
/** A source chip — GitHub / Agent get their mark; team is quiet. */
|
||||
export function SourceBadge({ source }: { source: Source }) {
|
||||
if (source === 'team') return null
|
||||
const Icon: Glyph = source === 'git' ? Github : source === 'agent' ? Bot : Circle
|
||||
return (
|
||||
<XStack items="center" gap="$1" px="$1.5" py="$0.5" rounded="$2" bg="$color3">
|
||||
<Icon size={11} color="$color10" />
|
||||
<Text fontSize="$1" color="$color10" fontWeight="600">
|
||||
{SOURCE_LABEL[source]}
|
||||
</Text>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Labels / assignee / meta ─────────────────────────────────────────────────
|
||||
|
||||
export function LabelChips({ labels, max = 3 }: { labels: string[]; max?: number }) {
|
||||
if (labels.length === 0) return null
|
||||
const shown = labels.slice(0, max)
|
||||
const extra = labels.length - shown.length
|
||||
return (
|
||||
<XStack gap="$1" flexWrap="wrap" items="center">
|
||||
{shown.map((l) => (
|
||||
<XStack key={l} items="center" gap="$1" px="$1.5" py="$0.5" rounded="$10" bg="$color3">
|
||||
<YStack width={6} height={6} rounded="$10" bg="$color9" />
|
||||
<Text fontSize="$1" color="$color11" numberOfLines={1}>
|
||||
{l}
|
||||
</Text>
|
||||
</XStack>
|
||||
))}
|
||||
{extra > 0 ? (
|
||||
<Text fontSize="$1" color="$color9">
|
||||
+{extra}
|
||||
</Text>
|
||||
) : null}
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
/** A small round identity chip — initials on a tinted tile. */
|
||||
export function Avatar({ name, size = 20 }: { name?: string; size?: number }) {
|
||||
const label = (name ?? '').trim()
|
||||
if (!label) {
|
||||
return (
|
||||
<YStack
|
||||
width={size}
|
||||
height={size}
|
||||
rounded="$10"
|
||||
borderWidth={1}
|
||||
borderColor="$color6"
|
||||
borderStyle="dashed"
|
||||
/>
|
||||
)
|
||||
}
|
||||
const initials = (label.split(/[\s@._-]+/).map((w) => w[0]).join('').slice(0, 2) || 'U').toUpperCase()
|
||||
return (
|
||||
<XStack width={size} height={size} items="center" justify="center" rounded="$10" bg="$color5">
|
||||
<Text fontSize={Math.round(size * 0.42)} fontWeight="800" color="$color12">
|
||||
{initials}
|
||||
</Text>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
/** A mono identifier tag (ENG-12). */
|
||||
export function Identifier({ id, muted }: { id: string; muted?: boolean }) {
|
||||
return (
|
||||
<Text className="hz-mono" fontSize="$1" color={muted ? '$color9' : '$color10'} numberOfLines={1}>
|
||||
{id}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
/** A thin progress bar (done / total) — used by Cycles + Roadmap. */
|
||||
export function ProgressBar({ value, width = 120 }: { value: number; width?: number }) {
|
||||
const pct = Math.max(0, Math.min(1, Number.isFinite(value) ? value : 0))
|
||||
return (
|
||||
<YStack width={width} height={6} rounded="$10" bg="$color4" overflow="hidden">
|
||||
<YStack height={6} rounded="$10" bg={pct >= 1 ? '$green10' : '$color11'} width={`${pct * 100}%`} />
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { Issue } from '~/lib/api/tracker'
|
||||
import {
|
||||
filterIssues,
|
||||
countFilters,
|
||||
matchesQuery,
|
||||
sortIssues,
|
||||
groupIssues,
|
||||
boardColumns,
|
||||
myIssues,
|
||||
deriveCurrentCycle,
|
||||
deriveRoadmap,
|
||||
githubUrl,
|
||||
gitHanzoUrl,
|
||||
linkedPRs,
|
||||
relTime,
|
||||
assigneesOf,
|
||||
labelsOf,
|
||||
parseLabels,
|
||||
} from './logic'
|
||||
|
||||
// ── fixtures ─────────────────────────────────────────────────────────────────
|
||||
let seq = 0
|
||||
function mk(p: Partial<Issue> = {}): Issue {
|
||||
seq += 1
|
||||
const key = p.projectKey ?? 'ENG'
|
||||
const n = p.number ?? seq
|
||||
return {
|
||||
id: p.id ?? `issue_${seq}`,
|
||||
identifier: p.identifier ?? `${key}-${n}`,
|
||||
projectKey: key,
|
||||
number: n,
|
||||
kind: p.kind ?? 'issue',
|
||||
source: p.source ?? 'team',
|
||||
repo: p.repo,
|
||||
extRef: p.extRef,
|
||||
title: p.title ?? `Issue ${n}`,
|
||||
description: p.description,
|
||||
status: p.status ?? 'backlog',
|
||||
priority: p.priority ?? 'none',
|
||||
assignee: p.assignee,
|
||||
labels: p.labels ?? [],
|
||||
createdAt: p.createdAt ?? 1_700_000_000,
|
||||
updatedAt: p.updatedAt ?? 1_700_000_000,
|
||||
}
|
||||
}
|
||||
|
||||
describe('filterIssues', () => {
|
||||
const issues = [
|
||||
mk({ status: 'todo', priority: 'high', assignee: 'ada@hanzo.ai', labels: ['bug'], source: 'git', repo: 'hanzoai/cloud' }),
|
||||
mk({ status: 'done', priority: 'low', assignee: 'bob@hanzo.ai', labels: ['chore'], source: 'team' }),
|
||||
mk({ status: 'todo', priority: 'urgent', kind: 'pr', source: 'agent', repo: 'hanzoai/cloud' }),
|
||||
]
|
||||
it('constrains only on set fields (empty = pass-through)', () => {
|
||||
expect(filterIssues(issues, {})).toHaveLength(3)
|
||||
expect(filterIssues(issues, { status: 'todo' })).toHaveLength(2)
|
||||
expect(filterIssues(issues, { kind: 'pr' })).toHaveLength(1)
|
||||
expect(filterIssues(issues, { source: 'git' })).toHaveLength(1)
|
||||
expect(filterIssues(issues, { assignee: 'ada@hanzo.ai' })).toHaveLength(1)
|
||||
expect(filterIssues(issues, { label: 'bug' })).toHaveLength(1)
|
||||
expect(filterIssues(issues, { team: 'ENG' })).toHaveLength(3)
|
||||
expect(filterIssues(issues, { team: 'ZZZ' })).toHaveLength(0)
|
||||
})
|
||||
it('AND-composes multiple constraints', () => {
|
||||
expect(filterIssues(issues, { status: 'todo', priority: 'urgent' })).toHaveLength(1)
|
||||
})
|
||||
it('counts active constraints', () => {
|
||||
expect(countFilters({})).toBe(0)
|
||||
expect(countFilters({ status: 'todo', q: 'x' })).toBe(2)
|
||||
expect(countFilters({ q: '' })).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('matchesQuery', () => {
|
||||
const i = mk({ identifier: 'GH-42', title: 'Login redirect loops', labels: ['auth'], assignee: 'ada@hanzo.ai', repo: 'hanzoai/iam' })
|
||||
it('is a case-insensitive substring over identity/title/labels/assignee/repo', () => {
|
||||
expect(matchesQuery(i, 'gh-42')).toBe(true)
|
||||
expect(matchesQuery(i, 'redirect')).toBe(true)
|
||||
expect(matchesQuery(i, 'AUTH')).toBe(true)
|
||||
expect(matchesQuery(i, 'iam')).toBe(true)
|
||||
expect(matchesQuery(i, 'nope')).toBe(false)
|
||||
expect(matchesQuery(i, '')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sortIssues', () => {
|
||||
it('orders by priority (urgent→none) then most-recently-updated', () => {
|
||||
const a = mk({ priority: 'low', updatedAt: 100 })
|
||||
const b = mk({ priority: 'urgent', updatedAt: 1 })
|
||||
const c = mk({ priority: 'urgent', updatedAt: 50 })
|
||||
const out = sortIssues([a, b, c])
|
||||
expect(out.map((x) => x.id)).toEqual([c.id, b.id, a.id])
|
||||
})
|
||||
it('does not mutate the input', () => {
|
||||
const input = [mk({ priority: 'low' }), mk({ priority: 'urgent' })]
|
||||
const snap = input.map((x) => x.id)
|
||||
sortIssues(input)
|
||||
expect(input.map((x) => x.id)).toEqual(snap)
|
||||
})
|
||||
})
|
||||
|
||||
describe('groupIssues', () => {
|
||||
const issues = [
|
||||
mk({ status: 'todo', priority: 'high', assignee: 'ada', projectKey: 'ENG' }),
|
||||
mk({ status: 'done', priority: 'low', projectKey: 'GH' }),
|
||||
mk({ status: 'todo', priority: 'urgent', assignee: 'ada', projectKey: 'ENG' }),
|
||||
]
|
||||
it('status grouping renders all five columns in order', () => {
|
||||
const g = groupIssues(issues, 'status')
|
||||
expect(g.map((x) => x.key)).toEqual(['backlog', 'todo', 'in_progress', 'done', 'canceled'])
|
||||
expect(g.find((x) => x.key === 'todo')!.issues).toHaveLength(2)
|
||||
})
|
||||
it('assignee grouping puts Unassigned last', () => {
|
||||
const g = groupIssues(issues, 'assignee')
|
||||
expect(g[g.length - 1].label).toBe('Unassigned')
|
||||
expect(g[0].label).toBe('ada')
|
||||
})
|
||||
it('team grouping buckets by projectKey', () => {
|
||||
const g = groupIssues(issues, 'team')
|
||||
expect(g.map((x) => x.key).sort()).toEqual(['ENG', 'GH'])
|
||||
})
|
||||
it('none is a single group', () => {
|
||||
expect(groupIssues(issues, 'none')).toHaveLength(1)
|
||||
expect(groupIssues(issues, 'none')[0].issues).toHaveLength(3)
|
||||
})
|
||||
it('board columns are always the five statuses', () => {
|
||||
expect(boardColumns([]).map((x) => x.key)).toEqual(['backlog', 'todo', 'in_progress', 'done', 'canceled'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('myIssues', () => {
|
||||
const issues = [
|
||||
mk({ assignee: 'ada@hanzo.ai' }),
|
||||
mk({ assignee: 'Ada' }),
|
||||
mk({ assignee: 'bob@hanzo.ai' }),
|
||||
mk({ assignee: undefined }),
|
||||
]
|
||||
it('matches full email, local-part, and case-insensitively', () => {
|
||||
expect(myIssues(issues, 'ada@hanzo.ai')).toHaveLength(2)
|
||||
expect(myIssues(issues, 'ADA')).toHaveLength(2)
|
||||
expect(myIssues(issues, 'bob')).toHaveLength(1)
|
||||
})
|
||||
it('empty identity → nothing (honest)', () => {
|
||||
expect(myIssues(issues, '')).toHaveLength(0)
|
||||
expect(myIssues(issues, null)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveCurrentCycle', () => {
|
||||
const now = 1_700_000_000_000
|
||||
const recent = Math.floor(now / 1000) - 2 * 86400 // 2d ago (seconds)
|
||||
const old = Math.floor(now / 1000) - 30 * 86400 // 30d ago
|
||||
const issues = [
|
||||
mk({ status: 'done', updatedAt: recent }),
|
||||
mk({ status: 'in_progress', updatedAt: recent }),
|
||||
mk({ status: 'todo', updatedAt: recent }),
|
||||
mk({ status: 'done', updatedAt: old }), // outside the window
|
||||
mk({ status: 'canceled', updatedAt: recent }), // excluded
|
||||
]
|
||||
it('windows recent non-canceled work and computes progress', () => {
|
||||
const c = deriveCurrentCycle(issues, 14, now)
|
||||
expect(c.total).toBe(3) // recent done + in_progress + todo
|
||||
expect(c.done).toBe(1)
|
||||
expect(c.progress).toBeCloseTo(1 / 3)
|
||||
expect(c.active.map((i) => i.status).sort()).toEqual(['in_progress', 'todo'])
|
||||
})
|
||||
it('empty set → zero progress, no divide-by-zero', () => {
|
||||
expect(deriveCurrentCycle([], 14, now).progress).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveRoadmap', () => {
|
||||
it('links epic children by ExtRef (identifier or id) and computes progress', () => {
|
||||
const epic = mk({ id: 'epic_1', identifier: 'ENG-1', kind: 'epic', title: 'Auth revamp' })
|
||||
const c1 = mk({ identifier: 'ENG-2', extRef: 'ENG-1', status: 'done' })
|
||||
const c2 = mk({ identifier: 'ENG-3', extRef: 'ENG-1', status: 'todo' })
|
||||
const c3 = mk({ identifier: 'ENG-4', extRef: 'epic_1', status: 'in_progress' })
|
||||
const unrelated = mk({ identifier: 'ENG-5' })
|
||||
const rows = deriveRoadmap([epic, c1, c2, c3, unrelated])
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0].total).toBe(3)
|
||||
expect(rows[0].done).toBe(1)
|
||||
expect(rows[0].children.map((x) => x.identifier).sort()).toEqual(['ENG-2', 'ENG-3', 'ENG-4'])
|
||||
})
|
||||
it('no epics → empty roadmap (honest)', () => {
|
||||
expect(deriveRoadmap([mk(), mk()])).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('external links', () => {
|
||||
it('parses the github: anchor form to an issues URL', () => {
|
||||
expect(githubUrl(mk({ extRef: 'github:hanzoai/cloud#123' }))).toBe('https://github.com/hanzoai/cloud/issues/123')
|
||||
})
|
||||
it('passes an absolute URL through', () => {
|
||||
expect(githubUrl(mk({ extRef: 'https://github.com/hanzoai/cloud/pull/9' }))).toBe('https://github.com/hanzoai/cloud/pull/9')
|
||||
})
|
||||
it('is null without a recognizable anchor', () => {
|
||||
expect(githubUrl(mk({ extRef: 'some-branch' }))).toBeNull()
|
||||
expect(githubUrl(mk({}))).toBeNull()
|
||||
})
|
||||
it('builds the native git.hanzo.ai URL', () => {
|
||||
expect(gitHanzoUrl('hanzoai/cloud')).toBe('https://git.hanzo.ai/hanzoai/cloud')
|
||||
expect(gitHanzoUrl('')).toBeNull()
|
||||
expect(gitHanzoUrl(undefined)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('linkedPRs', () => {
|
||||
it('finds agent/git PR rows by shared repo or ExtRef parent', () => {
|
||||
const issue = mk({ identifier: 'ENG-10', repo: 'hanzoai/cloud' })
|
||||
const prSameRepo = mk({ kind: 'pr', source: 'agent', repo: 'hanzoai/cloud', extRef: 'feat/x' })
|
||||
const prParent = mk({ kind: 'pr', source: 'agent', repo: 'other/repo', extRef: 'ENG-10' })
|
||||
const otherPr = mk({ kind: 'pr', repo: 'unrelated/repo', extRef: 'feat/y' })
|
||||
const links = linkedPRs(issue, [issue, prSameRepo, prParent, otherPr])
|
||||
expect(links.map((x) => x.id).sort()).toEqual([prParent.id, prSameRepo.id].sort())
|
||||
})
|
||||
it('a PR row itself links to nothing', () => {
|
||||
expect(linkedPRs(mk({ kind: 'pr' }), [])).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('relTime', () => {
|
||||
const now = 1_700_000_000_000
|
||||
it('renders compact buckets', () => {
|
||||
expect(relTime(Math.floor(now / 1000), now)).toBe('just now')
|
||||
expect(relTime(Math.floor(now / 1000) - 3600, now)).toBe('1h')
|
||||
expect(relTime(Math.floor(now / 1000) - 3 * 86400, now)).toBe('3d')
|
||||
expect(relTime(undefined, now)).toBe('—')
|
||||
})
|
||||
})
|
||||
|
||||
describe('menu helpers', () => {
|
||||
const issues = [mk({ assignee: 'ada', labels: ['bug', 'p1'] }), mk({ assignee: 'bob', labels: ['bug'] }), mk({})]
|
||||
it('assigneesOf is distinct + sorted', () => {
|
||||
expect(assigneesOf(issues)).toEqual(['ada', 'bob'])
|
||||
})
|
||||
it('labelsOf is distinct + sorted', () => {
|
||||
expect(labelsOf(issues)).toEqual(['bug', 'p1'])
|
||||
})
|
||||
it('parseLabels trims and drops empties', () => {
|
||||
expect(parseLabels('bug, , p1 ,')).toEqual(['bug', 'p1'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* Tracker logic — PURE, dependency-free decisions for the Linear-grade tracker, so
|
||||
* the grouping / filtering / sorting / cycle+roadmap derivation / link building is
|
||||
* unit-tested in isolation (logic.test.ts) with no React, no network. The views are
|
||||
* thin renderers over these functions; every "where does this issue go / how is it
|
||||
* ordered / what links to it" question is answered HERE, once.
|
||||
*
|
||||
* All of it operates over the real `Issue`/`Project` payloads from `lib/api/tracker`
|
||||
* — no fabricated rows, honest empties. Cycles + Roadmap are DERIVED from the real
|
||||
* model (active set + progress; epics and their ExtRef children), never invented.
|
||||
*/
|
||||
import {
|
||||
type Issue,
|
||||
type Project,
|
||||
type Status,
|
||||
type Priority,
|
||||
type Kind,
|
||||
type Source,
|
||||
STATUSES,
|
||||
ACTIVE_STATUSES,
|
||||
} from '~/lib/api/tracker'
|
||||
|
||||
// ── Presentation tokens (as-const so literals satisfy the GUI color union, exactly
|
||||
// like StatusTag's TONE maps) ──────────────────────────────────────────────
|
||||
export const STATUS_ORDER: readonly Status[] = STATUSES
|
||||
|
||||
export const STATUS_LABEL: Record<Status, string> = {
|
||||
backlog: 'Backlog',
|
||||
todo: 'Todo',
|
||||
in_progress: 'In Progress',
|
||||
done: 'Done',
|
||||
canceled: 'Canceled',
|
||||
}
|
||||
export const STATUS_DOT = {
|
||||
backlog: '$color8',
|
||||
todo: '$color11',
|
||||
in_progress: '$yellow10',
|
||||
done: '$green10',
|
||||
canceled: '$red10',
|
||||
} as const
|
||||
|
||||
export const PRIORITY_ORDER: readonly Priority[] = ['urgent', 'high', 'medium', 'low', 'none']
|
||||
export const PRIORITY_LABEL: Record<Priority, string> = {
|
||||
none: 'No priority',
|
||||
urgent: 'Urgent',
|
||||
high: 'High',
|
||||
medium: 'Medium',
|
||||
low: 'Low',
|
||||
}
|
||||
export const PRIORITY_COLOR = {
|
||||
none: '$color9',
|
||||
urgent: '$red10',
|
||||
high: '$yellow10',
|
||||
medium: '$yellow10',
|
||||
low: '$color10',
|
||||
} as const
|
||||
/** Sort rank — urgent first, no-priority last. Lower = higher up. */
|
||||
export const PRIORITY_RANK: Record<Priority, number> = { urgent: 0, high: 1, medium: 2, low: 3, none: 4 }
|
||||
|
||||
export const KIND_LABEL: Record<Kind, string> = { issue: 'Issue', pr: 'Pull Request', epic: 'Epic' }
|
||||
export const SOURCE_LABEL: Record<Source, string> = {
|
||||
team: 'Team',
|
||||
git: 'GitHub',
|
||||
crm: 'CRM',
|
||||
helpdesk: 'Helpdesk',
|
||||
cms: 'CMS',
|
||||
agent: 'Agent',
|
||||
}
|
||||
|
||||
// ── Dates ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Format an epoch timestamp (seconds OR milliseconds) as a local date. */
|
||||
export const fmtDate = (ts?: number): string => {
|
||||
if (!ts) return '—'
|
||||
const d = new Date(ts < 1e12 ? ts * 1000 : ts)
|
||||
return Number.isNaN(d.getTime()) ? '—' : d.toLocaleDateString()
|
||||
}
|
||||
|
||||
/** Compact relative time ("3d", "2h", "just now"). `now` injectable for tests. */
|
||||
export const relTime = (ts?: number, now: number = Date.now()): string => {
|
||||
if (!ts) return '—'
|
||||
const ms = ts < 1e12 ? ts * 1000 : ts
|
||||
const s = Math.max(0, Math.floor((now - ms) / 1000))
|
||||
if (s < 45) return 'just now'
|
||||
const m = Math.floor(s / 60)
|
||||
if (m < 60) return `${m}m`
|
||||
const h = Math.floor(m / 60)
|
||||
if (h < 24) return `${h}h`
|
||||
const d = Math.floor(h / 24)
|
||||
if (d < 7) return `${d}d`
|
||||
const w = Math.floor(d / 7)
|
||||
if (w < 5) return `${w}w`
|
||||
return fmtDate(ts)
|
||||
}
|
||||
|
||||
// ── Filtering ────────────────────────────────────────────────────────────────
|
||||
|
||||
export type IssueFilters = {
|
||||
status?: Status
|
||||
priority?: Priority
|
||||
kind?: Kind
|
||||
source?: Source
|
||||
assignee?: string
|
||||
label?: string
|
||||
team?: string
|
||||
/** free-text — substring over identifier / title / labels / assignee / repo. */
|
||||
q?: string
|
||||
}
|
||||
|
||||
/** Split a comma input into a clean label list. */
|
||||
export const parseLabels = (s: string): string[] => s.split(',').map((x) => x.trim()).filter(Boolean)
|
||||
|
||||
/** Does `issue` match the free-text query (case-insensitive substring, any field)? */
|
||||
export function matchesQuery(i: Issue, q: string): boolean {
|
||||
const needle = q.trim().toLowerCase()
|
||||
if (!needle) return true
|
||||
const hay = [i.identifier, i.title, i.assignee ?? '', i.repo ?? '', i.labels.join(' ')]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
return hay.includes(needle)
|
||||
}
|
||||
|
||||
/** Apply the active filter set. Empty fields impose no constraint. Pure. */
|
||||
export function filterIssues(issues: Issue[], f: IssueFilters): Issue[] {
|
||||
return issues.filter((i) => {
|
||||
if (f.status && i.status !== f.status) return false
|
||||
if (f.priority && i.priority !== f.priority) return false
|
||||
if (f.kind && i.kind !== f.kind) return false
|
||||
if (f.source && i.source !== f.source) return false
|
||||
if (f.team && i.projectKey !== f.team) return false
|
||||
if (f.assignee && (i.assignee ?? '') !== f.assignee) return false
|
||||
if (f.label && !i.labels.includes(f.label)) return false
|
||||
if (f.q && !matchesQuery(i, f.q)) return false
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/** Count active (non-empty) constraints — drives the "N filters" pill. */
|
||||
export const countFilters = (f: IssueFilters): number =>
|
||||
(['status', 'priority', 'kind', 'source', 'assignee', 'label', 'team', 'q'] as const).filter(
|
||||
(k) => (f[k] ?? '') !== '',
|
||||
).length
|
||||
|
||||
// ── Sorting ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Canonical in-group order: priority (urgent→none), then most-recently-updated. */
|
||||
export function sortIssues(issues: Issue[]): Issue[] {
|
||||
return [...issues].sort(
|
||||
(a, b) => PRIORITY_RANK[a.priority] - PRIORITY_RANK[b.priority] || b.updatedAt - a.updatedAt,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Grouping ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export type GroupBy = 'status' | 'priority' | 'assignee' | 'team' | 'none'
|
||||
export const GROUP_BY: readonly GroupBy[] = ['status', 'priority', 'assignee', 'team', 'none']
|
||||
export const GROUP_BY_LABEL: Record<GroupBy, string> = {
|
||||
status: 'Status',
|
||||
priority: 'Priority',
|
||||
assignee: 'Assignee',
|
||||
team: 'Team',
|
||||
none: 'No grouping',
|
||||
}
|
||||
|
||||
export type IssueGroup = { key: string; label: string; issues: Issue[] }
|
||||
|
||||
/**
|
||||
* Group + sort issues for a sectioned List. `status`/`priority` render EVERY column
|
||||
* in canonical order (even empty ones — a board shows its empty columns);
|
||||
* `assignee`/`team` render only the groups that occur, alphabetically (Unassigned
|
||||
* last). `none` is one anonymous group. Each group's issues are `sortIssues`-ordered.
|
||||
*/
|
||||
export function groupIssues(issues: Issue[], by: GroupBy): IssueGroup[] {
|
||||
if (by === 'none') return [{ key: 'all', label: '', issues: sortIssues(issues) }]
|
||||
|
||||
if (by === 'status') {
|
||||
return STATUS_ORDER.map((s) => ({
|
||||
key: s,
|
||||
label: STATUS_LABEL[s],
|
||||
issues: sortIssues(issues.filter((i) => i.status === s)),
|
||||
}))
|
||||
}
|
||||
if (by === 'priority') {
|
||||
return PRIORITY_ORDER.map((p) => ({
|
||||
key: p,
|
||||
label: PRIORITY_LABEL[p],
|
||||
issues: sortIssues(issues.filter((i) => i.priority === p)),
|
||||
}))
|
||||
}
|
||||
|
||||
// assignee | team — dynamic buckets, sorted, "Unassigned"/blank team last.
|
||||
const keyOf = (i: Issue) => (by === 'assignee' ? i.assignee ?? '' : i.projectKey)
|
||||
const buckets = new Map<string, Issue[]>()
|
||||
for (const i of issues) {
|
||||
const k = keyOf(i)
|
||||
;(buckets.get(k) ?? buckets.set(k, []).get(k)!).push(i)
|
||||
}
|
||||
const keys = [...buckets.keys()].sort((a, b) => {
|
||||
if (a === '' && b !== '') return 1
|
||||
if (b === '' && a !== '') return -1
|
||||
return a.localeCompare(b)
|
||||
})
|
||||
return keys.map((k) => ({
|
||||
key: k || 'unassigned',
|
||||
label: k || (by === 'assignee' ? 'Unassigned' : 'No team'),
|
||||
issues: sortIssues(buckets.get(k)!),
|
||||
}))
|
||||
}
|
||||
|
||||
/** Board columns — always the five statuses, in order (Board view is status-fixed). */
|
||||
export function boardColumns(issues: Issue[]): IssueGroup[] {
|
||||
return STATUS_ORDER.map((s) => ({
|
||||
key: s,
|
||||
label: STATUS_LABEL[s],
|
||||
issues: sortIssues(issues.filter((i) => i.status === s)),
|
||||
}))
|
||||
}
|
||||
|
||||
// ── "My Issues" ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Issues assigned to `me` — matched case-insensitively against the assignee, by
|
||||
* either the full value or its local-part (so "ada@hanzo.ai", "ada", and "Ada" all
|
||||
* hit). Empty `me` → nothing (honest; the caller shows a sign-in-scoped empty).
|
||||
*/
|
||||
export function myIssues(issues: Issue[], me?: string | null): Issue[] {
|
||||
const id = (me ?? '').trim().toLowerCase()
|
||||
if (!id) return []
|
||||
const local = id.split('@')[0]
|
||||
return issues.filter((i) => {
|
||||
const a = (i.assignee ?? '').trim().toLowerCase()
|
||||
return a !== '' && (a === id || a === local || a.split('@')[0] === local)
|
||||
})
|
||||
}
|
||||
|
||||
// ── Cycles (derived, honest) ─────────────────────────────────────────────────
|
||||
|
||||
export type CycleSummary = {
|
||||
/** active work (todo + in_progress) — the current cycle's scope. */
|
||||
active: Issue[]
|
||||
/** everything touched recently (default 14d) — the cycle window. */
|
||||
inWindow: Issue[]
|
||||
total: number
|
||||
done: number
|
||||
/** 0..1 completion of the windowed set. */
|
||||
progress: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The "current cycle" — a DERIVED iteration view (Linear's default cadence is a
|
||||
* rolling 1–2 week cycle). We do not invent a cycle entity the backend lacks: the
|
||||
* current cycle is the issues touched inside `windowDays` (default 14), its progress
|
||||
* the share already Done, and its scope the still-active work. Honest + real.
|
||||
*/
|
||||
export function deriveCurrentCycle(issues: Issue[], windowDays = 14, now: number = Date.now()): CycleSummary {
|
||||
const cutoff = now - windowDays * 86400_000
|
||||
const inWindow = issues.filter((i) => {
|
||||
const ms = i.updatedAt < 1e12 ? i.updatedAt * 1000 : i.updatedAt
|
||||
return ms >= cutoff && i.status !== 'canceled'
|
||||
})
|
||||
const active = issues.filter((i) => (ACTIVE_STATUSES as readonly Status[]).includes(i.status))
|
||||
const total = inWindow.length
|
||||
const done = inWindow.filter((i) => i.status === 'done').length
|
||||
return { active: sortIssues(active), inWindow, total, done, progress: total ? done / total : 0 }
|
||||
}
|
||||
|
||||
// ── Roadmap (epics + children, derived) ──────────────────────────────────────
|
||||
|
||||
export type EpicRow = { epic: Issue; children: Issue[]; total: number; done: number; progress: number }
|
||||
|
||||
/**
|
||||
* Roadmap = every epic (Kind:"epic") with its children — the issues whose ExtRef
|
||||
* anchors that epic (per the cloud contract: "an epic's children are issues whose
|
||||
* ExtRef is the epic"). We match ExtRef against the epic's identifier OR id, so a
|
||||
* child can point at either. Progress is the share of children already Done. Epics
|
||||
* with the most open work sort first.
|
||||
*/
|
||||
export function deriveRoadmap(issues: Issue[]): EpicRow[] {
|
||||
const epics = issues.filter((i) => i.kind === 'epic')
|
||||
const rows = epics.map((epic) => {
|
||||
const children = issues.filter(
|
||||
(i) => i.id !== epic.id && (i.extRef === epic.identifier || i.extRef === epic.id),
|
||||
)
|
||||
const total = children.length
|
||||
const done = children.filter((c) => c.status === 'done').length
|
||||
return { epic, children: sortIssues(children), total, done, progress: total ? done / total : 0 }
|
||||
})
|
||||
return rows.sort((a, b) => b.total - b.done - (a.total - a.done) || b.epic.updatedAt - a.epic.updatedAt)
|
||||
}
|
||||
|
||||
// ── External links (GitHub source + native git.hanzo.ai) ─────────────────────
|
||||
|
||||
/**
|
||||
* The upstream GitHub URL for a mirrored issue, or null. The App-webhook seam stores
|
||||
* ExtRef as "github:owner/repo#123" (or an https URL). Parse the anchor form to the
|
||||
* canonical issues URL; pass an already-absolute URL through.
|
||||
*/
|
||||
export function githubUrl(i: Issue): string | null {
|
||||
const ref = (i.extRef ?? '').trim()
|
||||
if (/^https?:\/\//i.test(ref)) return ref
|
||||
const m = ref.match(/^github:([^#\s]+)#(\d+)$/i)
|
||||
if (m) return `https://github.com/${m[1]}/issues/${m[2]}`
|
||||
return null
|
||||
}
|
||||
|
||||
/** The native git.hanzo.ai URL for a repo binding (owner/name), or null. */
|
||||
export function gitHanzoUrl(repo?: string): string | null {
|
||||
const r = (repo ?? '').trim().replace(/^\/+|\/+$/g, '')
|
||||
return r ? `https://git.hanzo.ai/${r}` : null
|
||||
}
|
||||
|
||||
/**
|
||||
* PRs linked to `issue` within `all`: agent/git pull-request rows (Kind:"pr") that
|
||||
* either share the issue's repo binding or anchor this issue by ExtRef (the coding
|
||||
* seam sets the PR's ExtRef to its branch and can parent the originating issue). The
|
||||
* issue↔branch↔PR chain the detail pane renders. Excludes the issue itself.
|
||||
*/
|
||||
export function linkedPRs(issue: Issue, all: Issue[]): Issue[] {
|
||||
if (issue.kind === 'pr') return []
|
||||
return sortIssues(
|
||||
all.filter(
|
||||
(i) =>
|
||||
i.id !== issue.id &&
|
||||
i.kind === 'pr' &&
|
||||
((issue.repo && i.repo === issue.repo) || i.extRef === issue.identifier),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** True when the DOM target is a text-entry element — suppress single-key shortcuts. */
|
||||
export function isTypingTarget(el: EventTarget | null): boolean {
|
||||
const t = el as HTMLElement | null
|
||||
if (!t) return false
|
||||
const tag = t.tagName
|
||||
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t.isContentEditable === true
|
||||
}
|
||||
|
||||
/** Distinct assignees present in a set, sorted (for the filter menu). */
|
||||
export const assigneesOf = (issues: Issue[]): string[] =>
|
||||
[...new Set(issues.map((i) => (i.assignee ?? '').trim()).filter(Boolean))].sort((a, b) => a.localeCompare(b))
|
||||
|
||||
/** Distinct labels present in a set, sorted (for the filter menu). */
|
||||
export const labelsOf = (issues: Issue[]): string[] =>
|
||||
[...new Set(issues.flatMap((i) => i.labels))].filter(Boolean).sort((a, b) => a.localeCompare(b))
|
||||
|
||||
/** Map a team KEY to its display name from the project list (falls back to the key). */
|
||||
export const teamName = (key: string, projects: Project[]): string =>
|
||||
projects.find((p) => p.key === key)?.name || key
|
||||
@@ -0,0 +1,502 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Tracker views — the Linear-grade surfaces rendered by the module's router:
|
||||
* IssuesView the unified issues board (All / My / a Team) — toolbar + group/filter/
|
||||
* search + List⇄Board + keyboard selection (j/k/↑/↓/Enter/e, c, /).
|
||||
* TeamsView the teams (KEY-prefixed projects) list + create, with a GitHub sync.
|
||||
* CyclesView the current cycle — derived from the active window, honest progress.
|
||||
* RoadmapView epics + their ExtRef children, with completion.
|
||||
*
|
||||
* Every view reads the SAME unified `issues` array (composed cross-team in the module)
|
||||
* and decides layout purely via `logic.ts`. Honest empties; no fabricated rows.
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { ClipboardList, Plus, RefreshCw, Github, Layers, Target, ArrowLeft } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import {
|
||||
TrackerApi,
|
||||
type Issue,
|
||||
type Project,
|
||||
type NewProject,
|
||||
} from '~/lib/api/tracker'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { EmptyState } from '~/components/ui/EmptyState'
|
||||
import { FieldRow, FieldText, FieldTextArea } from '~/components/ui/Field'
|
||||
import { PrimaryButton } from '~/components/ui/PrimaryButton'
|
||||
import { useToast } from '~/components/ui/Toast'
|
||||
import { asApiError } from '~/components/ui/States'
|
||||
import {
|
||||
type GroupBy,
|
||||
type IssueFilters,
|
||||
filterIssues,
|
||||
groupIssues,
|
||||
boardColumns,
|
||||
deriveCurrentCycle,
|
||||
deriveRoadmap,
|
||||
assigneesOf,
|
||||
labelsOf,
|
||||
isTypingTarget,
|
||||
teamName,
|
||||
relTime,
|
||||
STATUS_LABEL,
|
||||
} from './logic'
|
||||
import { Toolbar } from './Toolbar'
|
||||
import { GroupedList, Board } from './IssueList'
|
||||
import { StatusIcon, PriorityIcon, Identifier, ProgressBar } from './atoms'
|
||||
|
||||
// ── The unified issues view (All / My / a Team) ──────────────────────────────
|
||||
|
||||
export function IssuesView({
|
||||
issues,
|
||||
projects,
|
||||
header,
|
||||
showTeam = true,
|
||||
defaultFilters,
|
||||
onOpen,
|
||||
onCreate,
|
||||
onReload,
|
||||
actions,
|
||||
}: {
|
||||
issues: Issue[]
|
||||
projects: Project[]
|
||||
header: React.ReactNode
|
||||
showTeam?: boolean
|
||||
defaultFilters?: IssueFilters
|
||||
onOpen: (i: Issue) => void
|
||||
onCreate: () => void
|
||||
onReload: () => void
|
||||
actions?: React.ReactNode
|
||||
}) {
|
||||
const [view, setView] = useState<'list' | 'board'>('list')
|
||||
const [groupBy, setGroupBy] = useState<GroupBy>('status')
|
||||
const [filters, setFilters] = useState<IssueFilters>({})
|
||||
const [query, setQuery] = useState('')
|
||||
const [selectedId, setSelectedId] = useState<string | undefined>(undefined)
|
||||
const searchRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const filtered = useMemo(
|
||||
() => filterIssues(issues, { ...defaultFilters, ...filters, q: query }),
|
||||
[issues, defaultFilters, filters, query],
|
||||
)
|
||||
const groups = useMemo(
|
||||
() => (view === 'board' ? boardColumns(filtered) : groupIssues(filtered, groupBy)),
|
||||
[filtered, groupBy, view],
|
||||
)
|
||||
const flat = useMemo(() => groups.flatMap((g) => g.issues), [groups])
|
||||
const assignees = useMemo(() => assigneesOf(issues), [issues])
|
||||
const labels = useMemo(() => labelsOf(issues), [issues])
|
||||
|
||||
// Keep the selection valid as the list changes.
|
||||
useEffect(() => {
|
||||
if (selectedId && !flat.some((i) => i.id === selectedId)) setSelectedId(flat[0]?.id)
|
||||
}, [flat, selectedId])
|
||||
|
||||
// Keyboard-first (Linear): c=create, /=search, j/k/↑/↓=move, Enter=open, e=edit.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return
|
||||
if (e.key === '/') {
|
||||
if (isTypingTarget(e.target)) return
|
||||
e.preventDefault()
|
||||
searchRef.current?.focus()
|
||||
return
|
||||
}
|
||||
if (isTypingTarget(e.target)) return
|
||||
if (e.key === 'c') {
|
||||
e.preventDefault()
|
||||
onCreate()
|
||||
} else if (e.key === 'j' || e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setSelectedId((cur) => {
|
||||
const idx = flat.findIndex((i) => i.id === cur)
|
||||
return flat[Math.min(idx + 1, flat.length - 1)]?.id ?? flat[0]?.id
|
||||
})
|
||||
} else if (e.key === 'k' || e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setSelectedId((cur) => {
|
||||
const idx = flat.findIndex((i) => i.id === cur)
|
||||
return flat[Math.max(idx - 1, 0)]?.id ?? flat[0]?.id
|
||||
})
|
||||
} else if (e.key === 'Enter' || e.key === 'e') {
|
||||
const sel = flat.find((i) => i.id === selectedId)
|
||||
if (sel) {
|
||||
e.preventDefault()
|
||||
onOpen(sel)
|
||||
}
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [flat, selectedId, onCreate, onOpen])
|
||||
|
||||
const glyphFor = useCallback(
|
||||
(g: { key: string }) =>
|
||||
groupBy === 'status' || view === 'board'
|
||||
? <StatusIcon status={g.key as never} size={14} />
|
||||
: groupBy === 'priority'
|
||||
? <PriorityIcon priority={g.key as never} size={13} />
|
||||
: undefined,
|
||||
[groupBy, view],
|
||||
)
|
||||
|
||||
return (
|
||||
<YStack gap="$4">
|
||||
{header}
|
||||
<Toolbar
|
||||
ref={searchRef}
|
||||
view={view}
|
||||
onView={setView}
|
||||
groupBy={groupBy}
|
||||
onGroupBy={setGroupBy}
|
||||
filters={filters}
|
||||
onFilters={setFilters}
|
||||
query={query}
|
||||
onQuery={setQuery}
|
||||
assignees={assignees}
|
||||
labels={labels}
|
||||
actions={
|
||||
<>
|
||||
<Button size="$2" icon={<RefreshCw size={14} />} onPress={onReload} aria-label="Refresh" />
|
||||
{actions}
|
||||
<Button size="$2" icon={<Plus size={14} />} onPress={onCreate}>
|
||||
New issue
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{filtered.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={ClipboardList}
|
||||
title={issues.length === 0 ? 'No issues yet' : 'No issues match'}
|
||||
description={
|
||||
issues.length === 0
|
||||
? 'Create an issue, or connect GitHub so every repo’s issues flow in here.'
|
||||
: 'Adjust the filters or search to see issues.'
|
||||
}
|
||||
primary={issues.length === 0 ? { label: 'New issue', icon: <Plus size={15} />, onPress: onCreate } : undefined}
|
||||
/>
|
||||
) : view === 'board' ? (
|
||||
<Board columns={groups} projects={projects} selectedId={selectedId} showTeam={showTeam} glyphFor={glyphFor} onOpen={onOpen} />
|
||||
) : (
|
||||
<GroupedList
|
||||
groups={groups}
|
||||
projects={projects}
|
||||
selectedId={selectedId}
|
||||
showTeam={showTeam}
|
||||
keepEmpty={groupBy === 'status'}
|
||||
glyphFor={glyphFor}
|
||||
onOpen={onOpen}
|
||||
/>
|
||||
)}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Teams (KEY-prefixed projects) ────────────────────────────────────────────
|
||||
|
||||
export function TeamsView({
|
||||
projects,
|
||||
issues,
|
||||
loading,
|
||||
onOpenTeam,
|
||||
onReload,
|
||||
onSyncGitHub,
|
||||
syncing,
|
||||
}: {
|
||||
projects: Project[]
|
||||
issues: Issue[]
|
||||
loading: boolean
|
||||
onOpenTeam: (key: string) => void
|
||||
onReload: () => void
|
||||
onSyncGitHub: () => void
|
||||
syncing: boolean
|
||||
}) {
|
||||
const toast = useToast()
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [key, setKey] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const m = new Map<string, { total: number; open: number }>()
|
||||
for (const i of issues) {
|
||||
const c = m.get(i.projectKey) ?? { total: 0, open: 0 }
|
||||
c.total += 1
|
||||
if (i.status !== 'done' && i.status !== 'canceled') c.open += 1
|
||||
m.set(i.projectKey, c)
|
||||
}
|
||||
return m
|
||||
}, [issues])
|
||||
|
||||
const reset = () => {
|
||||
setKey('')
|
||||
setName('')
|
||||
setDescription('')
|
||||
setCreating(false)
|
||||
}
|
||||
const create = async () => {
|
||||
const n = name.trim()
|
||||
if (!n) {
|
||||
toast.error('Name required', 'Give the team a name.')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
try {
|
||||
const payload: NewProject = { name: n, description: description.trim() || undefined, ...(key.trim() ? { key: key.trim().toUpperCase() } : {}) }
|
||||
const created = await TrackerApi.createProject(payload)
|
||||
toast.success('Team created', `${created.key} · ${created.name}`)
|
||||
reset()
|
||||
onReload()
|
||||
} catch (e) {
|
||||
toast.error('Could not create team', asApiError(e).message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<YStack gap="$4">
|
||||
<PageHeader
|
||||
title="Teams"
|
||||
subtitle="Each team groups issues under a KEY (ENG-1, ENG-2…). GitHub-mirrored repos share the GH team."
|
||||
actions={
|
||||
<XStack gap="$2" flexWrap="wrap">
|
||||
<Button size="$2" icon={<RefreshCw size={14} />} onPress={onReload} aria-label="Refresh" />
|
||||
<Button size="$2" icon={<Github size={14} />} onPress={onSyncGitHub} disabled={syncing}>
|
||||
{syncing ? 'Syncing…' : 'Sync GitHub'}
|
||||
</Button>
|
||||
<Button size="$2" icon={<Plus size={14} />} onPress={() => setCreating((v) => !v)}>
|
||||
New team
|
||||
</Button>
|
||||
</XStack>
|
||||
}
|
||||
/>
|
||||
{creating ? (
|
||||
<Card borderWidth={1} borderColor="$borderColor" p="$4" gap="$3" maxW={640}>
|
||||
<Text fontSize="$5" fontWeight="700">
|
||||
New team
|
||||
</Text>
|
||||
<FieldRow label="Key (optional)">
|
||||
<FieldText value={key} onChange={setKey} placeholder="ENG" disabled={busy} />
|
||||
</FieldRow>
|
||||
<FieldRow label="Name">
|
||||
<FieldText value={name} onChange={setName} placeholder="Engineering" disabled={busy} />
|
||||
</FieldRow>
|
||||
<FieldRow label="Description">
|
||||
<FieldTextArea value={description} onChange={setDescription} rows={3} disabled={busy} />
|
||||
</FieldRow>
|
||||
<Text fontSize="$2" color="$color10">
|
||||
The key is uppercase (A–Z then A–Z0–9, 2–8 chars) and prefixes every issue (ENG-1). Omit it to derive one from the name.
|
||||
</Text>
|
||||
<XStack gap="$2">
|
||||
<PrimaryButton disabled={busy} onPress={() => void create()}>
|
||||
{busy ? 'Creating…' : 'Create team'}
|
||||
</PrimaryButton>
|
||||
<Button chromeless disabled={busy} onPress={reset}>
|
||||
Cancel
|
||||
</Button>
|
||||
</XStack>
|
||||
</Card>
|
||||
) : null}
|
||||
{!loading && projects.length === 0 && !creating ? (
|
||||
<EmptyState
|
||||
icon={ClipboardList}
|
||||
title="No teams yet"
|
||||
description="A team groups issues under a key. Create one, or Sync GitHub to mirror every repo’s issues into the GH team."
|
||||
primary={{ label: 'New team', icon: <Plus size={15} />, onPress: () => setCreating(true) }}
|
||||
/>
|
||||
) : (
|
||||
<YStack gap="$2">
|
||||
{projects.map((p) => {
|
||||
const c = counts.get(p.key) ?? { total: 0, open: 0 }
|
||||
return (
|
||||
<Card
|
||||
key={p.key}
|
||||
p="$3.5"
|
||||
gap="$2"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
hoverStyle={{ borderColor: '$color8' }}
|
||||
cursor="pointer"
|
||||
onPress={() => onOpenTeam(p.key)}
|
||||
>
|
||||
<XStack items="center" gap="$2.5">
|
||||
<XStack items="center" justify="center" width={34} height={34} rounded="$3" bg="$color4">
|
||||
<Text className="hz-mono" fontSize="$3" fontWeight="800" color="$color12">
|
||||
{p.key.slice(0, 3)}
|
||||
</Text>
|
||||
</XStack>
|
||||
<YStack flex={1} minW={0}>
|
||||
<Text fontSize="$4" fontWeight="700" color="$color12" numberOfLines={1}>
|
||||
{p.name}
|
||||
</Text>
|
||||
<Text fontSize="$2" color="$color10" numberOfLines={1}>
|
||||
{p.description || `${p.key} · updated ${relTime(p.updatedAt)}`}
|
||||
</Text>
|
||||
</YStack>
|
||||
<YStack items="flex-end">
|
||||
<Text fontSize="$4" fontWeight="700" color="$color12">
|
||||
{c.open}
|
||||
</Text>
|
||||
<Text fontSize="$1" color="$color9">
|
||||
open · {c.total} total
|
||||
</Text>
|
||||
</YStack>
|
||||
</XStack>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</YStack>
|
||||
)}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Cycles (derived current cycle) ───────────────────────────────────────────
|
||||
|
||||
export function CyclesView({
|
||||
issues,
|
||||
projects,
|
||||
onOpen,
|
||||
}: {
|
||||
issues: Issue[]
|
||||
projects: Project[]
|
||||
onOpen: (i: Issue) => void
|
||||
}) {
|
||||
const cycle = useMemo(() => deriveCurrentCycle(issues), [issues])
|
||||
const groups = useMemo(() => groupIssues(cycle.active, 'status'), [cycle.active])
|
||||
return (
|
||||
<YStack gap="$4">
|
||||
<PageHeader
|
||||
title="Current cycle"
|
||||
subtitle="A rolling two-week iteration, derived from recently-updated work. Progress is the share already done."
|
||||
/>
|
||||
<Card p="$4" gap="$3" borderWidth={1} borderColor="$borderColor">
|
||||
<XStack items="center" gap="$3" flexWrap="wrap">
|
||||
<XStack items="center" gap="$2">
|
||||
<Target size={16} />
|
||||
<Text fontSize="$5" fontWeight="700" color="$color12">
|
||||
{cycle.done}/{cycle.total} done
|
||||
</Text>
|
||||
</XStack>
|
||||
<ProgressBar value={cycle.progress} width={220} />
|
||||
<Text fontSize="$2" color="$color10">
|
||||
{Math.round(cycle.progress * 100)}% · {cycle.active.length} active
|
||||
</Text>
|
||||
</XStack>
|
||||
</Card>
|
||||
{cycle.active.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Target}
|
||||
title="Nothing active this cycle"
|
||||
description="Move issues to Todo or In Progress to bring them into the current cycle."
|
||||
/>
|
||||
) : (
|
||||
<GroupedList
|
||||
groups={groups}
|
||||
projects={projects}
|
||||
keepEmpty={false}
|
||||
showTeam
|
||||
glyphFor={(g) => <StatusIcon status={g.key as never} size={14} />}
|
||||
onOpen={onOpen}
|
||||
/>
|
||||
)}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Roadmap (epics + children) ───────────────────────────────────────────────
|
||||
|
||||
export function RoadmapView({ issues, projects, onOpen }: { issues: Issue[]; projects: Project[]; onOpen: (i: Issue) => void }) {
|
||||
const rows = useMemo(() => deriveRoadmap(issues), [issues])
|
||||
return (
|
||||
<YStack gap="$4">
|
||||
<PageHeader
|
||||
title="Roadmap"
|
||||
subtitle="Epics and the issues that roll up to them (a child’s ExtRef anchors its epic)."
|
||||
/>
|
||||
{rows.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Layers}
|
||||
title="No epics yet"
|
||||
description="Create an issue with kind “epic”, then set a child issue’s ExtRef to the epic’s identifier to roll it up here."
|
||||
/>
|
||||
) : (
|
||||
<YStack gap="$2.5">
|
||||
{rows.map((r) => (
|
||||
<Card key={r.epic.id} p="$3.5" gap="$3" borderWidth={1} borderColor="$borderColor">
|
||||
<XStack
|
||||
items="center"
|
||||
gap="$2.5"
|
||||
cursor="pointer"
|
||||
onPress={() => onOpen(r.epic)}
|
||||
hoverStyle={{ opacity: 0.85 }}
|
||||
>
|
||||
<Layers size={16} />
|
||||
<YStack flex={1} minW={0}>
|
||||
<XStack items="center" gap="$2">
|
||||
<Identifier id={r.epic.identifier} />
|
||||
<Text fontSize="$4" fontWeight="700" color="$color12" numberOfLines={1}>
|
||||
{r.epic.title}
|
||||
</Text>
|
||||
</XStack>
|
||||
</YStack>
|
||||
<Text fontSize="$2" color="$color10">
|
||||
{r.done}/{r.total}
|
||||
</Text>
|
||||
<ProgressBar value={r.progress} width={140} />
|
||||
</XStack>
|
||||
{r.children.length > 0 ? (
|
||||
<YStack gap="$0.5" pl="$6">
|
||||
{r.children.slice(0, 6).map((c) => (
|
||||
<XStack
|
||||
key={c.id}
|
||||
items="center"
|
||||
gap="$2"
|
||||
py="$1"
|
||||
cursor="pointer"
|
||||
hoverStyle={{ opacity: 0.8 }}
|
||||
onPress={() => onOpen(c)}
|
||||
>
|
||||
<StatusIcon status={c.status} size={13} />
|
||||
<Identifier id={c.identifier} muted />
|
||||
<Text flex={1} fontSize="$2" color="$color11" numberOfLines={1}>
|
||||
{c.title}
|
||||
</Text>
|
||||
<Text fontSize="$1" color="$color9">
|
||||
{STATUS_LABEL[c.status]}
|
||||
</Text>
|
||||
</XStack>
|
||||
))}
|
||||
{r.children.length > 6 ? (
|
||||
<Text fontSize="$1" color="$color9" pl="$5">
|
||||
+{r.children.length - 6} more
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
) : null}
|
||||
</Card>
|
||||
))}
|
||||
</YStack>
|
||||
)}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
/** A back-to-teams header used by the single-team board. */
|
||||
export function TeamHeader({ project, onBack }: { project: Project; onBack: () => void }) {
|
||||
return (
|
||||
<PageHeader
|
||||
title={project.name}
|
||||
subtitle={`${project.key} · ${project.description || 'Team board — grouped by status. Press “c” to create an issue.'}`}
|
||||
actions={
|
||||
<Button size="$2" chromeless icon={<ArrowLeft size={15} />} onPress={onBack}>
|
||||
Teams
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* The telemetry wire — pins what `TelemetrySurface` (Analytics.tsx) actually
|
||||
* configures, so the ONE front door and the error plane cannot silently regress.
|
||||
*
|
||||
* This asserts the CLIENT half in a plain Node env (the suite convention: stub the
|
||||
* browser globals a test needs, mock `fetch`). The React mount is one line in
|
||||
* Analytics.tsx; what is worth pinning is that the client it builds emits a
|
||||
* pageview to `POST https://api.hanzo.ai/v1/event` as `product: 'console'`, carries
|
||||
* the console's IAM bearer, and resolves the hanzo-console Sentry DSN from the
|
||||
* product name alone — no `dsn` prop, no env var.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createTelemetry } from '@hanzogui/telemetry'
|
||||
|
||||
type FetchCall = { url: string; init: RequestInit }
|
||||
|
||||
let calls: FetchCall[]
|
||||
|
||||
beforeEach(() => {
|
||||
calls = []
|
||||
const store = new Map<string, string>()
|
||||
const listeners = () => {}
|
||||
const win = {
|
||||
location: { hostname: 'console.hanzo.ai', search: '', href: 'https://console.hanzo.ai/' },
|
||||
localStorage: {
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void store.set(k, v),
|
||||
removeItem: (k: string) => void store.delete(k),
|
||||
},
|
||||
addEventListener: listeners,
|
||||
removeEventListener: listeners,
|
||||
}
|
||||
vi.stubGlobal('window', win)
|
||||
vi.stubGlobal('localStorage', win.localStorage)
|
||||
vi.stubGlobal('document', { referrer: '', visibilityState: 'visible', addEventListener: listeners })
|
||||
// No DNT/GPC — the consent-refused path is the telemetry package's own test.
|
||||
vi.stubGlobal('navigator', { doNotTrack: undefined, userAgent: 'test' })
|
||||
vi.stubGlobal('fetch', (url: string, init: RequestInit) => {
|
||||
calls.push({ url: String(url), init })
|
||||
return Promise.resolve({ ok: true, status: 200, text: () => Promise.resolve('{}') })
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
/** Exactly how `TelemetrySurface` builds it: product + the IAM PKCE bearer. */
|
||||
const consoleTelemetry = (token: string | null = 'iam-access-token') =>
|
||||
createTelemetry({ product: 'console', getToken: () => token })
|
||||
|
||||
describe('console telemetry wire', () => {
|
||||
it('POSTs a pageview to the ONE front door as product=console', () => {
|
||||
const t = consoleTelemetry()
|
||||
expect(t.enabled).toBe(true)
|
||||
expect(t.product).toBe('console')
|
||||
|
||||
t.pageview('/')
|
||||
t.flush()
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0].url).toBe('https://api.hanzo.ai/v1/event')
|
||||
|
||||
const body = JSON.parse(String(calls[0].init.body))
|
||||
const pv = body.batch.find((e: { type: string }) => e.type === 'pageview')
|
||||
expect(pv).toBeTruthy()
|
||||
expect(pv.product).toBe('console')
|
||||
expect(pv.path).toBe('/')
|
||||
})
|
||||
|
||||
it('carries the IAM access token as a Bearer', () => {
|
||||
const t = consoleTelemetry('iam-access-token')
|
||||
t.pageview('/')
|
||||
t.flush()
|
||||
|
||||
const headers = calls[0].init.headers as Record<string, string>
|
||||
expect(headers.Authorization).toBe('Bearer iam-access-token')
|
||||
})
|
||||
|
||||
it('still emits when signed out (no token, no crash)', () => {
|
||||
const t = consoleTelemetry(null)
|
||||
t.pageview('/signin')
|
||||
t.flush()
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
const headers = calls[0].init.headers as Record<string, string>
|
||||
expect(headers.Authorization).toBeUndefined()
|
||||
})
|
||||
|
||||
it('resolves the hanzo-console Sentry DSN from `product` alone', () => {
|
||||
// The reason @hanzo/event was bumped to ^0.3.4: the DSN registry landed in
|
||||
// 0.3.x, so declaring the product is what lights up sentry.hanzo.ai. A regression
|
||||
// here means the console's errors silently stop reaching the dashboard.
|
||||
const t = consoleTelemetry()
|
||||
expect(t.client.errorPlaneEnabled).toBe(true)
|
||||
expect(t.client.errorIngestUrl).toContain('api.hanzo.ai/v1/sentry/')
|
||||
})
|
||||
|
||||
it('identify binds a person id on the one stream', () => {
|
||||
const t = consoleTelemetry()
|
||||
t.identify('hanzo/z')
|
||||
t.flush()
|
||||
|
||||
const body = JSON.parse(String(calls[0].init.body))
|
||||
const id = body.batch.find((e: { type: string }) => e.type === 'identify')
|
||||
expect(id).toBeTruthy()
|
||||
expect(id.personId).toBe('hanzo/z')
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user