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 |
+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/*.
|
||||
|
||||
@@ -326,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,
|
||||
@@ -3702,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>
|
||||
)
|
||||
|
||||
@@ -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,7 +10,7 @@
|
||||
* 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
|
||||
* 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
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
// Side-effect CSS imports (`import './globals.css'`, `import '@hanzogui/core/reset.css'`).
|
||||
// The bundler owns them; TypeScript only needs to know the specifier resolves.
|
||||
// TS7 (tsgo) errors on an unresolvable side-effect import (TS2882) where tsc stayed
|
||||
// silent, so the declaration lives here — one place, every stylesheet.
|
||||
declare module '*.css'
|
||||
+24
-8
@@ -27,12 +27,24 @@ export type SessionClaims = {
|
||||
email?: string
|
||||
displayName?: string
|
||||
isAdmin?: boolean
|
||||
/** The IAM user's property bag — where `hanzo.preferences` rides as a SNAPSHOT. */
|
||||
properties?: Record<string, string>
|
||||
/** When the token was minted (`iat`, seconds). Defaults to now; set it in the past
|
||||
* to reproduce the production case where the snapshot predates a later write. */
|
||||
issuedAt?: number
|
||||
}
|
||||
|
||||
const b64 = (o: object): string => Buffer.from(JSON.stringify(o)).toString('base64')
|
||||
/**
|
||||
* base64URL — what a JWT segment actually is. Plain base64 was close enough while the
|
||||
* payloads were tiny, but `+` and `/` appear as soon as one grows (a `properties` bag
|
||||
* is enough), and a strict decoder rejects the token outright: the SDK reports signed
|
||||
* out and the app sits on its loader forever.
|
||||
*/
|
||||
const b64 = (o: object): string =>
|
||||
Buffer.from(JSON.stringify(o)).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
|
||||
/** The default identity render specs run as — a hanzo-org admin. */
|
||||
export const DEFAULT_CLAIMS: Required<SessionClaims> = {
|
||||
export const DEFAULT_CLAIMS: Required<Omit<SessionClaims, 'properties' | 'issuedAt'>> = {
|
||||
owner: 'hanzo',
|
||||
name: 'z',
|
||||
email: 'z@hanzo.ai',
|
||||
@@ -40,20 +52,24 @@ export const DEFAULT_CLAIMS: Required<SessionClaims> = {
|
||||
isAdmin: true,
|
||||
}
|
||||
|
||||
/** An unsigned JWT whose payload carries the claims + a far-future `exp`. */
|
||||
export function forgeToken(claims: Required<SessionClaims>): string {
|
||||
const payload = { ...claims, sub: `${claims.owner}/${claims.name}`, exp: Math.floor(Date.now() / 1000) + 3600 }
|
||||
/** An unsigned JWT whose payload carries the claims, an `iat` and a far-future `exp`. */
|
||||
export function forgeToken(claims: SessionClaims): string {
|
||||
const iat = claims.issuedAt ?? Math.floor(Date.now() / 1000)
|
||||
const payload = { ...claims, sub: `${claims.owner}/${claims.name}`, iat, exp: iat + 86_400 }
|
||||
return `${b64({ alg: 'none' })}.${b64(payload)}.x`
|
||||
}
|
||||
|
||||
/** Seed tokens + gate keys and register the IAM endpoint mocks. */
|
||||
export async function primeSession(page: Page, overrides: Partial<SessionClaims> = {}): Promise<void> {
|
||||
const claims: Required<SessionClaims> = { ...DEFAULT_CLAIMS, ...overrides }
|
||||
const claims: SessionClaims = { ...DEFAULT_CLAIMS, ...overrides }
|
||||
await page.addInitScript(
|
||||
({ org, token }: { org: string; token: string }) => {
|
||||
try {
|
||||
sessionStorage.setItem('hanzo_iam_access_token', token)
|
||||
sessionStorage.setItem('hanzo_iam_expires_at', String(Date.now() + 3600_000))
|
||||
// localStorage, not sessionStorage: the `@hanzo/iam` token store is shared
|
||||
// across tabs (that IS the session), so seeding a per-tab area would leave
|
||||
// the SDK reading an empty store and every primed spec signed out.
|
||||
localStorage.setItem('hanzo_iam_access_token', token)
|
||||
localStorage.setItem('hanzo_iam_expires_at', String(Date.now() + 3600_000))
|
||||
localStorage.setItem('hanzo.console.org', org)
|
||||
localStorage.setItem('hanzo.console.org.selected', '1')
|
||||
localStorage.setItem(`hz_onboarding_done:${org}`, '1')
|
||||
|
||||
+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,7 +131,7 @@ 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/methods',
|
||||
'/v1/billing/alerts',
|
||||
'/v1/usage/summary',
|
||||
'/v1/get-cloud-usages',
|
||||
|
||||
@@ -40,7 +40,7 @@ const CANONICAL_IDS: string[] = JSON.parse(readFileSync(join(process.cwd(), 'e2e
|
||||
* are NOT registry ids — they must resolve via SLUG_ALIASES to a real module (never
|
||||
* a 404 blank). Auditing them here proves the alias map end-to-end against the real app.
|
||||
*/
|
||||
const ALIAS_SLUGS = ['traces', 'deploy', 'plans-pricing', 'wallets', 'model-catalog', 'fine-tuning', 'web-search', 'mlpipelines', 'kubeflow']
|
||||
const ALIAS_SLUGS = ['traces', 'deploy', 'plans-pricing', 'wallets', 'model-catalog', 'fine-tuning', 'web-search']
|
||||
const IDS: string[] = [...CANONICAL_IDS, ...ALIAS_SLUGS]
|
||||
|
||||
/** A global-admin (sees every surface) or a tenant customer (Dave/maxpower shape). */
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/**
|
||||
* e2e: brand-forward chrome + voice — mocked-network render proof.
|
||||
*
|
||||
* The Chrome wave: the big floating chat CIRCLE was removed; the assistant now opens
|
||||
* from the TOPBAR (a small brand-H "Chat with Hanzo" + a "Talk to Hanzo" mic), the
|
||||
* top-left SidebarBrand renders the org's own logo (white-label), and the Developers
|
||||
* dock is drag-resizable with a live "Create key". This spec proves all of it in a
|
||||
* browser.
|
||||
* The Chrome wave: the big floating chat CIRCLE was removed; the assistant opens from
|
||||
* ONE control — a floating bottom-right cluster (a brand-H "Ask Hanzo" + a "Talk to
|
||||
* Hanzo" mic, `AssistantFab`), NOT the topbar, which carries navigation and account
|
||||
* chrome only. The top-left SidebarBrand renders the org's own logo (white-label), and
|
||||
* the Developers dock is drag-resizable with a live "Create key". This spec proves all
|
||||
* of it in a browser.
|
||||
*
|
||||
* Same harness as workbench.spec (the closest sibling): a LOCAL server with the
|
||||
* network mocked. `primeSession` seeds the IAM-PKCE identity AND the first-run gates
|
||||
@@ -13,7 +14,7 @@
|
||||
* real-shaped ledger rows for the dock's Overview, `/v1/models` → a small catalog for
|
||||
* the assistant's model list; everything else → an empty-ok envelope.
|
||||
*
|
||||
* Voice gotcha: headless chromium ships NO webkitSpeechRecognition, so the topbar mic
|
||||
* Voice gotcha: headless chromium ships NO webkitSpeechRecognition, so the mic
|
||||
* (rendered only when `voiceSupported()`) would be absent for an environment reason,
|
||||
* not a code one. A tiny, inert Web Speech stub is injected BEFORE load
|
||||
* (`installVoiceStub`) so `voiceSupported()` is deterministically true and the mic
|
||||
@@ -107,20 +108,20 @@ function installVoiceStub(page: Page) {
|
||||
})
|
||||
}
|
||||
|
||||
/** Prime + navigate; the topbar brand-H is on EVERY viewport, so it is the mount signal. */
|
||||
/** Prime + navigate; the floating brand-H is on EVERY viewport, so it is the mount signal. */
|
||||
async function openHome(page: Page, waitForMount = true) {
|
||||
await installVoiceStub(page)
|
||||
await page.route('**/*', mock)
|
||||
await primeSession(page)
|
||||
await page.goto(`${BASE_URL}/`, { waitUntil: 'domcontentloaded' })
|
||||
if (waitForMount) {
|
||||
await expect(page.locator('[aria-label="Chat with Hanzo"]').first()).toBeVisible({ timeout: 20_000 })
|
||||
await expect(page.locator('[aria-label="Ask Hanzo"]').first()).toBeVisible({ timeout: 20_000 })
|
||||
}
|
||||
}
|
||||
|
||||
test.beforeAll(() => mkdirSync(SHOTS, { recursive: true }))
|
||||
|
||||
test('the floating circle is gone; the topbar carries chat + voice, the sidebar brand + docked assistant + Developers dock work', async ({ browser }) => {
|
||||
test('one floating control carries chat + voice; the topbar carries neither; sidebar brand + docked assistant + Developers dock work', async ({ browser }) => {
|
||||
// laptop (≥ lg 1024): the persistent sidebar, the Developers dock, and the docked
|
||||
// assistant column are all present (they are desktop-only concerns).
|
||||
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } })
|
||||
@@ -130,10 +131,18 @@ test('the floating circle is gone; the topbar carries chat + voice, the sidebar
|
||||
// 1. The OLD floating circle is GONE — the bubble that covered page content.
|
||||
await expect(page.locator('[aria-label="Open AI assistant"]')).toHaveCount(0)
|
||||
|
||||
// 2. Topbar: the small brand-H "Chat with Hanzo" AND the "Talk to Hanzo" mic, both
|
||||
// visible (the mic renders because the Web Speech stub makes voiceSupported() true).
|
||||
await expect(page.locator('[aria-label="Chat with Hanzo"]').first()).toBeVisible()
|
||||
await expect(page.locator('[aria-label="Talk to Hanzo"]').first()).toBeVisible()
|
||||
// 2. ONE floating control, bottom-right: the brand-H "Ask Hanzo" AND the "Talk to
|
||||
// Hanzo" mic (the mic renders because the Web Speech stub makes voiceSupported()
|
||||
// true) — and the topbar carries no assistant control at all. The two used to live
|
||||
// up there beside the search box, which put the assistant in a third place.
|
||||
// Scoped to the control itself: the assistant's own composer carries a mic with
|
||||
// the same label, mounted-but-hidden until the panel opens, so a bare
|
||||
// `[aria-label="Talk to Hanzo"]` matches that one first and reads "hidden".
|
||||
const fab = page.getByTestId('assistant-fab')
|
||||
await expect(fab.locator('[aria-label="Ask Hanzo"]')).toBeVisible()
|
||||
await expect(fab.locator('[aria-label="Talk to Hanzo"]')).toBeVisible()
|
||||
await expect(page.locator('.hz-topbar [aria-label="Ask Hanzo"]')).toHaveCount(0)
|
||||
await expect(page.locator('.hz-topbar [aria-label="Talk to Hanzo"]')).toHaveCount(0)
|
||||
|
||||
// 3. The top-left SidebarBrand renders the org logo / BrandMark (an <img> or <svg>).
|
||||
const brand = page.locator('[aria-label*="right-click for brand menu"]').first()
|
||||
@@ -147,15 +156,18 @@ test('the floating circle is gone; the topbar carries chat + voice, the sidebar
|
||||
await expect(page.locator('[title="Drag to resize"]').first()).toBeVisible()
|
||||
await expect(page.locator('text=Create key').first()).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
// 5. Clicking "Chat with Hanzo" opens the DOCKED assistant surface — the "Assistant"
|
||||
// header + its Undock control appear (uniquely the docked panel at lg+).
|
||||
await page.locator('[aria-label="Chat with Hanzo"]').first().click()
|
||||
// 5. Clicking "Ask Hanzo" opens the DOCKED assistant surface — the "Assistant"
|
||||
// header + its Undock control appear (uniquely the docked panel at lg+). The
|
||||
// floating control then steps aside: at lg+ the docked column IS the assistant,
|
||||
// so keeping a button to open it on top of itself would be a second way in.
|
||||
await fab.locator('[aria-label="Ask Hanzo"]').click()
|
||||
await expect(page.locator('[aria-label^="Undock"]').first()).toBeVisible({ timeout: 15_000 })
|
||||
await expect(page.getByText('Assistant', { exact: true }).filter({ visible: true }).first()).toBeVisible()
|
||||
await expect(page.locator('[aria-label="Ask Hanzo"]')).toHaveCount(0)
|
||||
|
||||
// 6. The mic is wired: "Talk to Hanzo" → startVoice → the conversation opens the
|
||||
// recognition (voiceSignal effect → voice.start() → the stub records the call).
|
||||
await page.locator('[aria-label="Talk to Hanzo"]').first().click()
|
||||
// 6. The mic is wired: "Talk to Hanzo" (now the open conversation's own) → the
|
||||
// recognition opens (voice.start() → the stub records the call).
|
||||
await page.locator('[aria-label="Talk to Hanzo"]').filter({ visible: true }).first().click()
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => (window as unknown as { __voiceStarted?: number }).__voiceStarted ?? 0), { timeout: 15_000 })
|
||||
.toBeGreaterThan(0)
|
||||
@@ -179,7 +191,7 @@ test('renders across breakpoints with no horizontal body scroll on a phone; scre
|
||||
// (real render, or an honest blank shell if the sandbox can't paint the SPA).
|
||||
await openHome(page, false)
|
||||
await page
|
||||
.locator('[aria-label="Chat with Hanzo"]')
|
||||
.locator('[aria-label="Ask Hanzo"]')
|
||||
.first()
|
||||
.waitFor({ state: 'visible', timeout: 20_000 })
|
||||
.catch(() => {})
|
||||
|
||||
+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
|
||||
|
||||
+48
-8
@@ -1,4 +1,4 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs'
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
@@ -7,12 +7,16 @@ import { resolveBuildId, readGitSha } from './src/config/build-id.mjs'
|
||||
/**
|
||||
* Hanzo Cloud Console — Next.js config.
|
||||
*
|
||||
* Hanzo GUI is consumed at runtime (no optimizing compiler): the published
|
||||
* `@hanzogui/next-plugin` has a broken npm dependency (`hanzogui-loader@7.3.0`
|
||||
* is unpublished; the available fork renames its exports), so we transpile the
|
||||
* Gui ESM packages with Next's built-in `transpilePackages` and let
|
||||
* `GuiProvider` inject CSS at runtime. Gui is designed to work this way — the
|
||||
* compiler is an optimization, not a requirement.
|
||||
* Hanzo GUI is consumed at runtime (no optimizing compiler): we transpile the Gui
|
||||
* ESM packages with Next's built-in `transpilePackages` and let `GuiProvider`
|
||||
* inject CSS at runtime. Gui is designed to work this way — the compiler is an
|
||||
* optimization, not a requirement.
|
||||
*
|
||||
* (The original reason to avoid `@hanzogui/next-plugin` no longer holds: the loader
|
||||
* it depends on was unpublished at 7.3.0, but 8.x renamed it to `@hanzogui/loader`
|
||||
* and both now ship. Adopting the compiler is therefore a live option — as an
|
||||
* optimization to measure, not a correctness fix, so it is deliberately not bundled
|
||||
* into the 8.x convergence.)
|
||||
*
|
||||
* `react-native` is aliased to `react-native-web` for the browser.
|
||||
*
|
||||
@@ -46,6 +50,9 @@ function guiPackages() {
|
||||
return ['@hanzo/gui', '@hanzo/iam-js-sdk', '@hanzo/dash', '@hanzo/data', '@hanzo/canvas', '@hanzo/finance-ui', '@hanzo/usage', '@hanzo/ui', 'react-native-web', ...scoped]
|
||||
}
|
||||
|
||||
/** A `@hanzogui/<pkg>/<subpath>/index.{js,cjs}` metro-compat shim (see `webpack()`). */
|
||||
const GUI_SUBPATH_SHIM = /\/@hanzogui\/([^/]+)\/([^/]+)\/index\.c?js$/
|
||||
|
||||
/**
|
||||
* Same-origin `/v1/*` — ZERO client-visible prefix (the CTO contract: "no prefix
|
||||
* before /v1/ in any API call"). The browser ALWAYS calls its OWN origin at a clean
|
||||
@@ -206,7 +213,40 @@ const nextConfig = {
|
||||
experimental: {
|
||||
esmExternals: true,
|
||||
},
|
||||
webpack(config) {
|
||||
webpack(config, { webpack }) {
|
||||
// `@hanzogui/*` 8.x ships legacy metro-compat subpath DIRECTORIES (`config/v5/`,
|
||||
// `themes/v5/`, `shorthands/v5/`, …) beside the `exports` map that already names
|
||||
// the real entry. Each holds a CommonJS `index.js` — inside a `"type": "module"`
|
||||
// package. Whatever resolves the directory therefore parses that file as ESM: the
|
||||
// `export *` chain goes opaque ("'defaultConfig' is not exported from
|
||||
// '@hanzogui/config/v5'") and its bare `require('../dist/cjs/v5.cjs')` survives
|
||||
// into the server chunk, where it MODULE_NOT_FOUNDs at prerender (the require is
|
||||
// relative to `.next/server/chunks/`, not to the package).
|
||||
//
|
||||
// So redirect any such shim to the ESM build sitting beside it. Pattern-based, on
|
||||
// the RESOLVED file, so it holds however the request got there — and costs nothing
|
||||
// the day the shims stop shipping.
|
||||
config.plugins.push(
|
||||
new webpack.NormalModuleReplacementPlugin(GUI_SUBPATH_SHIM, (data) => {
|
||||
const resource = data.createData?.resource
|
||||
if (!resource) return
|
||||
const shim = GUI_SUBPATH_SHIM.exec(resource)
|
||||
if (!shim) return
|
||||
const esm = `${resource.slice(0, shim.index)}/@hanzogui/${shim[1]}/dist/esm/${shim[2]}.mjs`
|
||||
if (!existsSync(esm)) return
|
||||
// The module's CONTEXT must move with it, or its own relative imports
|
||||
// (`./v5-base.mjs`) keep resolving against the shim directory.
|
||||
data.createData.resource = esm
|
||||
data.createData.userRequest = esm
|
||||
data.createData.context = dirname(esm)
|
||||
data.context = dirname(esm)
|
||||
}),
|
||||
)
|
||||
// @hanzo/ui is consumed from SOURCE via a workspace link. Keep the symlinked path
|
||||
// so its own imports (@hanzo/gui, @hanzogui/*) walk up into the CONSOLE's
|
||||
// node_modules — one Tamagui instance, as the tsconfig `paths` already pin for
|
||||
// types. Resolving the realpath would load a second copy and break theme context.
|
||||
config.resolve.symlinks = false
|
||||
config.resolve.alias = {
|
||||
...config.resolve.alias,
|
||||
'react-native$': 'react-native-web',
|
||||
|
||||
Generated
+12019
File diff suppressed because it is too large
Load Diff
+8
-7
@@ -3,9 +3,9 @@
|
||||
"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,18 +14,18 @@
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"e2e": "playwright test",
|
||||
"e2e:headed": "playwright test --headed",
|
||||
"postinstall": "patch-package"
|
||||
"e2e:headed": "playwright test --headed"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hanzo/brand": "^1.4.5",
|
||||
"@hanzo/canvas": "^0.2.1",
|
||||
"@hanzo/dash": "^0.3.0",
|
||||
"@hanzo/data": "^1.2.2",
|
||||
"@hanzo/design": "^0.4.6",
|
||||
"@hanzo/event": "^0.3.8",
|
||||
"@hanzo/finance-ui": "~0.1.1",
|
||||
"@hanzo/gui": "^8.0.0",
|
||||
"@hanzo/iam": "^0.21.2",
|
||||
"@hanzo/iam": "^0.21.6",
|
||||
"@hanzo/logo": "^1.0.14",
|
||||
"@hanzo/ui": "^8.0.38",
|
||||
"@hanzo/usage": "^0.1.6",
|
||||
@@ -33,7 +33,8 @@
|
||||
"@hanzogui/core": "^8.0.0",
|
||||
"@hanzogui/lucide-icons-2": "^8.0.0",
|
||||
"@hanzogui/next-theme": "^8.0.0",
|
||||
"@hanzogui/shell": "^8.0.1",
|
||||
"@hanzogui/telemetry": "^8.0.0",
|
||||
"@hanzogui/shell": "^8.1.1",
|
||||
"@lexical/html": "0.46.0",
|
||||
"@lexical/link": "0.46.0",
|
||||
"@lexical/list": "0.46.0",
|
||||
@@ -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
+113
-246
@@ -20,6 +20,9 @@ importers:
|
||||
'@hanzo/data':
|
||||
specifier: ^1.2.2
|
||||
version: 1.2.2(@hanzo/gui@8.0.0(react-dom@19.2.7(react@19.2.7))(react-native@0.83.9(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.7)(supports-color@8.1.1))(react@19.2.7))(react@19.2.7)
|
||||
'@hanzo/design':
|
||||
specifier: ^0.4.6
|
||||
version: 0.4.6
|
||||
'@hanzo/event':
|
||||
specifier: ^0.3.8
|
||||
version: 0.3.8(react@19.2.7)
|
||||
@@ -30,8 +33,8 @@ importers:
|
||||
specifier: ^8.0.0
|
||||
version: 8.0.0(react-dom@19.2.7(react@19.2.7))(react-native@0.83.9(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.7)(supports-color@8.1.1))(react@19.2.7)
|
||||
'@hanzo/iam':
|
||||
specifier: ^0.21.2
|
||||
version: 0.21.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
specifier: ^0.21.6
|
||||
version: 0.21.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@hanzo/logo':
|
||||
specifier: ^1.0.14
|
||||
version: 1.0.14(react@19.2.7)
|
||||
@@ -54,8 +57,11 @@ importers:
|
||||
specifier: ^8.0.0
|
||||
version: 8.0.0(next@15.5.19(@babel/core@7.29.7(supports-color@8.1.1))(@playwright/test@1.62.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)
|
||||
'@hanzogui/shell':
|
||||
specifier: ^8.0.1
|
||||
version: 8.0.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
specifier: ^8.1.1
|
||||
version: 8.1.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@hanzogui/telemetry':
|
||||
specifier: ^8.0.0
|
||||
version: 8.0.0(react@19.2.7)
|
||||
'@lexical/html':
|
||||
specifier: 0.46.0
|
||||
version: 0.46.0(typescript@5.9.3)
|
||||
@@ -113,6 +119,9 @@ importers:
|
||||
react-dom:
|
||||
specifier: 19.2.7
|
||||
version: 19.2.7(react@19.2.7)
|
||||
react-native-svg:
|
||||
specifier: 15.15.5
|
||||
version: 15.15.5(react-native@0.83.9(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.7)(supports-color@8.1.1))(react@19.2.7)
|
||||
react-native-web:
|
||||
specifier: 0.21.2
|
||||
version: 0.21.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
@@ -132,9 +141,6 @@ importers:
|
||||
'@types/react-dom':
|
||||
specifier: 19.2.3
|
||||
version: 19.2.3(@types/react@19.2.17)
|
||||
patch-package:
|
||||
specifier: ^8.0.0
|
||||
version: 8.0.1
|
||||
react-native:
|
||||
specifier: 0.83.9
|
||||
version: 0.83.9(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.7)(supports-color@8.1.1)
|
||||
@@ -770,6 +776,10 @@ packages:
|
||||
'@hanzo/gui': '>=7.2.2'
|
||||
react: '>=19'
|
||||
|
||||
'@hanzo/design@0.4.6':
|
||||
resolution: {integrity: sha512-kRjnGKsGqMfGTg2kMUv+pPRDryarYk85rMOoblIqshFIZJp48pCKYcdv1DmfxbNyKm+7CqvqD1NjY3HhNPUQ/A==}
|
||||
hasBin: true
|
||||
|
||||
'@hanzo/event@0.3.8':
|
||||
resolution: {integrity: sha512-Mh7+CV2t23x/dircN1Y9Ulm7HE6QWMf9E0/oBrlsILZCS7oP4GY5sGFlO36ZSiUsnbEVJIT21/PZILLPi4JAhQ==}
|
||||
peerDependencies:
|
||||
@@ -789,8 +799,8 @@ packages:
|
||||
peerDependencies:
|
||||
react: '>=19'
|
||||
|
||||
'@hanzo/iam@0.21.2':
|
||||
resolution: {integrity: sha512-yuk+di2VB02FQVHH2ggqOrJtEXiDLnKNOwkC6XMr+WAI8yzC0u/KqGhxn162jJRtYihvq9ykA428UfLgR2giHg==}
|
||||
'@hanzo/iam@0.21.6':
|
||||
resolution: {integrity: sha512-vPYSu47BCdIvKMmBIkkd0LBeh9qfc6noS39rdLO8BtMXj8Q4zt1YuK0Vy8dAGueMmbrct/53OA0zuU2pmYVNMA==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
react: '>=17'
|
||||
@@ -1246,8 +1256,8 @@ packages:
|
||||
peerDependencies:
|
||||
react: '>=19'
|
||||
|
||||
'@hanzogui/shell@8.0.1':
|
||||
resolution: {integrity: sha512-Ez8LeDn9DO2GT15n+nLICtwE6R4GBXb1JrujewVflLmEeVLVV9lATAJZ5+XVjFKBFnO/ZrLO+uIfjOwt1TnU0A==}
|
||||
'@hanzogui/shell@8.1.1':
|
||||
resolution: {integrity: sha512-jDkGsHUsP+3+ukraBKQ1hX8EhWTl0/M9jYOBqE3K4HJa/ml5XaYwqvA7dt1AK8WRZntsVJDf+mVXvt12C5vsag==}
|
||||
peerDependencies:
|
||||
react: '*'
|
||||
react-dom: '*'
|
||||
@@ -2276,9 +2286,6 @@ packages:
|
||||
'@xyflow/system@0.0.78':
|
||||
resolution: {integrity: sha512-lY0z2qP33fUhTva9Vaxrk0lqZta2pkbxB1trHAx1omnJqRtPvDlAQYV2r5fhS6AdpkulYmbNW0svy+A4/t4B/g==}
|
||||
|
||||
'@yarnpkg/lockfile@1.1.0':
|
||||
resolution: {integrity: sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==}
|
||||
|
||||
'@zap-proto/web@1.0.0':
|
||||
resolution: {integrity: sha512-+16cawz65kF5TpI3lyTRs1OP/9HmdX40e1zkRieKledYeWIqfdoOMh8agg8/kvr0bdJk3U4FwX9Ws+P2QBJ2pg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -2420,6 +2427,9 @@ packages:
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
boolbase@1.0.0:
|
||||
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
|
||||
|
||||
brace-expansion@1.1.16:
|
||||
resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==}
|
||||
|
||||
@@ -2442,18 +2452,6 @@ packages:
|
||||
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
call-bind-apply-helpers@1.0.2:
|
||||
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
call-bind@1.0.9:
|
||||
resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
call-bound@1.0.4:
|
||||
resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
camelcase@5.3.1:
|
||||
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -2550,6 +2548,17 @@ packages:
|
||||
css-in-js-utils@3.1.0:
|
||||
resolution: {integrity: sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==}
|
||||
|
||||
css-select@5.2.2:
|
||||
resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==}
|
||||
|
||||
css-tree@1.1.3:
|
||||
resolution: {integrity: sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
css-what@6.2.2:
|
||||
resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
csstype@3.2.3:
|
||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||
|
||||
@@ -2612,10 +2621,6 @@ packages:
|
||||
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
define-data-property@1.1.4:
|
||||
resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
depd@2.0.0:
|
||||
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -2628,9 +2633,18 @@ packages:
|
||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
dom-serializer@2.0.0:
|
||||
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
|
||||
|
||||
domelementtype@2.3.0:
|
||||
resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
|
||||
|
||||
domhandler@5.0.3:
|
||||
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
domutils@3.2.2:
|
||||
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
|
||||
|
||||
ee-first@1.1.1:
|
||||
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
||||
@@ -2649,13 +2663,13 @@ packages:
|
||||
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
entities@4.5.0:
|
||||
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
error-stack-parser@2.1.4:
|
||||
resolution: {integrity: sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==}
|
||||
|
||||
es-define-property@1.0.1:
|
||||
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
es-errors@1.3.0:
|
||||
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -2663,10 +2677,6 @@ packages:
|
||||
es-module-lexer@1.7.0:
|
||||
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
|
||||
|
||||
es-object-atoms@1.1.2:
|
||||
resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
esbuild@0.28.1:
|
||||
resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -2752,9 +2762,6 @@ packages:
|
||||
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
find-yarn-workspace-root@2.0.0:
|
||||
resolution: {integrity: sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==}
|
||||
|
||||
flow-enums-runtime@0.0.6:
|
||||
resolution: {integrity: sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==}
|
||||
|
||||
@@ -2776,10 +2783,6 @@ packages:
|
||||
resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
fs-extra@10.1.0:
|
||||
resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
fs.realpath@1.0.0:
|
||||
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
|
||||
|
||||
@@ -2804,26 +2807,14 @@ packages:
|
||||
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
|
||||
engines: {node: 6.* || 8.* || >= 10.*}
|
||||
|
||||
get-intrinsic@1.3.0:
|
||||
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
get-package-type@0.1.0:
|
||||
resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
get-proto@1.0.1:
|
||||
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
glob@7.2.3:
|
||||
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
|
||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
|
||||
gopd@1.2.0:
|
||||
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
graceful-fs@4.2.11:
|
||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||
|
||||
@@ -2831,13 +2822,6 @@ packages:
|
||||
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
has-property-descriptors@1.0.2:
|
||||
resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==}
|
||||
|
||||
has-symbols@1.1.0:
|
||||
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
hasown@2.0.4:
|
||||
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -2921,9 +2905,6 @@ packages:
|
||||
resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
isarray@2.0.5:
|
||||
resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
|
||||
|
||||
isexe@2.0.0:
|
||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||
|
||||
@@ -2995,24 +2976,11 @@ packages:
|
||||
engines: {node: '>=6'}
|
||||
hasBin: true
|
||||
|
||||
json-stable-stringify@1.3.0:
|
||||
resolution: {integrity: sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
json5@2.2.3:
|
||||
resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
|
||||
engines: {node: '>=6'}
|
||||
hasBin: true
|
||||
|
||||
jsonfile@6.2.1:
|
||||
resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==}
|
||||
|
||||
jsonify@0.0.1:
|
||||
resolution: {integrity: sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==}
|
||||
|
||||
klaw-sync@6.0.0:
|
||||
resolution: {integrity: sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==}
|
||||
|
||||
leven@3.1.0:
|
||||
resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -3139,9 +3107,8 @@ packages:
|
||||
marky@1.3.0:
|
||||
resolution: {integrity: sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==}
|
||||
|
||||
math-intrinsics@1.1.0:
|
||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
mdn-data@2.0.14:
|
||||
resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==}
|
||||
|
||||
memoize-one@5.2.1:
|
||||
resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==}
|
||||
@@ -3288,9 +3255,6 @@ packages:
|
||||
minimatch@3.1.5:
|
||||
resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
|
||||
|
||||
minimist@1.2.8:
|
||||
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
|
||||
|
||||
mkdirp@1.0.4:
|
||||
resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -3372,6 +3336,9 @@ packages:
|
||||
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
nth-check@2.1.1:
|
||||
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
|
||||
|
||||
nullthrows@1.1.1:
|
||||
resolution: {integrity: sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==}
|
||||
|
||||
@@ -3390,10 +3357,6 @@ packages:
|
||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
object-keys@1.1.1:
|
||||
resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
on-finished@2.3.0:
|
||||
resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -3433,11 +3396,6 @@ packages:
|
||||
resolution: {integrity: sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==}
|
||||
engines: {node: '>= 0.4.0'}
|
||||
|
||||
patch-package@8.0.1:
|
||||
resolution: {integrity: sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==}
|
||||
engines: {node: '>=14', npm: '>5'}
|
||||
hasBin: true
|
||||
|
||||
path-exists@4.0.0:
|
||||
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -3548,6 +3506,12 @@ packages:
|
||||
react-native: 0.83 - 0.86
|
||||
react-native-worklets: 0.10.x - 0.11.x
|
||||
|
||||
react-native-svg@15.15.5:
|
||||
resolution: {integrity: sha512-L4go5jA+GWutdJ/JucuN20cjAbMg1HmMtAP+wZ+3JLCf6Jd0bhXQHxciRP/AQm/FlrIEZwkMcHNZP+FXAiic0w==}
|
||||
peerDependencies:
|
||||
react: '*'
|
||||
react-native: '*'
|
||||
|
||||
react-native-web@0.21.2:
|
||||
resolution: {integrity: sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==}
|
||||
peerDependencies:
|
||||
@@ -3649,10 +3613,6 @@ packages:
|
||||
resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
set-function-length@1.2.2:
|
||||
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
setimmediate@1.0.5:
|
||||
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
|
||||
|
||||
@@ -3681,10 +3641,6 @@ packages:
|
||||
signal-exit@3.0.7:
|
||||
resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
|
||||
|
||||
slash@2.0.0:
|
||||
resolution: {integrity: sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
slash@3.0.0:
|
||||
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -3812,10 +3768,6 @@ packages:
|
||||
resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
tmp@0.2.7:
|
||||
resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==}
|
||||
engines: {node: '>=14.14'}
|
||||
|
||||
tmpl@1.0.5:
|
||||
resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==}
|
||||
|
||||
@@ -3878,10 +3830,6 @@ packages:
|
||||
resolution: {integrity: sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
universalify@2.0.1:
|
||||
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
|
||||
unpipe@1.0.0:
|
||||
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -4716,6 +4664,8 @@ snapshots:
|
||||
'@hanzo/gui': 8.0.0(react-dom@19.2.7(react@19.2.7))(react-native@0.83.9(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.7)(supports-color@8.1.1))(react@19.2.7)
|
||||
react: 19.2.7
|
||||
|
||||
'@hanzo/design@0.4.6': {}
|
||||
|
||||
'@hanzo/event@0.3.8(react@19.2.7)':
|
||||
optionalDependencies:
|
||||
react: 19.2.7
|
||||
@@ -4795,7 +4745,7 @@ snapshots:
|
||||
- react-dom
|
||||
- react-native
|
||||
|
||||
'@hanzo/iam@0.21.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
'@hanzo/iam@0.21.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
dependencies:
|
||||
jose: 6.2.4
|
||||
libphonenumber-js: 1.13.9
|
||||
@@ -5656,7 +5606,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- react-dom
|
||||
|
||||
'@hanzogui/shell@8.0.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
'@hanzogui/shell@8.1.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
dependencies:
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
@@ -6828,8 +6778,6 @@ snapshots:
|
||||
d3-selection: 3.0.0
|
||||
d3-zoom: 3.0.0
|
||||
|
||||
'@yarnpkg/lockfile@1.1.0': {}
|
||||
|
||||
'@zap-proto/web@1.0.0(ws@8.21.1)':
|
||||
dependencies:
|
||||
'@zap-proto/zap': 1.6.0
|
||||
@@ -6990,6 +6938,8 @@ snapshots:
|
||||
|
||||
baseline-browser-mapping@2.11.3: {}
|
||||
|
||||
boolbase@1.0.0: {}
|
||||
|
||||
brace-expansion@1.1.16:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
@@ -7015,23 +6965,6 @@ snapshots:
|
||||
|
||||
cac@6.7.14: {}
|
||||
|
||||
call-bind-apply-helpers@1.0.2:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
function-bind: 1.1.2
|
||||
|
||||
call-bind@1.0.9:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
es-define-property: 1.0.1
|
||||
get-intrinsic: 1.3.0
|
||||
set-function-length: 1.2.2
|
||||
|
||||
call-bound@1.0.4:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
get-intrinsic: 1.3.0
|
||||
|
||||
camelcase@5.3.1: {}
|
||||
|
||||
camelcase@6.3.0: {}
|
||||
@@ -7138,6 +7071,21 @@ snapshots:
|
||||
dependencies:
|
||||
hyphenate-style-name: 1.1.0
|
||||
|
||||
css-select@5.2.2:
|
||||
dependencies:
|
||||
boolbase: 1.0.0
|
||||
css-what: 6.2.2
|
||||
domhandler: 5.0.3
|
||||
domutils: 3.2.2
|
||||
nth-check: 2.1.1
|
||||
|
||||
css-tree@1.1.3:
|
||||
dependencies:
|
||||
mdn-data: 2.0.14
|
||||
source-map: 0.6.1
|
||||
|
||||
css-what@6.2.2: {}
|
||||
|
||||
csstype@3.2.3: {}
|
||||
|
||||
d3-color@3.1.0: {}
|
||||
@@ -7190,23 +7138,29 @@ snapshots:
|
||||
|
||||
deep-eql@5.0.2: {}
|
||||
|
||||
define-data-property@1.1.4:
|
||||
dependencies:
|
||||
es-define-property: 1.0.1
|
||||
es-errors: 1.3.0
|
||||
gopd: 1.2.0
|
||||
|
||||
depd@2.0.0: {}
|
||||
|
||||
destroy@1.2.0: {}
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
dom-serializer@2.0.0:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
es-errors: 1.3.0
|
||||
gopd: 1.2.0
|
||||
domelementtype: 2.3.0
|
||||
domhandler: 5.0.3
|
||||
entities: 4.5.0
|
||||
|
||||
domelementtype@2.3.0: {}
|
||||
|
||||
domhandler@5.0.3:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
|
||||
domutils@3.2.2:
|
||||
dependencies:
|
||||
dom-serializer: 2.0.0
|
||||
domelementtype: 2.3.0
|
||||
domhandler: 5.0.3
|
||||
|
||||
ee-first@1.1.1: {}
|
||||
|
||||
@@ -7218,20 +7172,16 @@ snapshots:
|
||||
|
||||
encodeurl@2.0.0: {}
|
||||
|
||||
entities@4.5.0: {}
|
||||
|
||||
error-stack-parser@2.1.4:
|
||||
dependencies:
|
||||
stackframe: 1.3.4
|
||||
|
||||
es-define-property@1.0.1: {}
|
||||
|
||||
es-errors@1.3.0: {}
|
||||
|
||||
es-module-lexer@1.7.0: {}
|
||||
|
||||
es-object-atoms@1.1.2:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
|
||||
esbuild@0.28.1:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.28.1
|
||||
@@ -7343,10 +7293,6 @@ snapshots:
|
||||
locate-path: 5.0.0
|
||||
path-exists: 4.0.0
|
||||
|
||||
find-yarn-workspace-root@2.0.0:
|
||||
dependencies:
|
||||
micromatch: 4.0.8
|
||||
|
||||
flow-enums-runtime@0.0.6: {}
|
||||
|
||||
framer-motion@12.42.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
|
||||
@@ -7360,12 +7306,6 @@ snapshots:
|
||||
|
||||
fresh@0.5.2: {}
|
||||
|
||||
fs-extra@10.1.0:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
jsonfile: 6.2.1
|
||||
universalify: 2.0.1
|
||||
|
||||
fs.realpath@1.0.0: {}
|
||||
|
||||
fsevents@2.3.2:
|
||||
@@ -7380,26 +7320,8 @@ snapshots:
|
||||
|
||||
get-caller-file@2.0.5: {}
|
||||
|
||||
get-intrinsic@1.3.0:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
es-define-property: 1.0.1
|
||||
es-errors: 1.3.0
|
||||
es-object-atoms: 1.1.2
|
||||
function-bind: 1.1.2
|
||||
get-proto: 1.0.1
|
||||
gopd: 1.2.0
|
||||
has-symbols: 1.1.0
|
||||
hasown: 2.0.4
|
||||
math-intrinsics: 1.1.0
|
||||
|
||||
get-package-type@0.1.0: {}
|
||||
|
||||
get-proto@1.0.1:
|
||||
dependencies:
|
||||
dunder-proto: 1.0.1
|
||||
es-object-atoms: 1.1.2
|
||||
|
||||
glob@7.2.3:
|
||||
dependencies:
|
||||
fs.realpath: 1.0.0
|
||||
@@ -7409,18 +7331,10 @@ snapshots:
|
||||
once: 1.4.0
|
||||
path-is-absolute: 1.0.1
|
||||
|
||||
gopd@1.2.0: {}
|
||||
|
||||
graceful-fs@4.2.11: {}
|
||||
|
||||
has-flag@4.0.0: {}
|
||||
|
||||
has-property-descriptors@1.0.2:
|
||||
dependencies:
|
||||
es-define-property: 1.0.1
|
||||
|
||||
has-symbols@1.1.0: {}
|
||||
|
||||
hasown@2.0.4:
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
@@ -7499,8 +7413,6 @@ snapshots:
|
||||
dependencies:
|
||||
is-docker: 2.2.1
|
||||
|
||||
isarray@2.0.5: {}
|
||||
|
||||
isexe@2.0.0: {}
|
||||
|
||||
isomorphic.js@0.2.5: {}
|
||||
@@ -7604,28 +7516,8 @@ snapshots:
|
||||
|
||||
jsesc@3.1.0: {}
|
||||
|
||||
json-stable-stringify@1.3.0:
|
||||
dependencies:
|
||||
call-bind: 1.0.9
|
||||
call-bound: 1.0.4
|
||||
isarray: 2.0.5
|
||||
jsonify: 0.0.1
|
||||
object-keys: 1.1.1
|
||||
|
||||
json5@2.2.3: {}
|
||||
|
||||
jsonfile@6.2.1:
|
||||
dependencies:
|
||||
universalify: 2.0.1
|
||||
optionalDependencies:
|
||||
graceful-fs: 4.2.11
|
||||
|
||||
jsonify@0.0.1: {}
|
||||
|
||||
klaw-sync@6.0.0:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
|
||||
leven@3.1.0: {}
|
||||
|
||||
lexical@0.46.0(typescript@5.9.3):
|
||||
@@ -7725,7 +7617,7 @@ snapshots:
|
||||
|
||||
marky@1.3.0: {}
|
||||
|
||||
math-intrinsics@1.1.0: {}
|
||||
mdn-data@2.0.14: {}
|
||||
|
||||
memoize-one@5.2.1: {}
|
||||
|
||||
@@ -8098,8 +7990,6 @@ snapshots:
|
||||
dependencies:
|
||||
brace-expansion: 1.1.16
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
mkdirp@1.0.4: {}
|
||||
|
||||
motion-dom@12.42.2:
|
||||
@@ -8159,6 +8049,10 @@ snapshots:
|
||||
|
||||
normalize-path@3.0.0: {}
|
||||
|
||||
nth-check@2.1.1:
|
||||
dependencies:
|
||||
boolbase: 1.0.0
|
||||
|
||||
nullthrows@1.1.1: {}
|
||||
|
||||
oauth@0.10.2: {}
|
||||
@@ -8173,8 +8067,6 @@ snapshots:
|
||||
|
||||
object-assign@4.1.1: {}
|
||||
|
||||
object-keys@1.1.1: {}
|
||||
|
||||
on-finished@2.3.0:
|
||||
dependencies:
|
||||
ee-first: 1.1.1
|
||||
@@ -8214,23 +8106,6 @@ snapshots:
|
||||
|
||||
passport-strategy@1.0.0: {}
|
||||
|
||||
patch-package@8.0.1:
|
||||
dependencies:
|
||||
'@yarnpkg/lockfile': 1.1.0
|
||||
chalk: 4.1.2
|
||||
ci-info: 3.9.0
|
||||
cross-spawn: 7.0.6
|
||||
find-yarn-workspace-root: 2.0.0
|
||||
fs-extra: 10.1.0
|
||||
json-stable-stringify: 1.3.0
|
||||
klaw-sync: 6.0.0
|
||||
minimist: 1.2.8
|
||||
open: 7.4.2
|
||||
semver: 7.8.5
|
||||
slash: 2.0.0
|
||||
tmp: 0.2.7
|
||||
yaml: 2.9.0
|
||||
|
||||
path-exists@4.0.0: {}
|
||||
|
||||
path-is-absolute@1.0.1: {}
|
||||
@@ -8329,6 +8204,13 @@ snapshots:
|
||||
react-native-worklets: 0.10.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.83.9(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.7)(supports-color@8.1.1))(react@19.2.7)(supports-color@8.1.1)
|
||||
semver: 7.8.5
|
||||
|
||||
react-native-svg@15.15.5(react-native@0.83.9(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.7)(supports-color@8.1.1))(react@19.2.7):
|
||||
dependencies:
|
||||
css-select: 5.2.2
|
||||
css-tree: 1.1.3
|
||||
react: 19.2.7
|
||||
react-native: 0.83.9(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.7)(supports-color@8.1.1)
|
||||
|
||||
react-native-web@0.21.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.7
|
||||
@@ -8521,15 +8403,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
set-function-length@1.2.2:
|
||||
dependencies:
|
||||
define-data-property: 1.1.4
|
||||
es-errors: 1.3.0
|
||||
function-bind: 1.1.2
|
||||
get-intrinsic: 1.3.0
|
||||
gopd: 1.2.0
|
||||
has-property-descriptors: 1.0.2
|
||||
|
||||
setimmediate@1.0.5: {}
|
||||
|
||||
setprototypeof@1.2.0: {}
|
||||
@@ -8577,8 +8450,6 @@ snapshots:
|
||||
|
||||
signal-exit@3.0.7: {}
|
||||
|
||||
slash@2.0.0: {}
|
||||
|
||||
slash@3.0.0: {}
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
@@ -8681,8 +8552,6 @@ snapshots:
|
||||
|
||||
tinyspy@4.0.4: {}
|
||||
|
||||
tmp@0.2.7: {}
|
||||
|
||||
tmpl@1.0.5: {}
|
||||
|
||||
to-regex-range@5.0.1:
|
||||
@@ -8722,8 +8591,6 @@ snapshots:
|
||||
|
||||
unicode-property-aliases-ecmascript@2.2.0: {}
|
||||
|
||||
universalify@2.0.1: {}
|
||||
|
||||
unpipe@1.0.0: {}
|
||||
|
||||
update-browserslist-db@1.2.3(browserslist@4.28.7):
|
||||
|
||||
@@ -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,40 +1,38 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Bridges the console session + App-Router navigation into the shared analytics
|
||||
* client (`@hanzo/event`). Rendered once, inside both `SessionProvider` and
|
||||
* `AnalyticsProvider` (see `Provider.tsx`), it renders nothing.
|
||||
* The console's telemetry surface — the ONE Hanzo telemetry provider, plus the one
|
||||
* thing it deliberately leaves to the app.
|
||||
*
|
||||
* - `usePageview` emits a pageview on every path change (the provider fires the
|
||||
* FIRST pageview itself, so this only covers subsequent client navigations).
|
||||
* - `identify` binds the user to their Hanzo IAM user id (`account.userId`, the
|
||||
* OIDC `sub`) once the session resolves, AND carries the attributes that make
|
||||
* that id legible. Anonymous placeholder sessions are skipped.
|
||||
* `TelemetrySurface` mounts `@hanzogui/telemetry`, which owns the whole plane for
|
||||
* this subtree: pageviews (including SPA route changes), `window.onerror` +
|
||||
* `unhandledrejection`, React render errors (its internal boundary REPORTS and
|
||||
* re-throws, so the app's own error UI still decides what the user sees), lazily
|
||||
* imported interaction capture, and DNT/GPC consent. It replaces the hand-rolled
|
||||
* `<AnalyticsProvider>` + bridge + boundary combo; it mounts @hanzo/event's
|
||||
* `AnalyticsProvider` internally with its own client, so every existing
|
||||
* `useAnalytics()` call site keeps working against that ONE client and one stream.
|
||||
* `product="console"` is all the configuration there is — @hanzo/event's DSN
|
||||
* registry resolves the hanzo-console Sentry project from it, so the error plane
|
||||
* needs no `dsn` prop and no env var.
|
||||
*
|
||||
* WHY NOT `owner/name`. This used to identify by the `${owner}/${name}` actor ref.
|
||||
* That is an org-relative REFERENCE, not a user id: it is a different id space
|
||||
* from the one hanzo.ai and hanzo.chat identify by (the IAM `sub`), so the same
|
||||
* user counted twice the moment they used two Hanzo surfaces — and every
|
||||
* cross-property funnel, retention curve and path silently measured nothing. It
|
||||
* also moves when an org or a login handle is renamed, which rewrites history.
|
||||
* It reads `usePathname()` ITSELF rather than taking a `path` prop from `Provider`:
|
||||
* `Provider` memoizes its tree on `children`, so a path read up there would be
|
||||
* baked into the cached element and go stale on the first client navigation.
|
||||
*
|
||||
* WHY TRAITS. A bare `identify(id)` writes a user id and nothing else, so the
|
||||
* warehouse held a population of opaque subjects: every funnel could count users
|
||||
* but no one could say WHICH user, and answering "who hit this error" meant a
|
||||
* manual IAM lookup per row. Email and name are FIRST-PARTY facts about our own
|
||||
* users — they arrive in the same IAM claims this file already decodes to get the
|
||||
* id, and were simply dropped on the floor. Sent as traits they are exactly as
|
||||
* sensitive as they were in the token, and the id stays the join key.
|
||||
*
|
||||
* WHAT IS STILL NOT SENT: the org. The tenant is stamped SERVER-SIDE from the
|
||||
* validated bearer, so org-level cohorts are already queryable — and a tenant the
|
||||
* client can name is a tenant the client can get wrong. Traits describe the user,
|
||||
* never the scope they are trusted with.
|
||||
* `AnalyticsBridge` is the one thing TelemetryProvider does NOT do — `identify`.
|
||||
* It binds the person to the STABLE `owner/name` actor id (the same id the API
|
||||
* client uses via `setCurrentActor`), never the email, once the session resolves.
|
||||
* The org tenant is stamped server-side from the session, so we send the user id
|
||||
* only, and anonymous placeholder sessions are skipped. It renders nothing and
|
||||
* emits NO pageview — the provider owns those, and a second emitter would
|
||||
* double-count every route.
|
||||
*/
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useEffect, useRef, type ReactNode } from 'react'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useAnalytics, usePageview } from '@hanzo/event/react'
|
||||
import { TelemetryProvider, useTelemetry } from '@hanzogui/telemetry'
|
||||
|
||||
import { iamAccessToken } from '~/lib/auth/iam'
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
import { type Account } from '~/lib/api/types'
|
||||
|
||||
@@ -49,21 +47,30 @@ export function identityTraits(account: Account): Record<string, unknown> {
|
||||
return traits
|
||||
}
|
||||
|
||||
export function TelemetrySurface({ children }: { children: ReactNode }) {
|
||||
const path = usePathname()
|
||||
// @hanzo/iam (PKCE) is the console's ONE credential and it is a BEARER — the same
|
||||
// token `lib/api/client.ts` puts on every call — so telemetry authenticates the
|
||||
// same way rather than relying on a cookie the ingest host would never receive.
|
||||
return (
|
||||
<TelemetryProvider product="console" path={path} getToken={iamAccessToken}>
|
||||
{children}
|
||||
</TelemetryProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export function AnalyticsBridge() {
|
||||
const analytics = useAnalytics()
|
||||
const telemetry = useTelemetry()
|
||||
const { account } = useSession()
|
||||
usePageview(usePathname())
|
||||
|
||||
const identified = useRef('')
|
||||
useEffect(() => {
|
||||
if (!account || account.type === 'anonymous-user') return
|
||||
// No IAM subject means no IAM user — leave the visitor anonymous rather than
|
||||
// inventing an id for them. Identity flows from the token's own claims.
|
||||
const userId = account.userId
|
||||
if (!userId || identified.current === userId) return
|
||||
identified.current = userId
|
||||
analytics.identify(userId, identityTraits(account))
|
||||
}, [account, analytics])
|
||||
if (!account?.owner || !account?.name || account.type === 'anonymous-user') return
|
||||
const personId = `${account.owner}/${account.name}`
|
||||
if (identified.current === personId) return
|
||||
identified.current = personId
|
||||
telemetry.identify(personId)
|
||||
}, [account, telemetry])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -22,12 +22,13 @@ import { useIam } from '@hanzo/iam/react'
|
||||
import { Button, Text, YStack } from '@hanzo/gui'
|
||||
|
||||
import { Loader } from '~/components/ui/Loader'
|
||||
import { takeReturnTo } from '~/lib/auth/iam'
|
||||
import { takeReturnTo, startReauth } from '~/lib/auth/iam'
|
||||
import { classifyCallback, type CallbackVerdict } from '~/lib/auth/callback-error'
|
||||
|
||||
export function AuthCallback() {
|
||||
const router = useRouter()
|
||||
const { handleCallback } = useIam()
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [verdict, setVerdict] = useState<CallbackVerdict | null>(null)
|
||||
// The OAuth `code` is SINGLE-USE and the SDK removes the PKCE verifier BEFORE the
|
||||
// token fetch, so the exchange must fire EXACTLY ONCE. Without this guard, React
|
||||
// StrictMode's double-invoke (or any `handleCallback` identity change re-running the
|
||||
@@ -47,20 +48,37 @@ export function AuthCallback() {
|
||||
window.location.assign(takeReturnTo())
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setError('Sign-in failed.')
|
||||
if (cancelled) return
|
||||
// The SDK is the AUTHORITY on whether a callback is a sign-in — it validates
|
||||
// `state` before honouring an error branch, so an attacker-supplied
|
||||
// /callback?error=… cannot mint a session. It reports every refusal the same
|
||||
// way, though, so this screen used to say "Sign-in failed." to someone who had
|
||||
// merely cancelled a consent screen. Read the code for WORDING only; the
|
||||
// decision was already made above.
|
||||
setVerdict(
|
||||
classifyCallback(typeof window === 'undefined' ? '' : window.location.search) ?? {
|
||||
kind: 'failed',
|
||||
message: 'Sign-in failed.',
|
||||
},
|
||||
)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [handleCallback])
|
||||
|
||||
if (error) {
|
||||
if (verdict) {
|
||||
// A refusal is not always a fault. Only a genuine failure is worded as one, and
|
||||
// the two benign outcomes lead with the action that actually resolves them.
|
||||
const retry = verdict.kind === 'failed' ? 'Back to sign in' : 'Sign in'
|
||||
return (
|
||||
<YStack flex={1} minH="100vh" items="center" justify="center" gap="$3">
|
||||
<Text color="$color12" fontWeight="600">
|
||||
{error}
|
||||
{verdict.message}
|
||||
</Text>
|
||||
<Button onPress={() => router.replace('/signin')}>Back to sign in</Button>
|
||||
<Button onPress={() => (verdict.kind === 'failed' ? router.replace('/signin') : startReauth())}>
|
||||
{retry}
|
||||
</Button>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -119,7 +119,7 @@ function Tooltip({ x, height, label, value }: { x: number; height: number; label
|
||||
borderRadius: 8,
|
||||
padding: '6px 9px',
|
||||
fontSize: 11,
|
||||
lineHeight: 1.35,
|
||||
lineHeight: '1.35',
|
||||
color: RAMP[0],
|
||||
whiteSpace: 'nowrap',
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.4)',
|
||||
|
||||
@@ -43,12 +43,15 @@ export function FieldText({
|
||||
disabled,
|
||||
secure,
|
||||
placeholder,
|
||||
ariaLabel,
|
||||
}: {
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
disabled?: boolean
|
||||
secure?: boolean
|
||||
placeholder?: string
|
||||
/** Accessible name when the field stands alone, with no visible <Field> label. */
|
||||
ariaLabel?: string
|
||||
}) {
|
||||
return (
|
||||
<Input
|
||||
@@ -57,6 +60,7 @@ export function FieldText({
|
||||
disabled={disabled}
|
||||
secureTextEntry={secure}
|
||||
placeholder={placeholder}
|
||||
aria-label={ariaLabel}
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* MenuRow — ONE selectable row for the console's popover menus.
|
||||
*
|
||||
* The context switcher (org + project) and the network switcher both list
|
||||
* "pick one of these" rows with the same anatomy: an optional leading dot or
|
||||
* icon, a label, an optional sub-label, and a check on the active one. That row
|
||||
* was written twice; this is it once, so the two switchers cannot drift apart
|
||||
* in padding, hover or type.
|
||||
*
|
||||
* `DotColor` is the tier hue used by the network switcher — monochrome by
|
||||
* default, and only the genuine states carry a hue (mainnet live green, testnet
|
||||
* caution amber). It lives here because the row is what paints it.
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import { Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Check } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
export type DotColor = '$green10' | '$yellow10' | '$color10' | '$color9' | '$color8'
|
||||
|
||||
export function MenuRow({
|
||||
label,
|
||||
sub,
|
||||
dot,
|
||||
icon,
|
||||
active,
|
||||
onPress,
|
||||
}: {
|
||||
label: string
|
||||
sub?: string
|
||||
dot?: DotColor
|
||||
icon?: ReactNode
|
||||
/**
|
||||
* Whether this row is the chosen one. Its PRESENCE also decides the row's
|
||||
* semantics: a row that can be current is one of a set exactly one of which
|
||||
* holds (`radio`, inside the caller's `radiogroup`), while a row without the
|
||||
* notion is an action (`menuitem`). So "Acme Industrial" is a radio and "New
|
||||
* project" is not, without either call site having to say so twice.
|
||||
*
|
||||
* `radio` rather than the more obvious `option` because @hanzo/gui's `role`
|
||||
* union is React Native's accessibility-role set: it admits `option` but NOT
|
||||
* `listbox`, so an `option` here could never be given the parent ARIA
|
||||
* requires. `radiogroup`/`radio` is the single-select pair gui does carry
|
||||
* whole, and it says the same thing.
|
||||
*/
|
||||
active?: boolean
|
||||
onPress: () => void
|
||||
}) {
|
||||
const selectable = active !== undefined
|
||||
return (
|
||||
<XStack
|
||||
onPress={onPress}
|
||||
role={selectable ? 'radio' : 'menuitem'}
|
||||
aria-checked={selectable ? !!active : undefined}
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
* toast.success('Created my-db')
|
||||
* toast.error(err instanceof ApiError ? err.message : 'Failed to create')
|
||||
*/
|
||||
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { CircleCheck, CircleX, Info, X } from '@hanzogui/lucide-icons-2'
|
||||
@@ -144,13 +144,23 @@ export function Toast({ children }: { children: ReactNode }) {
|
||||
return () => map.forEach((t) => clearTimeout(t))
|
||||
}, [])
|
||||
|
||||
const api: ToastApi = {
|
||||
toast,
|
||||
success: (title, description) => toast({ title, description, kind: 'success' }),
|
||||
error: (title, description) => toast({ title, description, kind: 'error' }),
|
||||
info: (title, description) => toast({ title, description, kind: 'info' }),
|
||||
dismiss,
|
||||
}
|
||||
// The context value is what every consumer's effect dependency list compares. Built
|
||||
// fresh here it is a NEW identity on every render of this provider — and this
|
||||
// provider re-renders each time a toast is added. So an effect that both depends on
|
||||
// the toast api and raises a toast re-triggers itself, forever: raising one toast
|
||||
// renders the provider, which hands the effect a new api, which raises another. The
|
||||
// callbacks below are already stable, so memoising the object is what makes
|
||||
// "depend on the toast api" safe.
|
||||
const api: ToastApi = useMemo(
|
||||
() => ({
|
||||
toast,
|
||||
success: (title, description) => toast({ title, description, kind: 'success' }),
|
||||
error: (title, description) => toast({ title, description, kind: 'error' }),
|
||||
info: (title, description) => toast({ title, description, kind: 'info' }),
|
||||
dismiss,
|
||||
}),
|
||||
[toast, dismiss],
|
||||
)
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={api}>
|
||||
|
||||
@@ -51,8 +51,8 @@ describe('inspectorRoute', () => {
|
||||
expect(inspectorRoute('trace_z')).toEqual({ path: 'o11y/traces/trace_z', kind: 'trace', label: 'Trace' })
|
||||
})
|
||||
|
||||
it('maps an hk-/sk-/pk- key to the account key status (never a secret path)', () => {
|
||||
for (const k of ['hk-abc', 'sk-live-xyz', 'pk-123']) {
|
||||
it('maps an sk-/pk- key to the account key status (never a secret path)', () => {
|
||||
for (const k of ['sk-live-xyz', 'pk-123']) {
|
||||
expect(inspectorRoute(k)).toEqual({ path: 'iam/keys', kind: 'key', label: 'API key' })
|
||||
}
|
||||
})
|
||||
@@ -67,6 +67,8 @@ describe('inspectorRoute', () => {
|
||||
expect(inspectorRoute('https://evil.com/x')).toHaveProperty('error')
|
||||
expect(inspectorRoute('agents/../admin')).toHaveProperty('error')
|
||||
expect(inspectorRoute('justaword')).toHaveProperty('error')
|
||||
// `sk-` and `pk-` are the only key shapes — any other prefix is unrecognized.
|
||||
expect(inspectorRoute('xk-abc')).toHaveProperty('error')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -53,9 +53,9 @@ export function renderOutput(value: unknown, maxChars = 20000): string {
|
||||
/** A resolved inspect target (a same-origin `/v1` GET) or an honest parse error. */
|
||||
export type InspectTarget = { path: string; kind: string; label: string } | { error: string }
|
||||
|
||||
/** The `hk-`/`sk-`/`pk-` credential prefixes — there is no GET-by-value, so they
|
||||
* resolve to the account's key STATUS (never the secret). */
|
||||
const KEY_PREFIXES = ['hk-', 'sk-', 'pk-']
|
||||
/** The `sk-` (secret) and `pk-` (publishable) credential prefixes — there is no
|
||||
* GET-by-value, so they resolve to the account's key STATUS (never the secret). */
|
||||
const KEY_PREFIXES = ['sk-', 'pk-']
|
||||
|
||||
/**
|
||||
* id-prefix → the `/v1` resource that owns it. The prefixes mirror the object ids
|
||||
@@ -75,7 +75,7 @@ const ID_ROUTES: { prefixes: string[]; kind: string; label: string; path: (id: s
|
||||
|
||||
/**
|
||||
* Route an object id (or a bare `resource/name` path) to the same-origin `/v1` GET
|
||||
* that reads it. Recognizes the platform's id prefixes, the `hk-/sk-/pk-` key
|
||||
* that reads it. Recognizes the platform's id prefixes, the `sk-/pk-` key
|
||||
* prefixes (→ key status, never the secret), and a raw `resource/name` fallback so
|
||||
* any object addressable by path (`agents/my-agent`, `models/zen5`) is inspectable.
|
||||
* A URL or an unrecognized bare token is refused with an honest message — the
|
||||
@@ -96,7 +96,7 @@ export function inspectorRoute(input: string): InspectTarget {
|
||||
return { path, kind: 'resource', label: 'Resource' }
|
||||
}
|
||||
return {
|
||||
error: 'Unrecognized id. Try agent_… , fn_… , flow_… , run_… , prompt_… , an hk-/sk-/pk- key, or a resource path like agents/<name>.',
|
||||
error: 'Unrecognized id. Try agent_… , fn_… , flow_… , run_… , prompt_… , an sk-/pk- key, or a resource path like agents/<name>.',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ export function inspectorRoute(input: string): InspectTarget {
|
||||
/** Normalize a workbench path (`/v1/x`, `v1/x`, `x`) to the bare resource `x`. */
|
||||
const bareResource = (path: string): string => path.replace(/^\/+/, '').replace(/^v1\//, '')
|
||||
|
||||
/** The `curl` form of a `/v1` GET — the real request, keyed by the account's `hk-` key. */
|
||||
/** The `curl` form of a `/v1` GET — the real request, keyed by the account's `sk-` key. */
|
||||
export function curlFor(path: string, origin = 'https://api.hanzo.ai'): string {
|
||||
return `curl ${origin}/v1/${bareResource(path)} \\\n -H "Authorization: Bearer $HANZO_API_KEY"`
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ export function OverviewTab({ records, onGo }: { records: UsageRecord[]; onGo: (
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Mint (or rotate) the account's real hk- Cloud API key. The secret is returned
|
||||
// Mint (or rotate) the account's real sk- Cloud API key. The secret is returned
|
||||
// ONCE by create() and shown once here (copy affordance); status() then reflects
|
||||
// the new key's public prefix. Honest error on failure — never a fake key.
|
||||
const createKey = async () => {
|
||||
@@ -178,7 +178,7 @@ export function OverviewTab({ records, onGo }: { records: UsageRecord[]; onGo: (
|
||||
<MetricCard icon={<Activity size={15} />} label="Spend" value={usd(totals.cents)} caption="Charged, last 24h" />
|
||||
</XStack>
|
||||
|
||||
{/* API keys — the account's real hk- credential. Create/rotate the key INLINE
|
||||
{/* API keys — the account's real sk- credential. Create/rotate the key INLINE
|
||||
(the secret shows ONCE, copy it), or Manage in the full API Keys product. */}
|
||||
<Card p="$3" gap="$2" borderWidth={1} borderColor="$borderColor" bg="$color2">
|
||||
<XStack items="center" gap="$2" flexWrap="wrap">
|
||||
@@ -208,8 +208,8 @@ export function OverviewTab({ records, onGo }: { records: UsageRecord[]; onGo: (
|
||||
{keyLoading
|
||||
? 'Loading…'
|
||||
: key?.hasKey
|
||||
? `Cloud API key active · ${key.keyPrefix ? `${key.keyPrefix}…` : 'hk-…'}`
|
||||
: 'No Cloud API key yet — create one to call the gateway as Bearer hk-…'}
|
||||
? `Cloud API key active · ${key.keyPrefix ? `${key.keyPrefix}…` : 'sk-…'}`
|
||||
: 'No Cloud API key yet — create one to call the gateway as Bearer sk-…'}
|
||||
</Text>
|
||||
{newKey ? (
|
||||
<YStack gap="$1" p="$2" rounded="$3" borderWidth={1} borderColor="$color7" bg="$color1">
|
||||
@@ -245,7 +245,7 @@ export function OverviewTab({ records, onGo }: { records: UsageRecord[]; onGo: (
|
||||
</Text>
|
||||
) : null}
|
||||
<Text fontSize="$1" color="$color9">
|
||||
hk- (cloud key) · sk- (provider key) · pk- (publishable) — presented as Authorization: Bearer to api.hanzo.ai.
|
||||
sk- (secret) — presented as Authorization: Bearer to api.hanzo.ai. pk- (publishable) is safe in client code and never authenticates.
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -344,7 +344,7 @@ describe('cloudAudience — the cloud API resource for the forwarded bearer', ()
|
||||
* FIVE faces (billing/marketing/ads/social/sentry) resolve at the config level and stay
|
||||
* orthogonal to the brand (a face host keeps its own brand). Called with explicit hosts.
|
||||
*/
|
||||
describe('config: unified product shell (five faces)', () => {
|
||||
describe('config: unified product shell (product faces)', () => {
|
||||
const FACE_HOST: Record<Exclude<ShellId, 'console'>, string> = {
|
||||
billing: 'billing.hanzo.ai',
|
||||
marketing: 'marketing.hanzo.ai',
|
||||
@@ -353,6 +353,7 @@ describe('config: unified product shell (five faces)', () => {
|
||||
sentry: 'sentry.hanzo.ai',
|
||||
dns: 'dns.hanzo.ai',
|
||||
platform: 'platform.hanzo.ai',
|
||||
tracker: 'tracker.hanzo.ai',
|
||||
}
|
||||
|
||||
it('resolveConfig(host).shell is the host face; a full-console host is "console"', () => {
|
||||
|
||||
+18
-3
@@ -41,7 +41,7 @@ export type BrandId = 'hanzo' | 'lux' | 'zoo' | 'pars' | '7stars' | 'yotoda'
|
||||
* identity (which IAM you log into, the wordmark) is resolved separately by
|
||||
* `brandFromHost`, so a face NEVER crosses brands (sentry.lux.cloud is the lux brand).
|
||||
*/
|
||||
export type ShellId = 'console' | 'billing' | 'marketing' | 'ads' | 'social' | 'sentry' | 'dns' | 'platform'
|
||||
export type ShellId = 'console' | 'billing' | 'marketing' | 'ads' | 'social' | 'sentry' | 'dns' | 'platform' | 'tracker'
|
||||
|
||||
export type ConsoleConfig = {
|
||||
/** Resolved brand id (from hostname). */
|
||||
@@ -405,18 +405,32 @@ export function isPlatformHost(host?: string | null): boolean {
|
||||
return normHost(host).startsWith('platform.')
|
||||
}
|
||||
|
||||
/**
|
||||
* True on the dedicated Tracker host (tracker.<brand>, e.g. tracker.hanzo.ai) — the
|
||||
* SAME console image wearing the standalone Linear-grade issue-tracker face (the
|
||||
* TrackerModule: unified issues board across every team + every mirrored GitHub repo,
|
||||
* teams, cycles, roadmap, agent-actionable work). Strict `tracker.` prefix — no false
|
||||
* positives. tracker.hanzo.ai boots straight into the tracker with the catalog chrome
|
||||
* stripped, while console.hanzo.ai keeps Tracker as one product among many — one shared
|
||||
* surface (cloud `/v1/tracker`), two entry points.
|
||||
*/
|
||||
export function isTrackerHost(host?: string | null): boolean {
|
||||
return normHost(host).startsWith('tracker.')
|
||||
}
|
||||
|
||||
/**
|
||||
* The product shell a host wears, resolved at runtime — the ONE resolver for EVERY
|
||||
* console FACE. `NEXT_PUBLIC_PRODUCT_SHELL` overrides for dev/preview (any host → a
|
||||
* chosen face); otherwise each dedicated host (or its legacy `NEXT_PUBLIC_*_ONLY=1`
|
||||
* env) selects its face — billing / marketing / ads / social / sentry / dns / platform —
|
||||
* env) selects its face — billing / marketing / ads / social / sentry / dns / platform /
|
||||
* tracker —
|
||||
* and everything else is the full `console`. Brand is resolved separately
|
||||
* (`brandFromHost`), so the shell is orthogonal — a face NEVER crosses a brand
|
||||
* (sentry.lux.cloud is the lux brand).
|
||||
*/
|
||||
export function shellFromHost(host?: string | null): ShellId {
|
||||
const env = process.env.NEXT_PUBLIC_PRODUCT_SHELL
|
||||
if (env === 'billing' || env === 'marketing' || env === 'ads' || env === 'social' || env === 'sentry' || env === 'dns' || env === 'platform' || env === 'console') return env
|
||||
if (env === 'billing' || env === 'marketing' || env === 'ads' || env === 'social' || env === 'sentry' || env === 'dns' || env === 'platform' || env === 'tracker' || env === 'console') return env
|
||||
if (isBillingOnly(host)) return 'billing'
|
||||
if (isMarketing(host)) return 'marketing'
|
||||
if (isAds(host)) return 'ads'
|
||||
@@ -424,6 +438,7 @@ export function shellFromHost(host?: string | null): ShellId {
|
||||
if (isSentryHost(host)) return 'sentry'
|
||||
if (isDnsHost(host)) return 'dns'
|
||||
if (isPlatformHost(host)) return 'platform'
|
||||
if (isTrackerHost(host)) return 'tracker'
|
||||
return 'console'
|
||||
}
|
||||
|
||||
|
||||
+27
-15
@@ -7,41 +7,53 @@
|
||||
* any guard could bounce the still-unauthenticated visitor and discard the `?code`.
|
||||
* • `signin` → <SignIn/> — the ONE sign-in experience (a signed-in visitor here is
|
||||
* bounced to `/` by SignIn itself).
|
||||
* • `landing` → <PublicLanding/> — the public marketing page for an anon visitor at `/`
|
||||
* on a consumer host.
|
||||
* • `guarded` → a neutral loader while it redirects an anon visitor to `/signin`.
|
||||
* • `guarded` → a neutral loader while the IAM hop STARTS. An anonymous visitor to the
|
||||
* console has exactly one thing to do, so do it: `/signin`'s entire content is one
|
||||
* button that starts this same hop, and routing through it spent a page load asking
|
||||
* "did you mean it?" — the second such question already, since the visitor clicked
|
||||
* "Sign in" on cloud.hanzo.ai to get here. That double-ask IS the reported bug.
|
||||
*
|
||||
* WHY THIS IS SAFE FOR SIGN-OUT, the one hazard here: an explicit sign-out lands on
|
||||
* `/signin` (session.tsx), which is surface `signin` and keeps its button. IAM may still
|
||||
* hold its own session, so auto-authorizing THERE would sign the user straight back in and
|
||||
* make signing out impossible. `/signin` is therefore the one surface that never
|
||||
* auto-starts — it is also where a callback failure lands, so a broken hop cannot loop.
|
||||
*
|
||||
* SPA-FALLBACK CAVEAT: the deploy serves the `/` route's shell for every path, so a direct
|
||||
* load of `/signin` or `/auth/callback` mounts THIS tree, not that path's own route file —
|
||||
* hence each experience is rendered inline here rather than by a navigation.
|
||||
*
|
||||
* This view only ever renders for the `signin` stage (see resolve): an authenticated
|
||||
* landing/guarded visitor advances past it, so there is no "render the app" branch here.
|
||||
* guarded visitor advances past it, so there is no "render the app" branch here.
|
||||
*/
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { Loader } from '~/components/ui/Loader'
|
||||
import { SignIn } from '~/components/SignIn'
|
||||
import { AuthCallback } from '~/components/AuthCallback'
|
||||
import { PublicLanding } from '~/components/PublicLanding'
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
import { startReauth } from '~/lib/auth/iam'
|
||||
import type { Surface } from './resolve'
|
||||
|
||||
export function Auth({ surface }: { surface: Surface }) {
|
||||
const { account, loading } = useSession()
|
||||
const router = useRouter()
|
||||
// The hop is a full-page navigation, but React may re-run the effect before the browser
|
||||
// commits it; starting a second authorize would overwrite the stored PKCE verifier that
|
||||
// the first one's `code` will be exchanged against. Once per mount.
|
||||
const started = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
// A definitively-unauthenticated visitor on a guarded path → normalize the URL to
|
||||
// /signin (never while loading — the session may still resolve to an account; and
|
||||
// never from /signin itself, where a same-URL replace is a no-op).
|
||||
if (surface === 'guarded' && !loading && !account) router.replace('/signin')
|
||||
}, [surface, loading, account, router])
|
||||
// A definitively-unauthenticated visitor on a guarded path → start the IAM hop (never
|
||||
// while loading: the session may still resolve to an account). `startReauth` is the
|
||||
// existing composition — stash where they were, THEN authorize — so a deep link like
|
||||
// /models returns to /models instead of dumping them on the home.
|
||||
if (surface !== 'guarded' || loading || account || started.current) return
|
||||
started.current = true
|
||||
startReauth()
|
||||
}, [surface, loading, account])
|
||||
|
||||
if (surface === 'callback') return <AuthCallback />
|
||||
if (surface === 'signin') return <SignIn />
|
||||
if (surface === 'landing' && !loading && !account) return <PublicLanding />
|
||||
// Pre-mount, loading, or the brief redirecting/advancing window.
|
||||
// Pre-mount, loading, or the brief window while the IAM hop navigates away.
|
||||
return <Loader />
|
||||
}
|
||||
|
||||
+40
-70
@@ -25,8 +25,9 @@
|
||||
* push the content) — the classic rail + flyout. On phones the sidebar is a LEFT
|
||||
* drawer (hamburger) instead; the collapse/rail is a desktop concern.
|
||||
*
|
||||
* The assistant docks: a floating bubble by default, or a PERMANENT right column
|
||||
* when docked (`useFloatingChat().docked`), which this shell reserves at `lg+`.
|
||||
* The assistant is opened from its OWN floating control bottom-right (`FloatingChat`),
|
||||
* never from this topbar; all this shell owns about it is `docked`, which reserves the
|
||||
* PERMANENT right column at `lg+`.
|
||||
*
|
||||
* Every product icon carries a tasteful per-product COLOR, recolorable/pinnable from
|
||||
* the customize pane — all persisted per-user via the account-backed preferences.
|
||||
@@ -59,7 +60,6 @@ import {
|
||||
LayoutGrid,
|
||||
Lock,
|
||||
Menu,
|
||||
Mic,
|
||||
PanelLeft,
|
||||
Plus,
|
||||
Repeat,
|
||||
@@ -100,16 +100,16 @@ import { useDetailPane } from '~/components/DetailPane'
|
||||
import { ProductCustomize, ManagePins } from '~/components/SidebarCustomize'
|
||||
import { SlideOver } from '~/components/ui/SlideOver'
|
||||
import { ProductIcon } from '~/components/ui/ProductIcon'
|
||||
import { ThemeToggle } from '~/components/ui/ThemeToggle'
|
||||
import { SystemStatusBadge } from '~/components/ui/SystemStatusBadge'
|
||||
import { Breadcrumbs } from '~/components/ui/Breadcrumbs'
|
||||
import { SidebarBrand } from '~/components/SidebarBrand'
|
||||
import { AccountMenu } from '~/components/AccountMenu'
|
||||
import { BrandMark } from '~/components/ui/BrandLogo'
|
||||
import { shellFor, isProductShell } from '~/lib/products/shell'
|
||||
import { HanzoAppLauncher } from '@hanzogui/shell'
|
||||
import { ScopeSwitcher } from '~/components/ScopeSwitcher'
|
||||
import { ContextSwitcher } from '~/components/ContextSwitcher'
|
||||
import { useFloatingChat, DockedChatPanel } from '~/components/FloatingChat'
|
||||
import { voiceSupported } from '~/lib/voice'
|
||||
import { WorkbenchDock } from '~/components/workbench/Workbench'
|
||||
import { Z } from '~/lib/z'
|
||||
|
||||
@@ -704,6 +704,12 @@ function SidebarNav({
|
||||
<nav role="navigation" aria-label="Products" style={{ display: 'contents' }}>
|
||||
<SidebarBrand collapsed={false} onNavigate={onNavigate} />
|
||||
|
||||
{/* WHERE you are — organization and project in ONE control, directly under
|
||||
the tenant's own mark. The account at the foot answers WHO you are; the
|
||||
network chip in the top-right is a global MODE. Three questions, three
|
||||
controls, each in one place. */}
|
||||
<ContextSwitcher />
|
||||
|
||||
{/* Product filter — narrows the whole list; a match from any category jumps
|
||||
straight there. Typing hides the section chrome so the list stays scannable. */}
|
||||
<XStack
|
||||
@@ -873,17 +879,8 @@ function NavDrawer({ open, onOpenChange }: { open: boolean; onOpenChange: (o: bo
|
||||
</Text>
|
||||
<Command size={13} opacity={0.5} />
|
||||
</XStack>
|
||||
<Button
|
||||
size="$3"
|
||||
icon={<LayoutGrid size={18} />}
|
||||
onPress={() => {
|
||||
onOpenChange(false)
|
||||
palette.open()
|
||||
}}
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
aria-label="Apps"
|
||||
/>
|
||||
{/* No second "Apps" trigger here either — the search row above opens the
|
||||
very same palette, so one row is the whole affordance. */}
|
||||
{/* Explicit close — right-aligned INSIDE the drawer header, always reachable
|
||||
on a 390px phone (backdrop/Escape still close too). */}
|
||||
<Button
|
||||
@@ -940,12 +937,10 @@ export function Dashboard({ children }: { children: ReactNode }) {
|
||||
// drawer, dock), never a route change.
|
||||
const router = useRouter()
|
||||
const { get, set } = usePreferences()
|
||||
const palette = useCommandPalette()
|
||||
// The assistant: `docked` reserves the right column; `openChat` (the small brand-H)
|
||||
// opens it as the right sidebar; `startVoice` (the mic) opens it AND starts listening.
|
||||
const { docked, openChat, startVoice } = useFloatingChat()
|
||||
// Show the mic only where the browser can actually listen (no dead control).
|
||||
const [voiceOk] = useState(() => voiceSupported())
|
||||
// The assistant: `docked` is the only thing the SHELL owns about it — it reserves
|
||||
// the right column at lg+. Opening it belongs to the assistant's own floating
|
||||
// control (`AssistantFab`), not to this topbar.
|
||||
const { docked } = useFloatingChat()
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
// Collapsed-rail hover flyout (desktop only): the full sidebar overlays the content
|
||||
@@ -1054,65 +1049,40 @@ export function Dashboard({ children }: { children: ReactNode }) {
|
||||
aria-label="Open navigation"
|
||||
/>
|
||||
|
||||
{/* The search box IS the app switcher: it calls the same
|
||||
`useCommandPalette().open` the old "Apps" button called, and says so
|
||||
("Search or jump to… ⌘K"). Two triggers for one palette, side by
|
||||
side, read as two different destinations — so there is now one. */}
|
||||
<CommandSearchBox />
|
||||
|
||||
{/* Apps — opens the command palette; icon-only below lg, labeled at lg+. */}
|
||||
<Button
|
||||
size="$3"
|
||||
icon={<LayoutGrid size={18} />}
|
||||
onPress={palette.open}
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
aria-label="Apps"
|
||||
>
|
||||
<Text display="none" $lg={{ display: 'flex' }}>
|
||||
Apps
|
||||
</Text>
|
||||
</Button>
|
||||
|
||||
{/* Talk to Hanzo — a SMALL brand-H opens the assistant in the right sidebar
|
||||
(desktop) / sheet (phones); the mic starts a voice session. AI help is one
|
||||
small press away, while the user's OWN brand still leads the chrome
|
||||
(top-left). Shown on every viewport. Replaces the old floating circle. */}
|
||||
<Button
|
||||
size="$3"
|
||||
chromeless
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
icon={<BrandMark size={16} />}
|
||||
onPress={openChat}
|
||||
aria-label="Chat with Hanzo"
|
||||
/>
|
||||
{voiceOk ? (
|
||||
<Button
|
||||
size="$3"
|
||||
chromeless
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
icon={<Mic size={17} />}
|
||||
onPress={startVoice}
|
||||
aria-label="Talk to Hanzo"
|
||||
/>
|
||||
) : null}
|
||||
{/* The assistant is NOT here. Chat and voice live together in the one
|
||||
floating control bottom-right (`AssistantFab` in `FloatingChat`), where
|
||||
the assistant itself appears — so the topbar carries navigation and
|
||||
account chrome only, and the user's OWN brand leads it (top-left). */}
|
||||
|
||||
{/* Spacer — pushes the right-side controls to the edge at lg+. Below lg the
|
||||
search box fills the row (two flex:1 siblings would halve it). */}
|
||||
<XStack display="none" $lg={{ display: 'flex' }} flex={1} />
|
||||
|
||||
{/* Full topbar controls — shown only at lg+. */}
|
||||
{/* Full topbar controls — shown only at lg+. The bar carries navigation
|
||||
only: status, docs, and the cross-app launcher. Theme lives in the
|
||||
account menu, alerts at /alerts, and the network picker in the
|
||||
account drawer — production is the default environment, so the
|
||||
environment is chrome you open, not chrome you wear. */}
|
||||
<XStack display="none" $lg={{ display: 'flex' }} items="center" gap="$2">
|
||||
<SystemStatusBadge />
|
||||
<ThemeToggle />
|
||||
<Button size="$2" chromeless icon={<CircleHelp size={16} />} onPress={openDocs} aria-label="Documentation" />
|
||||
<Button size="$2" chromeless icon={<Bell size={16} />} onPress={() => push('/alerts')} aria-label="Notifications" />
|
||||
<ScopeSwitcher />
|
||||
{/* The cross-app launcher, from the shared shell — the same one chat
|
||||
and every other Hanzo surface carries. ⌘K stays with the command
|
||||
palette; the launcher must not claim it too. */}
|
||||
<HanzoAppLauncher currentApp="console" align="right" quickSwitchKey={false} />
|
||||
</XStack>
|
||||
|
||||
{/* Compact topbar trigger — the switchers + account fold into a drawer. */}
|
||||
{/* Account drawer trigger — every viewport. The drawer is where the
|
||||
occasional controls live (environment, notifications, account). */}
|
||||
<Button
|
||||
size="$3"
|
||||
chromeless
|
||||
$lg={{ display: 'none' }}
|
||||
icon={<SlidersHorizontal size={18} />}
|
||||
onPress={() => setMenuOpen(true)}
|
||||
aria-label="Account and settings"
|
||||
@@ -1152,10 +1122,10 @@ export function Dashboard({ children }: { children: ReactNode }) {
|
||||
<DockedChatPanel />
|
||||
</YStack>
|
||||
|
||||
{/* Mobile account drawer — the SAME account control as the rail foot, so
|
||||
identity, tenancy, theme, billing and sign-out read identically on a
|
||||
phone. Only the phone-only extras (project/network scope, alerts) sit
|
||||
beside it; the drawer no longer keeps its own copies of them. */}
|
||||
{/* Account drawer — the SAME account control as the rail foot, so identity,
|
||||
tenancy, theme, billing and sign-out read identically everywhere. The
|
||||
occasional controls sit beside it: the environment picker (production
|
||||
is the default, switching it is a deliberate act) and notifications. */}
|
||||
<SlideOver open={menuOpen} onClose={() => setMenuOpen(false)} side="right" size={320} title="Account">
|
||||
<YStack gap="$2" className="hz-touch-target">
|
||||
<AccountMenu />
|
||||
|
||||
+4
-3
@@ -39,9 +39,10 @@ function surfaceOf(): Exclude<Surface, null> {
|
||||
const path = window.location.pathname
|
||||
if (path === '/signin') return 'signin'
|
||||
if (path.startsWith('/auth/callback')) return 'callback'
|
||||
// `/` is the public marketing landing on a consumer host; an admin host keeps its
|
||||
// silent-SSO bounce (guarded → /signin), never the marketing page.
|
||||
if (path === '/' && !isAdminHost(window.location.host)) return 'landing'
|
||||
// Everything else, `/` included, is the app — gated. The console is the APPLICATION;
|
||||
// the marketing face of Hanzo Cloud is cloud.hanzo.ai. Serving a SECOND copy of it
|
||||
// here is what made signing in look broken: cloud.hanzo.ai's "Sign in" landed on a
|
||||
// page wearing the byte-identical shared header, so the click read as a re-render.
|
||||
return 'guarded'
|
||||
}
|
||||
|
||||
|
||||
@@ -40,14 +40,11 @@ describe('resolve(session) → stage', () => {
|
||||
expect(at({ surface: 'signin' })).toBe('signin')
|
||||
expect(at({ surface: 'signin', authed: true })).toBe('signin')
|
||||
})
|
||||
it('anonymous on the landing surface → signin (public marketing landing)', () => {
|
||||
expect(at({ surface: 'landing', authed: false })).toBe('signin')
|
||||
})
|
||||
it('anonymous on a guarded surface → signin (redirect to /signin)', () => {
|
||||
it('anonymous on a guarded surface → signin (Auth starts the IAM hop)', () => {
|
||||
expect(at({ surface: 'guarded', authed: false })).toBe('signin')
|
||||
})
|
||||
it('authed on the landing surface advances past signin', () => {
|
||||
expect(at({ surface: 'landing', authed: true })).toBe('ready')
|
||||
it('authed on a guarded surface advances past signin', () => {
|
||||
expect(at({ surface: 'guarded', authed: true })).toBe('ready')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -106,7 +103,7 @@ describe('resolve(session) → stage', () => {
|
||||
* cannot leak to an unauthed / org-less session — these sweeps prove `resolve` can't.
|
||||
*/
|
||||
describe('fail-closed — resolve NEVER yields ready when it must not', () => {
|
||||
const SURFACES: Surface[] = ['callback', 'signin', 'landing', 'guarded', null]
|
||||
const SURFACES: Surface[] = ['callback', 'signin', 'guarded', null]
|
||||
const BOOLS = [true, false]
|
||||
const ENTERED: Array<boolean | null> = [true, false, null]
|
||||
|
||||
@@ -149,7 +146,7 @@ describe('fail-closed — resolve NEVER yields ready when it must not', () => {
|
||||
expect(authed).toBe(true)
|
||||
expect(owner).not.toBe('')
|
||||
expect(entered).toBe(true)
|
||||
expect(surface === 'landing' || surface === 'guarded').toBe(true)
|
||||
expect(surface).toBe('guarded')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -22,11 +22,13 @@ export type Stage = 'signin' | 'waitlist' | 'org' | 'onboard' | 'ready'
|
||||
* Which entry surface the URL is, resolved AFTER mount (the served SPA shell is the `/`
|
||||
* route for every path, so the real path is only knowable client-side). `null` until then.
|
||||
* - `callback` — `/auth/callback`: complete the PKCE exchange (before any guard bounce).
|
||||
* - `signin` — `/signin`: the sign-in experience owns the screen.
|
||||
* - `landing` — `/` on a consumer host: the public marketing page for an anon visitor.
|
||||
* - `guarded` — anything else: the app, gated; an anon visitor is bounced to `/signin`.
|
||||
* - `signin` — `/signin`: the sign-in experience owns the screen. The ONE surface that
|
||||
* never auto-authorizes: sign-out lands here, and IAM may still hold its own session,
|
||||
* so an automatic authorize would silently sign the user back in.
|
||||
* - `guarded` — everything else, `/` included: the app, gated. An anon visitor STARTS
|
||||
* the IAM hop here rather than being parked on an interstitial.
|
||||
*/
|
||||
export type Surface = 'callback' | 'signin' | 'landing' | 'guarded' | null
|
||||
export type Surface = 'callback' | 'signin' | 'guarded' | null
|
||||
|
||||
/** Everything the stage decision reads. Plain values — no hooks, no JSX, no React. */
|
||||
export interface Session {
|
||||
|
||||
@@ -1,54 +1,73 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { adminOrgState, scopedOrgRow } from './org-state'
|
||||
import { switchOrg } from '~/lib/org-scope'
|
||||
import { contextLabel, scopedOrgRow } from './org-state'
|
||||
|
||||
describe('adminOrgState', () => {
|
||||
const findOrgs = async () => []
|
||||
|
||||
it('switches through the console org scope and nothing else', () => {
|
||||
// THE money-path invariant. `org-scope.switchOrg` persists the scope and
|
||||
// reloads, so every module refetches under the new `X-Org-Id` — the one seam
|
||||
// tenant scoping and its billing attribution hang off. A switcher that minted
|
||||
// its own would bypass it silently, which is why this is an IDENTITY check and
|
||||
// not a behavioural one: there is one switch, and this is it.
|
||||
expect(adminOrgState({ scoped: 'hanzo', findOrgs, switchOrg }).switchOrg).toBe(switchOrg)
|
||||
})
|
||||
|
||||
it('carries the cross-tenant search through untouched', () => {
|
||||
const state = adminOrgState({ scoped: 'hanzo', findOrgs, switchOrg })
|
||||
expect(state.findOrgs).toBe(findOrgs)
|
||||
})
|
||||
|
||||
it('reports the scoped org as the current one', () => {
|
||||
const state = adminOrgState({ scoped: 'maxpower', findOrgs, switchOrg })
|
||||
expect(state.currentOrgId).toBe('maxpower')
|
||||
expect(state.currentOrg?.name).toBe('maxpower')
|
||||
expect(state.organizations.map((o) => o.name)).toEqual(['maxpower'])
|
||||
describe('scopedOrgRow', () => {
|
||||
it('titles a slug without inventing a display name', () => {
|
||||
expect(scopedOrgRow('acme').at(0)?.displayName).toBe('Acme')
|
||||
expect(scopedOrgRow('acme').at(0)?.name).toBe('acme')
|
||||
})
|
||||
|
||||
it('is honest when nothing is scoped yet', () => {
|
||||
const state = adminOrgState({ scoped: '', findOrgs, switchOrg })
|
||||
expect(state.currentOrgId).toBeNull()
|
||||
expect(state.currentOrg).toBeNull()
|
||||
expect(state.organizations).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves projects to the scope switcher — no second project picker', () => {
|
||||
const state = adminOrgState({ scoped: 'hanzo', findOrgs, switchOrg })
|
||||
expect(state.projects).toEqual([])
|
||||
expect(state.currentProjectId).toBeNull()
|
||||
})
|
||||
|
||||
it('titles a slug without inventing a display name', () => {
|
||||
expect(scopedOrgRow('acme').at(0)?.displayName).toBe('Acme')
|
||||
expect(scopedOrgRow('')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
it('never lists an org it was not given', () => {
|
||||
const spy = vi.fn(async () => [])
|
||||
const state = adminOrgState({ scoped: 'hanzo', findOrgs: spy, switchOrg })
|
||||
expect(state.organizations).toHaveLength(1)
|
||||
expect(spy).not.toHaveBeenCalled()
|
||||
describe('contextLabel', () => {
|
||||
it('reads org alone at org-level scope', () => {
|
||||
expect(contextLabel('Hanzo')).toBe('Hanzo')
|
||||
expect(contextLabel('Hanzo', undefined)).toBe('Hanzo')
|
||||
})
|
||||
|
||||
it('reads org / project once a project is chosen', () => {
|
||||
expect(contextLabel('Hanzo', 'atlas')).toBe('Hanzo / atlas')
|
||||
})
|
||||
})
|
||||
|
||||
/** Every .ts/.tsx under src/, so the scan cannot miss a new caller. */
|
||||
function sources(dir: string, out: string[] = []): string[] {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const p = join(dir, name)
|
||||
if (statSync(p).isDirectory()) sources(p, out)
|
||||
else if (/\.tsx?$/.test(name) && !/\.test\.tsx?$/.test(name)) out.push(p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
describe('the one org switch', () => {
|
||||
/**
|
||||
* THE money-path invariant. `org-scope.switchOrg` persists the scope and
|
||||
* reloads, so every module refetches under the new `X-Org-Id` — the one seam
|
||||
* tenant scoping and its billing attribution hang off. A second switcher
|
||||
* elsewhere would bypass it silently, and a UI with the org in two corners is
|
||||
* exactly how that creeps in. So this asserts the STRUCTURE: precisely one
|
||||
* component calls it.
|
||||
*/
|
||||
it('has exactly these call sites, and no other', () => {
|
||||
const root = join(import.meta.dirname, '../..')
|
||||
const callers = sources(root)
|
||||
.filter((p) => /switchOrg\s*\(/.test(readFileSync(p, 'utf8')))
|
||||
.map((p) => p.slice(root.length + 1))
|
||||
.sort()
|
||||
expect(callers).toEqual([
|
||||
// The command palette can jump you to a tenant — a search result, not a
|
||||
// corner of the chrome, so it costs the IA nothing.
|
||||
'components/CommandPalette.tsx',
|
||||
// The ONE persistent org control in the shell.
|
||||
'components/ContextSwitcher.tsx',
|
||||
// The definition itself.
|
||||
'lib/org-scope.ts',
|
||||
])
|
||||
})
|
||||
|
||||
it('is imported from org-scope by every caller, never redefined', () => {
|
||||
const root = join(import.meta.dirname, '../..')
|
||||
for (const rel of ['components/ContextSwitcher.tsx', 'components/CommandPalette.tsx']) {
|
||||
const src = readFileSync(join(root, rel), 'utf8')
|
||||
expect(src, rel).toMatch(/import \{[^}]*\bswitchOrg\b[^}]*\} from '~\/lib\/org-scope'/)
|
||||
expect(src, rel).not.toMatch(/function switchOrg|const switchOrg\s*=/)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user