mirror of
https://github.com/hanzoai/build.git
synced 2026-10-23 02:18:08 +00:00
Extract the builder from the workspace
The /dev surface in hanzoai/app reaches the whole workspace shell for one thing — the column beside the pane — so lifting what its imports touch would have copied thirteen rooms, an org switcher and a settings pane to publish a demo of the composer. The column is nine lines of portal here instead, and nothing else came with it. Everything it talks to is public: api.hanzo.ai for the catalog, the projects and the run, hanzo.id for identity. The IAM application hanzo-build does not exist yet, so sign-in answers "cannot read the sign-in configuration for this application" until it is seeded. Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
*.tsbuildinfo
|
||||
@@ -0,0 +1,28 @@
|
||||
name: CI/CD
|
||||
|
||||
# The caller, and deliberately nothing else: triggers plus the import. All real
|
||||
# config is the repo-root hanzo.yml, which platform.hanzo.ai reads too.
|
||||
#
|
||||
# `.hanzo/workflows`, NOT `.github/workflows`, on BOTH sides of the `uses:`. The
|
||||
# forge collects workflows from the first of WORKFLOW_DIRS present in the commit
|
||||
# and resolves a reusable through the same allowlist, and github.com has no
|
||||
# self-hosted runners for this org — so a job asking for hanzo-build-linux-amd64
|
||||
# there is never claimed, and an unclaimable job does not fail, it waits out the
|
||||
# 24h timeout while the next push queues behind it.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# A `v*` tag is a RELEASE and publishes an image named after it, verbatim.
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: cicd-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
cicd:
|
||||
uses: hanzoai/ci/.hanzo/workflows/build.yml@v1
|
||||
secrets: inherit
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
# Hanzo Build — the Vite SPA built once, served by hanzoai/spa.
|
||||
FROM node:24-alpine AS build
|
||||
WORKDIR /build
|
||||
ENV CI=true PNPM_HOME=/pnpm PATH=$PNPM_HOME:$PATH
|
||||
RUN corepack enable && corepack prepare [email protected] --activate
|
||||
|
||||
# Manifests first so a source edit re-runs the build and not the resolve.
|
||||
#
|
||||
# pnpm-workspace.yaml is REQUIRED even though this repo has no workspace
|
||||
# members: every dependency is `catalog:`, and the catalog lives in that file.
|
||||
# It also carries the overrides that keep ONE copy of the gui runtime — two are
|
||||
# two theme contexts, and every themed component then throws.
|
||||
#
|
||||
# FROZEN, and the lockfile ships. A resolver left free to drift builds a tree
|
||||
# nobody ran; frozen, the image gets the tree the gate passed against.
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY tsconfig.json vite.config.ts index.html ./
|
||||
COPY src src
|
||||
|
||||
# `pnpm build` is `tsc --noEmit` && `vite build`, so the typecheck is part of
|
||||
# the image.
|
||||
#
|
||||
# `&&`, not `;`: with `;` the RUN carries the status of the LAST command and a
|
||||
# failed build would be masked. A vite build that emits nothing still exits 0,
|
||||
# which is what the file tests are for.
|
||||
RUN pnpm build \
|
||||
&& [ -s dist/index.html ] \
|
||||
&& [ -d dist/assets ]
|
||||
|
||||
# hanzoai/spa, not hanzoai/static, and not a webserver of our own: this is a
|
||||
# client-routed SPA, so it needs index.html served for every path.
|
||||
# hanzoai/static defaults to `Content-Security-Policy: default-src 'none'`,
|
||||
# which blocks the bundle the page loads and leaves a blank screen.
|
||||
# Defaults: PORT=3000, ROOT=/public.
|
||||
FROM ghcr.io/hanzoai/spa:1.4.11
|
||||
COPY --from=build /build/dist /public
|
||||
EXPOSE 3000
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
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:
|
||||
|
||||
You must give any other recipients of the Work or Derivative Works a copy of
|
||||
this License; and
|
||||
You must cause any modified files to carry prominent notices stating that You
|
||||
changed the files; and
|
||||
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
|
||||
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.
|
||||
|
||||
Copyright 2026 Hanzo AI, Inc
|
||||
|
||||
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.
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Hanzo AI, Inc
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,23 @@
|
||||
Hanzo Build
|
||||
Copyright (c) 2026 Hanzo AI, Inc.
|
||||
|
||||
Licensed under either of Apache License, Version 2.0 (LICENSE-APACHE) or
|
||||
MIT license (LICENSE-MIT) at your option.
|
||||
|
||||
Unless you explicitly state otherwise, any contribution intentionally
|
||||
submitted for inclusion in this work by you, as defined in the Apache-2.0
|
||||
licence, shall be dual licensed as above, without any additional terms or
|
||||
conditions.
|
||||
|
||||
The builder in this repository was extracted from the Hanzo workspace
|
||||
(github.com/hanzoai/app).
|
||||
|
||||
Bundled at build time, under their own licences:
|
||||
@hanzo/ui, @hanzo/gui MIT github.com/hanzoai/ui
|
||||
@hanzo/ai MIT github.com/hanzoai/ai
|
||||
@hanzo/iam MIT github.com/hanzoai/iam
|
||||
@hanzo/font MIT github.com/hanzoai/font
|
||||
lucide-react ISC github.com/lucide-icons/lucide
|
||||
react, react-dom MIT github.com/facebook/react
|
||||
react-router MIT github.com/remix-run/react-router
|
||||
next-themes MIT github.com/pacocoursey/next-themes
|
||||
@@ -0,0 +1,87 @@
|
||||
# Hanzo Build
|
||||
|
||||
Say what you want. Hanzo writes it, runs it, and puts it on a live URL — with
|
||||
the database, the sign-in and the storage already there.
|
||||
|
||||
This is the builder as a page: one composer, the public template catalog, the
|
||||
projects an organization already has, and the live transcript of a run. It talks
|
||||
to the Hanzo platform over its public API and to Hanzo IAM for identity. There
|
||||
is nothing private in it — no internal host, no unpublished package.
|
||||
|
||||
```
|
||||
pnpm install
|
||||
pnpm dev # http://localhost:3200
|
||||
```
|
||||
|
||||
## What is on the screen
|
||||
|
||||
**The ask.** `Build` asks for the thing, `Plan` asks what building it would
|
||||
involve; the mode rides with the prompt. Sending starts a coding run
|
||||
(`POST /v1/agents/coding`) and opens its transcript in place.
|
||||
|
||||
**The run.** Turns as they arrive, folded into blocks, with Pause, Resume and
|
||||
Stop. Steering is a queue the agent drains between turns, so a press is
|
||||
confirmed as recorded and the status is left to the feed to correct.
|
||||
|
||||
**The catalog.** `/v1/templates` is public, so the shelf is drawn whether or not
|
||||
you are signed in. Picking one takes a copy of that template's own repository
|
||||
rather than asking a model to reinvent a starter the platform already publishes.
|
||||
|
||||
**A project.** Its deployed page, framed; the files the site is serving, edited
|
||||
in place; and every deployment attempt. The column on the right is the running
|
||||
page beside the thing that builds it.
|
||||
|
||||
## Configuration
|
||||
|
||||
Everything has a working default. Each is a Vite `VITE_` variable, read at build
|
||||
time.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `VITE_HANZO_API` | The platform. Defaults to `https://api.hanzo.ai` on a `hanzo.ai` host and to this page's own origin anywhere else, which is what makes the dev proxy work. |
|
||||
| `VITE_HANZO_IAM` | The issuer. `https://hanzo.id`. |
|
||||
| `VITE_HANZO_CLIENT_ID` | This application's IAM client, `<org>-<app>`. `hanzo-build`. |
|
||||
| `VITE_PUBLISHABLE_KEY` | Optional. Records anonymous reads against the organization that published the page. It names an org and no person, so it is publishable by construction. |
|
||||
|
||||
**Sign-in needs an IAM application.** `hanzo-build` must exist in Hanzo IAM with
|
||||
this deployment's `/auth/callback` among its redirect URIs. Until it is seeded,
|
||||
hanzo.id answers "cannot read the sign-in configuration for this application"
|
||||
and only the public half of the surface — the ask and the catalog — works. Point
|
||||
`VITE_HANZO_CLIENT_ID` at an application that does exist to run against it.
|
||||
|
||||
## The two things worth knowing before editing
|
||||
|
||||
**The room is a flex column, and says so.** `@hanzo/ui`'s `Box` renders
|
||||
`display: block`, and a block parent makes `flex: 1` inert on everything below
|
||||
it — the frame then sizes to its own bar and the whole builder measures zero
|
||||
high under a header that looks perfectly fine. `src/app.tsx` uses `YStack`.
|
||||
|
||||
**There is no CSS pipeline, and none is needed.** `<Hanzo>` imports the
|
||||
stylesheet `@hanzo/ui` generates at ITS publish time, and gui inserts the rules
|
||||
for props a package could not have known about — this app's own `px="$4"`,
|
||||
`width={720}` — at first render, into `<style id="_hanzogui-styles">`, before
|
||||
paint. Measured on this app: emptying a pre-generated sheet changes no geometry
|
||||
and no pixel. So there is no generator to run, nothing to commit, and
|
||||
`src/build.css` holds the three rules no package ships.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
src/
|
||||
main.tsx the mount: faces, sheets, <Hanzo>
|
||||
app.tsx the room
|
||||
routes.tsx two addresses: the builder, and the return from the issuer
|
||||
root.tsx identity, and the client that speaks for it
|
||||
frame.tsx the bar, the pane, and the column beside it
|
||||
Build.tsx the ask, the catalog, a project
|
||||
Session.tsx an open run
|
||||
run.ts POST /v1/agents/coding
|
||||
ai.tsx the platform client
|
||||
api.ts where the platform is
|
||||
token.ts where the session is kept
|
||||
open.ts what is open, and the address that carries it
|
||||
```
|
||||
|
||||
## Licence
|
||||
|
||||
Apache-2.0 OR MIT, at your option. See `NOTICE`.
|
||||
@@ -0,0 +1,32 @@
|
||||
# Hanzo CI/CD — build. All real config is here; .hanzo/workflows/cicd.yml is
|
||||
# triggers and an import of hanzoai/ci.
|
||||
|
||||
test:
|
||||
# Frozen, so the gate resolves the same tree the Dockerfile does. The lockfile
|
||||
# ships, and a stale one fails here rather than silently building something
|
||||
# else.
|
||||
- name: install
|
||||
run: |
|
||||
set -e
|
||||
corepack enable
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
# Verbatim the build line the image runs, run before there is an image, so a
|
||||
# break arrives in a minute instead of after a full layer stack. `pnpm build`
|
||||
# is `tsc --noEmit` then `vite build`. A vite build that emits nothing still
|
||||
# exits 0, hence the file tests.
|
||||
- name: build
|
||||
run: |
|
||||
set -e
|
||||
pnpm build
|
||||
test -s dist/index.html
|
||||
test -d dist/assets
|
||||
|
||||
images:
|
||||
- name: build
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
repo: ghcr.io/hanzoai/build
|
||||
platforms: [linux/amd64]
|
||||
# No build_secrets, and none belong here. Every credential that names a
|
||||
# person is minted by hanzo.id at sign-in and never built in.
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<base href="/" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<title>Hanzo Build</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Say what you want. Hanzo writes it, runs it, and puts it on a live URL."
|
||||
/>
|
||||
<style>
|
||||
/* The two things only the host document can answer: how tall the app is,
|
||||
and that its one child is a flex item. @hanzo/ui's stylesheet resets and
|
||||
paints everything else. Without this the builder grows to fit its
|
||||
content instead of filling the window, and every pane that scrolls
|
||||
inside it scrolls the page instead. */
|
||||
#root {
|
||||
display: flex;
|
||||
height: 100dvh;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
/* Keep gui's theme class in step with next-themes'. One decision, two
|
||||
names: next-themes writes `light` on <html> and gui reads `t_light` /
|
||||
`t_dark`, so without this the account's choice moves one of them and the
|
||||
page stays painted the other way.
|
||||
|
||||
`was` is not an optimisation — a `classList.toggle` that changes nothing
|
||||
still writes the attribute, and an observer reacting to its own write is
|
||||
a loop. Comparing first makes the callback a no-op unless the theme
|
||||
moved. Inline and in <head>, because the first paint happens before any
|
||||
module runs. */
|
||||
;(function () {
|
||||
var e = document.documentElement, was
|
||||
function s() {
|
||||
var l = e.classList.contains('light')
|
||||
if (l === was) return
|
||||
was = l
|
||||
e.classList.toggle('t_light', l)
|
||||
e.classList.toggle('t_dark', !l)
|
||||
}
|
||||
s()
|
||||
new MutationObserver(s).observe(e, { attributes: true, attributeFilter: ['class'] })
|
||||
})()
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "@hanzo/build",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"description": "Hanzo Build \u2014 say what you want, watch it built, deployed and running.",
|
||||
"packageManager": "[email protected]",
|
||||
"engines": {
|
||||
"node": ">=22.18"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hanzo/ai": "catalog:",
|
||||
"@hanzo/font": "catalog:",
|
||||
"@hanzo/gui": "catalog:",
|
||||
"@hanzo/iam": "catalog:",
|
||||
"@hanzo/ui": "catalog:",
|
||||
"@hanzogui/next-theme": "catalog:",
|
||||
"lucide-react": "catalog:",
|
||||
"next-themes": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-hook-form": "catalog:",
|
||||
"react-native-web": "catalog:",
|
||||
"react-router": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hanzo/vite": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"@vitejs/plugin-react": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:"
|
||||
}
|
||||
}
|
||||
Generated
+6822
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
# What may run its own install script. Vite's bundler is the only one, and a
|
||||
# placeholder here is an install that stops to ask.
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
|
||||
# One version of a package in the tree, whatever asked for it. Two copies of a
|
||||
# React runtime are two contexts, and a component then looks for the one it was
|
||||
# not rendered under. @hanzo/ui pins the gui runtime a minor below @hanzo/gui,
|
||||
# and each pins its own dependencies exactly, so without these a second copy of
|
||||
# the runtime resolves beside the first and every themed component throws
|
||||
# "Missing theme."
|
||||
overrides:
|
||||
'@hanzo/gui': 8.3.5
|
||||
'@hanzogui/animations-css': 8.3.5
|
||||
'@hanzogui/config': 8.3.5
|
||||
'@hanzogui/core': 8.3.5
|
||||
'@hanzogui/lucide-icons-2': 8.3.5
|
||||
'@hanzogui/next-theme': 8.3.5
|
||||
'@hanzogui/telemetry': 8.3.5
|
||||
'@hanzogui/toast': 8.3.5
|
||||
'@hanzogui/web': 8.3.5
|
||||
'@react-native/normalize-colors': 0.87.1
|
||||
react-is: 19.2.8
|
||||
|
||||
# Latest of everything. An age gate holds this app behind its own release train,
|
||||
# which publishes hours before it is consumed.
|
||||
minimumReleaseAge: 0
|
||||
|
||||
catalog:
|
||||
'@hanzo/ai': 0.6.16
|
||||
'@hanzo/font': 1.9.3
|
||||
'@hanzo/gui': 8.3.5
|
||||
'@hanzo/iam': 0.21.18
|
||||
'@hanzo/ui': 8.27.12
|
||||
'@hanzo/vite': 0.1.4
|
||||
'@hanzogui/next-theme': 8.3.5
|
||||
'@types/node': 26.5.0
|
||||
'@types/react': 19.2.18
|
||||
'@types/react-dom': 19.2.7
|
||||
'@vitejs/plugin-react': ^6.1.1
|
||||
lucide-react: ^1.42.0
|
||||
next-themes: ^0.4.6
|
||||
react: 19.2.8
|
||||
react-dom: 19.2.8
|
||||
react-hook-form: ^7.87.0
|
||||
react-native-web: ^0.21.2
|
||||
react-router: ^8.3.1
|
||||
typescript: 7.0.2
|
||||
vite: ^8.2.2
|
||||
+1322
File diff suppressed because it is too large
Load Diff
+434
@@ -0,0 +1,434 @@
|
||||
// The pane when a run is open: the transcript, live, and the controls.
|
||||
//
|
||||
// Everything here is `@hanzo/ai`. The turns come from `useSession`, the new ones
|
||||
// from `sessions.stream()`, and every control is `sessions.steer()` — which is
|
||||
// ONE method taking the command as a value, so these three buttons are a list
|
||||
// and not three handlers.
|
||||
//
|
||||
// STEERING IS A QUEUE, NOT A SIGNAL. `steer` records a command the running
|
||||
// agent drains between turns; it does not interrupt one mid-flight. So a press
|
||||
// is confirmed as RECORDED and the status is left to the feed to correct — a
|
||||
// button that optimistically flipped the run to "paused" would be lying for as
|
||||
// long as the current turn takes.
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Pause, Play, Square } from 'lucide-react'
|
||||
import { useAi, useSession } from '@hanzo/ai/react'
|
||||
import type { SessionCommand, SessionEvent } from '@hanzo/ai'
|
||||
import { Composer } from '@hanzo/ui/chat'
|
||||
import { Button, Text, XStack, YStack } from '@hanzo/ui'
|
||||
import { Beside } from '~/frame'
|
||||
|
||||
/** The controls, as data. `steer` takes the verb, so the row is a map. */
|
||||
const CONTROLS: { command: SessionCommand; label: string; icon: typeof Pause }[] = [
|
||||
{ command: 'pause', label: 'Pause', icon: Pause },
|
||||
{ command: 'resume', label: 'Resume', icon: Play },
|
||||
{ command: 'stop', label: 'Stop', icon: Square },
|
||||
]
|
||||
|
||||
/** A string field, or empty. Keeps every read below total. */
|
||||
function str(v: unknown): string {
|
||||
return typeof v === 'string' ? v : ''
|
||||
}
|
||||
|
||||
function num(v: unknown): number | null {
|
||||
return typeof v === 'number' && Number.isFinite(v) ? v : null
|
||||
}
|
||||
|
||||
/**
|
||||
* The turn's body, whatever it arrived wrapped in.
|
||||
*
|
||||
* A live event carries `payload` as a JSON STRING — `"{\"type\":\"tool_call\",…}"`
|
||||
* — where a recorded one carries the object. Both shapes are answered here, and
|
||||
* a string that is not JSON stays exactly what it was, because that is the case
|
||||
* where it really is the message.
|
||||
*/
|
||||
function decode(payload: unknown): Record<string, unknown> | string | null {
|
||||
if (payload && typeof payload === 'object') return payload as Record<string, unknown>
|
||||
if (typeof payload !== 'string') return null
|
||||
const text = payload.trim()
|
||||
if (!text.startsWith('{') && !text.startsWith('[')) return payload
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(text)
|
||||
if (parsed && typeof parsed === 'object') return parsed as Record<string, unknown>
|
||||
} catch {
|
||||
// Not JSON after all — it was prose that happened to open with a brace.
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
/** A path's last segment — `control.txt`, never the whole host path. */
|
||||
function base(path: string): string {
|
||||
const cut = path.replace(/\/+$/, '')
|
||||
return cut.slice(cut.lastIndexOf('/') + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* What a turn SAID.
|
||||
*
|
||||
* A CURATED PROJECTION, never a dump. A record carries the machine the run
|
||||
* happened on — `cwd`, `repo.remote`, `host`, `machineId`, and a `files[]`
|
||||
* holding whole file bodies. None of that is what happened; it is the wiring
|
||||
* underneath what happened, it belongs to whoever ran the session, and printing
|
||||
* it would put someone's filesystem in front of anyone who can read the run. So
|
||||
* each kind gets a sentence built from the few fields that describe the
|
||||
* OUTCOME, and everything else is dropped by omission — a projection cannot
|
||||
* leak a field it never reads.
|
||||
*/
|
||||
function line(payload: unknown): string {
|
||||
const body = decode(payload)
|
||||
if (typeof body === 'string') return body
|
||||
if (!body) return ''
|
||||
|
||||
// The prose fields, in the order a turn is worth reading by. `result` is here
|
||||
// because a tool result carries its answer there and it is the sentence a
|
||||
// reader wants.
|
||||
for (const key of ['text', 'message', 'content', 'result', 'command']) {
|
||||
const v = body[key]
|
||||
if (typeof v === 'string' && v) return v
|
||||
}
|
||||
|
||||
switch (str(body.type)) {
|
||||
// The run attached to a working copy. The branch is what a reader is
|
||||
// working ON; where it happens to live is not shown.
|
||||
case 'context': {
|
||||
const repo = body.repo as Record<string, unknown> | undefined
|
||||
const branch = str(repo?.branch)
|
||||
return branch ? `Started on ${branch}` : 'Started'
|
||||
}
|
||||
|
||||
case 'resume':
|
||||
return 'Resumed an earlier run'
|
||||
|
||||
// `reason` is already the durability note, written for a person.
|
||||
case 'sandbox':
|
||||
return (
|
||||
str(body.reason) ||
|
||||
(body.durable === false
|
||||
? 'Nothing this run writes is saved.'
|
||||
: 'What this run writes is saved.')
|
||||
)
|
||||
|
||||
case 'turn': {
|
||||
const n = num(body.turn)
|
||||
const max = num(body.maxTurns)
|
||||
if (n === null) return ''
|
||||
return max === null ? `Turn ${n}` : `Turn ${n} of ${max}`
|
||||
}
|
||||
|
||||
// What CHANGED, by name. `files[].content` carries whole file bodies and is
|
||||
// never read here.
|
||||
case 'done': {
|
||||
const changed = Array.isArray(body.changed)
|
||||
? body.changed.map((f) => base(str(f))).filter(Boolean)
|
||||
: []
|
||||
const what =
|
||||
changed.length === 0
|
||||
? 'no files changed'
|
||||
: changed.length <= 3
|
||||
? `changed ${changed.join(', ')}`
|
||||
: `changed ${changed.length} files`
|
||||
const why = str(body.finishReason)
|
||||
return why && why !== 'stop' ? `Ended (${why}) — ${what}` : `Done — ${what}`
|
||||
}
|
||||
|
||||
case 'error':
|
||||
return str(body.error) || str(body.detail) || 'This run hit an error.'
|
||||
}
|
||||
|
||||
// A tool call has no prose — it has a name and an `arguments` blob that is
|
||||
// itself encoded JSON. The name IS the sentence; printing the arguments back
|
||||
// is how raw JSON ends up on screen.
|
||||
return str(body.name)
|
||||
}
|
||||
|
||||
/**
|
||||
* The actor, short enough to be a label.
|
||||
*
|
||||
* An actor reads `hanzo/2d4d67ab-30f1-474e-b81f-f60461852259`, and a
|
||||
* 45-character line introducing a six-word sentence is noise wearing a label's
|
||||
* clothes. The org and the first block of the id identify a run's author among
|
||||
* the handful a reader ever sees on one screen.
|
||||
*/
|
||||
function who(actor: string): string {
|
||||
const [org = '', rest = ''] = actor.split('/')
|
||||
const head = rest.split('-')[0] ?? ''
|
||||
return head ? `${org}/${head}` : actor
|
||||
}
|
||||
|
||||
/**
|
||||
* What a run has written, beside its transcript.
|
||||
*
|
||||
* Names only, through `base`, because `changed` carries absolute host paths and
|
||||
* a run's column is not where those become a reader's problem. Read from the
|
||||
* turns, which are already the record.
|
||||
*/
|
||||
function Made({ changed, status }: { changed: string[]; status: string }) {
|
||||
return (
|
||||
<YStack p="$3" gap="$4">
|
||||
<YStack gap="$1">
|
||||
<Text fontSize="$1" color="$soft" textTransform="uppercase">
|
||||
Run
|
||||
</Text>
|
||||
<Text fontSize="$2" color="$ink">
|
||||
{status}
|
||||
</Text>
|
||||
</YStack>
|
||||
|
||||
<YStack gap="$2">
|
||||
<Text fontSize="$1" color="$soft" textTransform="uppercase">
|
||||
Files
|
||||
</Text>
|
||||
{changed.length === 0 ? (
|
||||
<Text fontSize="$2" color="$soft">
|
||||
Nothing has been written yet.
|
||||
</Text>
|
||||
) : (
|
||||
changed.map((name) => (
|
||||
<Text key={name} fontSize="$2" color="$ink" numberOfLines={1}>
|
||||
{name}
|
||||
</Text>
|
||||
))
|
||||
)}
|
||||
</YStack>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
export function Session({ id }: { id: string }) {
|
||||
const ai = useAi()
|
||||
const { session, loading, reload } = useSession(id)
|
||||
const [live, setLive] = useState<SessionEvent[]>([])
|
||||
const [sending, setSending] = useState(false)
|
||||
const [draft, setDraft] = useState('')
|
||||
const [noted, setNoted] = useState<string | null>(null)
|
||||
const foot = useRef<HTMLDivElement | null>(null)
|
||||
// status|title as last read, so a heartbeat that changed neither is free.
|
||||
const shownRef = useRef('')
|
||||
|
||||
// The live feed, narrowed to this run's tree. `stream()` ends when the server
|
||||
// closes it; reopening is deliberately not automatic — a pane that silently
|
||||
// reconnects forever hides a feed that is actually failing.
|
||||
useEffect(() => {
|
||||
const control = new AbortController()
|
||||
void (async () => {
|
||||
try {
|
||||
for await (const frame of ai.sessions.stream({ root: id }, { signal: control.signal })) {
|
||||
if (control.signal.aborted) return
|
||||
if (frame.kind === 'event') {
|
||||
setLive((prev) => [...prev, frame.event])
|
||||
} else if (frame.session.id === id) {
|
||||
// A row frame lands on every heartbeat of a live run, and re-reading
|
||||
// pulls the whole detail each time. Only the two fields this pane
|
||||
// SHOWS are worth a read.
|
||||
const next = `${frame.session.status ?? ''}|${frame.session.title ?? ''}`
|
||||
if (next !== shownRef.current) {
|
||||
shownRef.current = next
|
||||
reload()
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// A dropped feed is ordinary. The transcript already on screen stands.
|
||||
}
|
||||
})()
|
||||
return () => control.abort()
|
||||
}, [ai, id, reload])
|
||||
|
||||
// The turns the detail carries, then everything the feed has added, deduped
|
||||
// by seq — the two overlap whenever a turn lands between the read and the
|
||||
// subscribe.
|
||||
const turns = useMemo(() => {
|
||||
const seen = new Map<string, SessionEvent>()
|
||||
const all = [...(session?.recentEvents ?? []), ...live]
|
||||
all.forEach((e, i) => {
|
||||
// Position is the fallback key, never a random one: a turn carrying
|
||||
// neither seq nor id would otherwise be keyed differently on every render,
|
||||
// so the dedup would stop deduping it and the pane would grow a copy per
|
||||
// paint.
|
||||
seen.set(e.seq !== undefined ? `s${e.seq}` : (e.id ?? `i${i}`), e)
|
||||
})
|
||||
return [...seen.values()].sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0))
|
||||
}, [session?.recentEvents, live])
|
||||
|
||||
/**
|
||||
* Turns folded into blocks.
|
||||
*
|
||||
* A streamed answer arrives one TOKEN per event, so an unfolded transcript
|
||||
* prints a sentence as a dozen mid-word cards, each under its own actor line.
|
||||
* Consecutive turns of the same kind and actor are therefore one block with
|
||||
* one label.
|
||||
*
|
||||
* Message tokens join with nothing, because each carries its own leading
|
||||
* space and re-inserting one would double it. Every other kind joins with a
|
||||
* newline: two tool results are two facts, not one run-on.
|
||||
*/
|
||||
const blocks = useMemo(() => {
|
||||
const out: { key: string; kind: string; actor?: string; parts: string[] }[] = []
|
||||
for (const e of turns) {
|
||||
const kind = e.kind ?? 'turn'
|
||||
const said = line(e.payload)
|
||||
const last = out[out.length - 1]
|
||||
if (last && last.kind === kind && last.actor === e.actor) last.parts.push(said)
|
||||
else out.push({ key: `${e.seq ?? e.id ?? out.length}`, kind, actor: e.actor, parts: [said] })
|
||||
}
|
||||
return out.map((b) => ({
|
||||
...b,
|
||||
text: b.kind === 'message' ? b.parts.join('') : b.parts.filter(Boolean).join('\n'),
|
||||
}))
|
||||
}, [turns])
|
||||
|
||||
// What this run has written, by name. `changed` rides a `done` turn; every
|
||||
// other kind carries none, so this reads the one that does.
|
||||
const changed = useMemo(() => {
|
||||
const out = new Set<string>()
|
||||
for (const e of turns) {
|
||||
const body = decode(e.payload)
|
||||
if (!body || typeof body === 'string') continue
|
||||
if (str(body.type) !== 'done' || !Array.isArray(body.changed)) continue
|
||||
for (const f of body.changed) {
|
||||
const name = base(str(f))
|
||||
if (name) out.add(name)
|
||||
}
|
||||
}
|
||||
return [...out]
|
||||
}, [turns])
|
||||
|
||||
useEffect(() => {
|
||||
foot.current?.scrollIntoView({ block: 'end' })
|
||||
}, [blocks.length])
|
||||
|
||||
useEffect(() => {
|
||||
if (session) shownRef.current = `${session.status ?? ''}|${session.title ?? ''}`
|
||||
}, [session])
|
||||
|
||||
/**
|
||||
* What the header may honestly claim.
|
||||
*
|
||||
* `recentEvents` is capped and holds the OPENING of a run rather than its
|
||||
* end, so the line names which turns these ARE. Claiming "the latest" would
|
||||
* describe turns nobody has read.
|
||||
*/
|
||||
const shown = turns.length
|
||||
const total = typeof session?.events === 'number' ? session.events : shown
|
||||
const turnCount = total > shown ? ` · turns 1–${shown} of ${total}` : total ? ` · ${total} turns` : ''
|
||||
|
||||
const steer = useCallback(
|
||||
async (command: SessionCommand) => {
|
||||
setNoted(null)
|
||||
try {
|
||||
await ai.sessions.steer(id, command)
|
||||
setNoted(`${command} queued — it takes effect between turns`)
|
||||
reload()
|
||||
} catch {
|
||||
setNoted(`Could not ${command} this run`)
|
||||
}
|
||||
},
|
||||
[ai, id, reload],
|
||||
)
|
||||
|
||||
const send = useCallback(async () => {
|
||||
const message = draft.trim()
|
||||
if (!message || sending) return
|
||||
setSending(true)
|
||||
setNoted(null)
|
||||
try {
|
||||
await ai.sessions.steer(id, 'message', { message })
|
||||
setDraft('')
|
||||
setNoted('Sent — the agent reads it between turns')
|
||||
} catch {
|
||||
setNoted('Could not reach this run')
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}, [ai, draft, id, sending])
|
||||
|
||||
const done = session?.status === 'done' || session?.status === 'error'
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* The frame owns the column; this is what a run puts in it. */}
|
||||
<Beside>
|
||||
<Made changed={changed} status={session?.status ?? (loading ? 'loading' : 'unknown')} />
|
||||
</Beside>
|
||||
<YStack flex={1} minH={0} minW={0} width="100%">
|
||||
<XStack
|
||||
px="$4"
|
||||
py="$3"
|
||||
gap="$3"
|
||||
items="center"
|
||||
justify="space-between"
|
||||
borderBottomWidth={1}
|
||||
borderColor="$borderColor"
|
||||
>
|
||||
{/* flex + minWidth 0 TOGETHER. A flex item's default `min-width: auto`
|
||||
refuses to shrink below its content, so a long run title sizes this
|
||||
column past the viewport and pushes its own text off a narrow
|
||||
window. */}
|
||||
<YStack flex={1} minW={0} gap="$1">
|
||||
<Text render="h1" fontSize="$5" fontWeight="500" color="$ink" numberOfLines={1}>
|
||||
{session?.title || 'Untitled run'}
|
||||
</Text>
|
||||
<Text fontSize="$2" color="$soft" numberOfLines={1}>
|
||||
{session?.status ?? (loading ? 'loading' : 'unknown')}
|
||||
{session?.agent ? ` · ${session.agent}` : ''}
|
||||
{turnCount}
|
||||
</Text>
|
||||
</YStack>
|
||||
|
||||
{/* A finished run cannot be steered, so the controls are absent rather
|
||||
than present-and-refusing. */}
|
||||
{!done ? (
|
||||
<XStack gap="$2" shrink={0}>
|
||||
{CONTROLS.map(({ command, label, icon: Icon }) => (
|
||||
<Button key={command} size="sm" onClick={() => void steer(command)} aria-label={label}>
|
||||
<Icon size={14} aria-hidden />
|
||||
<Text fontSize="$2">{label}</Text>
|
||||
</Button>
|
||||
))}
|
||||
</XStack>
|
||||
) : null}
|
||||
</XStack>
|
||||
|
||||
<YStack flex={1} minH={0} overflow="scroll" px="$4" py="$4" gap="$3">
|
||||
{blocks.length === 0 ? (
|
||||
<Text fontSize="$3" color="$soft">
|
||||
{loading ? 'Reading this run…' : 'No turns recorded yet.'}
|
||||
</Text>
|
||||
) : (
|
||||
blocks.map((b) => (
|
||||
<YStack key={b.key} gap="$1">
|
||||
<Text fontSize="$1" color="$soft">
|
||||
{b.kind}
|
||||
{b.actor ? ` · ${who(b.actor)}` : ''}
|
||||
</Text>
|
||||
{b.text ? (
|
||||
<Text fontSize="$3" color="$ink">
|
||||
{b.text}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
))
|
||||
)}
|
||||
<div ref={foot} />
|
||||
</YStack>
|
||||
|
||||
<YStack px="$4" pb="$4" gap="$2">
|
||||
{noted ? (
|
||||
<Text fontSize="$2" color="$soft">
|
||||
{noted}
|
||||
</Text>
|
||||
) : null}
|
||||
<Composer
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
onSend={() => void send()}
|
||||
disabled={sending || done}
|
||||
placeholder={done ? 'This run has finished' : 'Send a message to this run'}
|
||||
label="Send a message to this run"
|
||||
/>
|
||||
</YStack>
|
||||
</YStack>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
// The platform client, and on whose account it speaks.
|
||||
//
|
||||
// ONE CLIENT, mounted once. `@hanzo/ai/react` publishes `useAi()`, which reads
|
||||
// the client out of context — so this builds it and provides it, and no surface
|
||||
// carries a second hook of its own. A second client is a second credential and
|
||||
// a second organization, and the two disagree the moment either changes.
|
||||
//
|
||||
// Signed in — the visitor's own IAM token, scoped to the organization they work
|
||||
// in. Signed out — anonymous, which reads the public catalog and nothing more.
|
||||
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import { useIam } from '@hanzo/iam/react'
|
||||
import { AiProvider } from '@hanzo/ai/react'
|
||||
import { createAiClient } from '@hanzo/ai'
|
||||
import { api } from '~/api'
|
||||
import { bearer, hasSession, org } from '~/token'
|
||||
|
||||
/**
|
||||
* The tenant an anonymous read is recorded against, where a deployment sets
|
||||
* one. It names an org and no person, so it is publishable by construction.
|
||||
*
|
||||
* It is a KEY, not a token, and the distinction decides which route the SDK
|
||||
* takes: with a bearer it sends the metered one, without one the
|
||||
* credential-less one, which reads neither the model nor the org off the
|
||||
* request. Passed as `token` it would select the metered route and be refused.
|
||||
*/
|
||||
const PUBLISHABLE = import.meta.env.VITE_PUBLISHABLE_KEY || ''
|
||||
|
||||
export function Ai({ children }: { children: ReactNode }) {
|
||||
const { sdk, isAuthenticated } = useIam()
|
||||
// The organization every call is scoped to: the SDK's selection, sent only
|
||||
// when the token says the reader belongs to it. Another tab changing it moves
|
||||
// this one too, so the client is rebuilt and every read comes back for the
|
||||
// new organization.
|
||||
const [scoped, setScoped] = useState<string | null>(() => org())
|
||||
useEffect(() => {
|
||||
const on = () => setScoped(org())
|
||||
window.addEventListener('storage', on)
|
||||
return () => window.removeEventListener('storage', on)
|
||||
}, [])
|
||||
|
||||
const account = Boolean((isAuthenticated || hasSession()) && sdk)
|
||||
|
||||
const client = useMemo(
|
||||
() =>
|
||||
account && sdk
|
||||
? createAiClient({
|
||||
baseUrl: api(),
|
||||
headers: scoped ? { 'X-Org-Id': scoped } : {},
|
||||
auth: {
|
||||
...sdk,
|
||||
getValidAccessToken: async () => (await sdk.getValidAccessToken?.()) || bearer(),
|
||||
},
|
||||
})
|
||||
: createAiClient({ baseUrl: api(), publishableKey: PUBLISHABLE }),
|
||||
[account, sdk, scoped],
|
||||
)
|
||||
|
||||
return <AiProvider client={client}>{children}</AiProvider>
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* WHERE THE PLATFORM IS, DERIVED ONCE.
|
||||
*
|
||||
* A FUNCTION, NOT A CONSTANT. A module-scope `const` is evaluated when the chunk
|
||||
* loads, which is before the page can say where it is. Called at use time it
|
||||
* answers for the document that is open.
|
||||
*
|
||||
* The local case is why this is not simply the absolute address. api.hanzo.ai
|
||||
* admits an origin by allowlist and by an https, portless DNS proof; a
|
||||
* localhost port satisfies neither, so a credentialed read to the absolute
|
||||
* address fails its preflight and the builder draws as though the org owned
|
||||
* nothing. `vite.config.ts` proxies `/v1` for exactly this, and same-origin is
|
||||
* what lets the proxy do its job.
|
||||
*/
|
||||
|
||||
/** The gateway's origin, with no trailing slash: callers write `${api()}/v1/…`. */
|
||||
export function api(): string {
|
||||
const set = import.meta.env.VITE_HANZO_API
|
||||
if (set) return set.replace(/\/+$/, '')
|
||||
if (typeof window !== 'undefined') {
|
||||
const host = window.location.hostname
|
||||
// A host the gateway already admits can address it absolutely; anywhere
|
||||
// else asks its own origin and lets the proxy in front of it answer.
|
||||
if (host === 'hanzo.ai' || host.endsWith('.hanzo.ai')) return 'https://api.hanzo.ai'
|
||||
return window.location.origin
|
||||
}
|
||||
return 'https://api.hanzo.ai'
|
||||
}
|
||||
|
||||
/**
|
||||
* Identity, likewise once. IAM issues the bearer every call above carries and
|
||||
* is not reached through the API host, so it is a second address rather than a
|
||||
* path on the first. It is not proxied and does not need to be — hanzo.id
|
||||
* admits localhost on authorize, token and userinfo.
|
||||
*/
|
||||
export function iam(): string {
|
||||
return (import.meta.env.VITE_HANZO_IAM || 'https://hanzo.id').replace(/\/+$/, '')
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { YStack } from '@hanzo/ui'
|
||||
import { ThemeProvider } from 'next-themes'
|
||||
import { RouterProvider } from 'react-router/dom'
|
||||
|
||||
import { router } from '~/routes'
|
||||
|
||||
/**
|
||||
* The application.
|
||||
*
|
||||
* `<Hanzo>` sits above this (main.tsx) and is the design system; identity and
|
||||
* the platform client sit BELOW, as the layout route every screen shares
|
||||
* (routes.tsx), because the callback screen needs the router.
|
||||
*
|
||||
* THE ROOM IS A FLEX COLUMN AND SAYS SO. `YStack`, not `Box`: `Box` renders
|
||||
* `display: block`, and a block parent makes `flex: 1` inert on every descendant
|
||||
* — the frame under it then sizes to its own bar, the pane and the column
|
||||
* beside it measure zero high, and the builder paints a header over an empty
|
||||
* page with no error anywhere.
|
||||
*
|
||||
* The document IS the builder, so it states its size once and clips, and every
|
||||
* pane below scrolls inside it. `flex={1}` for the width and `100dvh` for the
|
||||
* height — `#root` is the flex parent, so width comes from growing into it and
|
||||
* height is the one thing only the document can answer. `dvh` rather than `vh`
|
||||
* so a phone's collapsing browser bar cannot crop the composer off.
|
||||
*
|
||||
* `ThemeProvider` is what a theme control writes to. `attribute="class"`
|
||||
* because that is the switch @hanzo/ui reads.
|
||||
*/
|
||||
export const App = () => (
|
||||
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem>
|
||||
<YStack flex={1} minW={0} bg="$background" style={{ height: '100dvh', overflow: 'hidden' }}>
|
||||
<RouterProvider router={router} />
|
||||
</YStack>
|
||||
</ThemeProvider>
|
||||
)
|
||||
@@ -0,0 +1,82 @@
|
||||
/* This app's own rules — the ones no package ships, because they are its layout
|
||||
rather than a library's components.
|
||||
|
||||
Everything else comes from packages. `@hanzo/ui/theme.css` is the design
|
||||
tokens, the reset and the glass material; `@hanzo/ui/styles/motion.css` is the
|
||||
rules its components assume; `src/gui.css` is the gui atomic sheet, generated
|
||||
from the config `<Hanzo>` mounts. Nothing here restates a token and nothing
|
||||
here is a utility — a rule belongs in this file only if it addresses a class
|
||||
this app writes. Three do. */
|
||||
|
||||
/* THE COLUMN BESIDE THE PANE, and the one piece of layout that is not a gui
|
||||
prop: the frame renders an empty element and the pane portals into it, so
|
||||
the width, the edge and the stacking are stated here rather than by whatever
|
||||
happens to be inside it.
|
||||
|
||||
Below the breakpoint there is no room to spare and the column is not offered
|
||||
at all — a 380px panel on a phone eats the surface it is meant to sit beside.
|
||||
The pane is complete without it: what goes in here is a preview of a project
|
||||
the pane can also open full-width. */
|
||||
.aside {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: none;
|
||||
width: 380px;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.aside {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* THE COMPOSER'S PANEL. `.hz-composer` is @hanzo/ui's published shape — the
|
||||
ring at `::before`, the halo at `::after`, both masked to a rim — and the
|
||||
package leaves the MATERIAL inside that rim to the host. This is the
|
||||
material. `-webkit-` because Safari still needs the prefix, and a prefix
|
||||
belongs in a stylesheet rather than in an inline style a component has to
|
||||
remember to write. */
|
||||
.chrome {
|
||||
background: var(--panel, rgb(255 255 255 / 0.05));
|
||||
-webkit-backdrop-filter: blur(24px) saturate(180%);
|
||||
backdrop-filter: blur(24px) saturate(180%);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
/* THE COMPOSER'S SEND CONTROL. A circle whose diameter is the composer's own
|
||||
`--hz-composer-control`, so the surface moves the control by moving one
|
||||
variable and this rule never restates the number.
|
||||
|
||||
`aspect-ratio` rather than a second width: the button carries a glyph and no
|
||||
word, so a width from padding settles at a capsule, and a round corner is not
|
||||
yet a circle. Taking the width from the height already stated is what makes
|
||||
the two one shape. */
|
||||
.hz-round {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: var(--hz-composer-control, 30px);
|
||||
aspect-ratio: 1;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--radius-full, 9999px);
|
||||
background-color: var(--neutral-800, rgb(255 255 255 / 0.1));
|
||||
color: var(--ink, #fafafa);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color var(--duration-fast, 150ms) var(--ease-in-out, ease-in-out),
|
||||
color var(--duration-fast, 150ms) var(--ease-in-out, ease-in-out);
|
||||
}
|
||||
|
||||
.hz-round:hover {
|
||||
background-color: var(--primary, #fafafa);
|
||||
color: var(--primary-foreground, #0a0a0a);
|
||||
}
|
||||
|
||||
.hz-round:disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useIam } from '@hanzo/iam/react'
|
||||
import { Box, Text, YStack } from '@hanzo/ui'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
|
||||
/**
|
||||
* The return from Hanzo IAM.
|
||||
*
|
||||
* The SDK reads the authorization code and state from the URL and the PKCE
|
||||
* verifier from storage, exchanges the code, and stores the tokens. Nothing
|
||||
* here touches a credential; this screen only decides where the reader lands,
|
||||
* and there is one place to land — this app answers one address.
|
||||
*
|
||||
* A failed exchange is reported rather than swallowed: it is the most expensive
|
||||
* refusal in the whole flow, and it is invisible from the outside.
|
||||
*/
|
||||
export function Callback() {
|
||||
const go = useNavigate()
|
||||
const { handleCallback } = useIam()
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
handleCallback()
|
||||
.then(() => {
|
||||
if (!cancelled) go('/', { replace: true })
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : 'Sign-in failed')
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [handleCallback, go])
|
||||
|
||||
return (
|
||||
<Box flex={1} items="center" justify="center" bg="$background">
|
||||
<YStack gap="$2" items="center">
|
||||
<Text fontSize="$5" color="$ink">
|
||||
{error ?? 'Completing sign-in…'}
|
||||
</Text>
|
||||
<Text fontSize="$3" color="$soft">
|
||||
{error ? 'Try signing in again.' : 'One moment.'}
|
||||
</Text>
|
||||
</YStack>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Copying something out of the app, in one control.
|
||||
//
|
||||
// The glyph, the tick and how long the tick stays are decided here rather than
|
||||
// at each call site — a control that confirms for 1400ms in one pane and 800ms
|
||||
// in another reads as two different controls doing two different things.
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Check, Copy } from 'lucide-react'
|
||||
import { Box, Text, XStack } from '@hanzo/ui'
|
||||
|
||||
/**
|
||||
* Take something away with you.
|
||||
*
|
||||
* It copies the TEXT IT IS GIVEN, which is the source rather than what is on
|
||||
* screen: rendered text has thrown away whatever shape the bytes carried.
|
||||
*
|
||||
* ALWAYS PRESENT, not hover-only. A control that exists only under a pointer is
|
||||
* one a keyboard and a thumb cannot reach; this one rests dim and comes up on
|
||||
* hover or focus. Given a `label` it stops resting — a word in a row of named
|
||||
* controls is read at full weight.
|
||||
*/
|
||||
export function Take({
|
||||
text,
|
||||
says = 'this',
|
||||
label,
|
||||
}: {
|
||||
/** What lands on the clipboard. */
|
||||
text: string
|
||||
/** What it is, for the reader who hears the control rather than sees it. */
|
||||
says?: string
|
||||
/** A word beside the glyph, where the control sits among named ones. */
|
||||
label?: string
|
||||
}) {
|
||||
const [took, setTook] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!took) return
|
||||
const t = setTimeout(() => setTook(false), 1400)
|
||||
return () => clearTimeout(t)
|
||||
}, [took])
|
||||
|
||||
return (
|
||||
<Box
|
||||
render="button"
|
||||
onClick={() => {
|
||||
void navigator.clipboard
|
||||
?.writeText(text)
|
||||
.then(() => setTook(true))
|
||||
.catch(() => {})
|
||||
}}
|
||||
aria-label={took ? 'Copied' : `Copy ${says}`}
|
||||
borderWidth={0}
|
||||
bg="transparent"
|
||||
p="$1"
|
||||
rounded="$2"
|
||||
opacity={label || took ? 1 : 0.35}
|
||||
hoverStyle={{ opacity: 1, bg: '$hover' }}
|
||||
focusStyle={{ opacity: 1 }}
|
||||
>
|
||||
<XStack items="center" gap="$1">
|
||||
{took ? <Check size={13} aria-hidden /> : <Copy size={13} aria-hidden />}
|
||||
{label ? (
|
||||
<Text fontSize="$2" color="$ink">
|
||||
{took ? 'Copied' : label}
|
||||
</Text>
|
||||
) : null}
|
||||
</XStack>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Signing in WITHOUT LOSING THE PAGE.
|
||||
*
|
||||
* A full-page redirect unloads the document, and with it the sentence somebody
|
||||
* typed and the project they had open. The popup keeps the document, so the
|
||||
* work is still on screen when the account arrives. A browser that refuses the
|
||||
* popup falls back to the redirect, which is the only alternative there is.
|
||||
*
|
||||
* One function, so every Sign in on this surface behaves the same way.
|
||||
*/
|
||||
export interface Door {
|
||||
login: () => unknown
|
||||
loginPopup: () => Promise<unknown>
|
||||
}
|
||||
|
||||
/** True when the popup completed here; false when the browser was sent away. */
|
||||
export async function enter(door: Door): Promise<boolean> {
|
||||
try {
|
||||
await door.loginPopup()
|
||||
return true
|
||||
} catch {
|
||||
void door.login()
|
||||
return false
|
||||
}
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
// The chrome: a bar, the pane, and one column beside it.
|
||||
//
|
||||
// THE COLUMN IS A SLOT, not a prop. What belongs beside the pane is decided by
|
||||
// whatever the pane is showing — the deployed page while you build it — and
|
||||
// threading that up through the frame would make the frame know about projects.
|
||||
// A portal keeps the REACT tree the contents were written in, so a component in
|
||||
// the column still reads the pane's own hooks while rendering somewhere else.
|
||||
|
||||
import { createContext, useContext, useState, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useIam } from '@hanzo/iam/react'
|
||||
import { Box, Text, XStack, YStack } from '@hanzo/ui'
|
||||
import { HanzoMark } from '@hanzo/ui/product'
|
||||
import { enter } from '~/enter'
|
||||
|
||||
const Slot = createContext<HTMLElement | null>(null)
|
||||
|
||||
/**
|
||||
* Put this in the column beside the pane.
|
||||
*
|
||||
* WHAT IS BESIDE THE PANE CANNOT PUT SOMETHING BESIDE THE PANE. A portal keeps
|
||||
* the React tree it was written in, so contents rendered in the column would
|
||||
* still see this slot and draw a column into the column they are already in.
|
||||
* Handing the contents an empty slot ends that by construction rather than by a
|
||||
* rule somebody has to remember.
|
||||
*/
|
||||
export function Beside({ children }: { children: ReactNode }) {
|
||||
const slot = useContext(Slot)
|
||||
if (!slot) return null
|
||||
return createPortal(<Slot.Provider value={null}>{children}</Slot.Provider>, slot)
|
||||
}
|
||||
|
||||
/** Who is signed in, and the way in or out. */
|
||||
function Account() {
|
||||
const door = useIam()
|
||||
const { user, isAuthenticated, logout } = door
|
||||
const name =
|
||||
(user as { displayName?: string; name?: string; email?: string } | null)?.displayName ||
|
||||
(user as { name?: string } | null)?.name ||
|
||||
(user as { email?: string } | null)?.email ||
|
||||
''
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<Box
|
||||
render="button"
|
||||
onClick={() => void enter(door)}
|
||||
px="$3"
|
||||
py="$2"
|
||||
rounded="$3"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
hoverStyle={{ bg: '$hover' }}
|
||||
>
|
||||
<Text fontSize="$2" color="$ink">
|
||||
Sign in
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<XStack items="center" gap="$2">
|
||||
<Text fontSize="$2" color="$soft" numberOfLines={1}>
|
||||
{name}
|
||||
</Text>
|
||||
<Box
|
||||
render="button"
|
||||
onClick={() => void logout()}
|
||||
px="$3"
|
||||
py="$2"
|
||||
rounded="$3"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
hoverStyle={{ bg: '$hover' }}
|
||||
>
|
||||
<Text fontSize="$2" color="$ink">
|
||||
Sign out
|
||||
</Text>
|
||||
</Box>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The frame. A bar across the top, the pane under it, and the column the pane
|
||||
* fills through `Beside`.
|
||||
*
|
||||
* The slot is held in STATE rather than a ref, because a ref does not re-render
|
||||
* and the column would stay empty until something else happened to. The ref
|
||||
* callback is the setter, so the first paint that has a node is the first paint
|
||||
* that fills it.
|
||||
*/
|
||||
export function Frame({ children }: { children: ReactNode }) {
|
||||
const [slot, setSlot] = useState<HTMLElement | null>(null)
|
||||
return (
|
||||
<YStack flex={1} minW={0} minH={0}>
|
||||
<XStack
|
||||
height={48}
|
||||
shrink={0}
|
||||
items="center"
|
||||
px="$4"
|
||||
gap="$3"
|
||||
borderBottomWidth={1}
|
||||
borderColor="$borderColor"
|
||||
>
|
||||
<HanzoMark size={18} />
|
||||
<Text fontSize="$3" fontWeight="500" color="$ink">
|
||||
Build
|
||||
</Text>
|
||||
<YStack flex={1} />
|
||||
<Account />
|
||||
</XStack>
|
||||
<XStack flex={1} minH={0}>
|
||||
<Slot.Provider value={slot}>
|
||||
<YStack flex={1} minW={0} minH={0}>
|
||||
{children}
|
||||
</YStack>
|
||||
</Slot.Provider>
|
||||
{/* A plain element, because this is a hole rather than a component: it
|
||||
holds whatever the pane portals into it and styles none of it. The
|
||||
width and the edge are its own, in build.css, and it is not offered
|
||||
at all on a window too narrow to spare the room. */}
|
||||
<div className="aside" ref={setSlot} />
|
||||
</XStack>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Hanzo } from '@hanzo/ui'
|
||||
// THE FACES. Zen is authored in `@hanzo/font`, which ships the woff2 the
|
||||
// `@font-face` needs; `@hanzo/ui`'s theme names the family and nothing more, so
|
||||
// without this the `--font-sans` token resolves to a system fallback.
|
||||
import '@hanzo/font/css'
|
||||
// THE MATERIAL AND THE TOKENS. `theme.css` is @hanzo/design's sheet, the reset,
|
||||
// and the glass surfaces together.
|
||||
import '@hanzo/ui/theme.css'
|
||||
// THE RULES THE COMPONENTS ASSUME: the `min-width: 0` floor every Grid child
|
||||
// needs, stated once by the package rather than inline on each component.
|
||||
import '@hanzo/ui/styles/motion.css'
|
||||
// THIS APP'S OWN LAYOUT — the column beside the pane, and the paint the
|
||||
// composer's controls leave to their host.
|
||||
import '~/build.css'
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
|
||||
import { App } from '~/app'
|
||||
|
||||
/**
|
||||
* THE AMBIENT TELEMETRY CLIENT, OFF.
|
||||
*
|
||||
* `@hanzo/ui` depends on `@hanzogui/telemetry`, whose `track` builds a client
|
||||
* the first time any shared component reports an interaction — zero config,
|
||||
* pageviews on by default, and an absolute ingest address it carries itself.
|
||||
* This surface measures nobody: a page anyone can run and fork does not beacon
|
||||
* to somebody else's tenant on their behalf, and with no key to send the
|
||||
* refusal is a console error on every visit besides.
|
||||
*
|
||||
* Module scope, not an effect: the ambient client is built on first use DURING
|
||||
* render, and this chunk is evaluated before any component runs.
|
||||
*/
|
||||
;(globalThis as { __HANZO_TELEMETRY__?: { enabled?: boolean } }).__HANZO_TELEMETRY__ = {
|
||||
enabled: false,
|
||||
}
|
||||
|
||||
/**
|
||||
* The mount.
|
||||
*
|
||||
* `<Hanzo>` IS the root: it mounts the gui provider with the config the
|
||||
* `$background` / `$ink` / `$borderColor` tokens resolve against. There is no
|
||||
* config file here to keep in step with the components — a second config is how
|
||||
* two surfaces of one product end up different sizes.
|
||||
*/
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<Hanzo theme="dark">
|
||||
<App />
|
||||
</Hanzo>
|
||||
</StrictMode>,
|
||||
)
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
// What the builder is showing: the open run, and the open project.
|
||||
//
|
||||
// OUTSIDE REACT, because the pane, the column beside it and the frame around
|
||||
// them all read it, and the router mounts and unmounts the components between.
|
||||
// A module is above every component, so a route change cannot destroy it. It
|
||||
// resets on a real page load, which is correct — a fresh document is a fresh
|
||||
// workspace.
|
||||
//
|
||||
// THE ADDRESS IS PART OF THE STORE, not a second copy of it kept in step by
|
||||
// whoever remembers. A selection you can link to is one a reload lands on and
|
||||
// one a reader can send to somebody; a selection only a component knows is
|
||||
// neither. So the address is seeded here at load and rewritten here on every
|
||||
// change, and no surface owns a synchronising effect.
|
||||
|
||||
import { useSyncExternalStore } from 'react'
|
||||
|
||||
export interface Open {
|
||||
/** The open run, by the session the platform narrates it in, or null. */
|
||||
session: string | null
|
||||
openSession: (id: string | null) => void
|
||||
/** The open project, by id, or null for the list of them. */
|
||||
project: string | null
|
||||
openProject: (id: string | null) => void
|
||||
}
|
||||
|
||||
interface Selection {
|
||||
session: string | null
|
||||
project: string | null
|
||||
}
|
||||
|
||||
/** The initial selection, and a stable reference: React compares snapshots by
|
||||
* identity, so a fresh object per read is an infinite render. */
|
||||
const EMPTY: Selection = { session: null, project: null }
|
||||
|
||||
const param = (name: string): string | null => {
|
||||
if (typeof window === 'undefined') return null
|
||||
try {
|
||||
return new URLSearchParams(window.location.search).get(name)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
let selection: Selection = { session: param('session'), project: param('project') }
|
||||
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
const subscribe = (listener: () => void): (() => void) => {
|
||||
listeners.add(listener)
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
/** Put the selection in the address, without a history entry: opening a project
|
||||
* is not a page a Back button should have to walk out of one field at a time. */
|
||||
function address(next: Selection): void {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
const url = new URL(window.location.href)
|
||||
for (const [name, value] of Object.entries(next)) {
|
||||
if (value) url.searchParams.set(name, value)
|
||||
else url.searchParams.delete(name)
|
||||
}
|
||||
window.history.replaceState(null, '', `${url.pathname}${url.search}${url.hash}`)
|
||||
} catch {
|
||||
/* an address this browser will not rewrite still leaves the store correct */
|
||||
}
|
||||
}
|
||||
|
||||
function set(next: Selection): void {
|
||||
if (next.session === selection.session && next.project === selection.project) return
|
||||
selection = next
|
||||
address(next)
|
||||
for (const listener of listeners) listener()
|
||||
}
|
||||
|
||||
/** Opens a run, or empties the pane with null. */
|
||||
export function openSession(id: string | null): void {
|
||||
set({ ...selection, session: id })
|
||||
}
|
||||
|
||||
/** Opens a project, or returns to the list of them with null. */
|
||||
export function openProject(id: string | null): void {
|
||||
set({ ...selection, project: id })
|
||||
}
|
||||
|
||||
export function useOpen(): Open {
|
||||
const current = useSyncExternalStore(
|
||||
subscribe,
|
||||
() => selection,
|
||||
() => EMPTY,
|
||||
)
|
||||
return {
|
||||
session: current.session,
|
||||
openSession,
|
||||
project: current.project,
|
||||
openProject,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Who this browser is, and the client that speaks for them — the layout route
|
||||
// every screen sits under.
|
||||
//
|
||||
// A LAYOUT ROUTE rather than a wrapper around the router, because the callback
|
||||
// screen reads the session and needs the router above it while the session
|
||||
// needs to be above the screen. Mounting this as the route every screen sits
|
||||
// under satisfies both with one tree instead of two.
|
||||
|
||||
import { IamProvider, useIam } from '@hanzo/iam/react'
|
||||
import { useEffect } from 'react'
|
||||
import { Outlet } from 'react-router'
|
||||
|
||||
import { Ai } from '~/ai'
|
||||
import { own, subject } from '~/token'
|
||||
|
||||
/**
|
||||
* WHERE THIS BROWSER SIGNS IN, derived once and read by everyone who needs it.
|
||||
*
|
||||
* The client is `hanzo-build`, this application's name under the estate's
|
||||
* `<org>-<app>` scheme, so the org is the name's first word rather than a
|
||||
* second table to forget to update. A code is bound to the client that asked
|
||||
* for it, so two derivations would be two clients and the exchange would be
|
||||
* refused outright.
|
||||
*/
|
||||
export function origin(): {
|
||||
serverUrl: string
|
||||
clientId: string
|
||||
redirectUri: string
|
||||
organization: string
|
||||
} {
|
||||
const clientId = import.meta.env.VITE_HANZO_CLIENT_ID || 'hanzo-build'
|
||||
return {
|
||||
serverUrl: (import.meta.env.VITE_HANZO_IAM || 'https://hanzo.id').replace(/\/+$/, ''),
|
||||
clientId,
|
||||
redirectUri: `${window.location.origin}/auth/callback`,
|
||||
organization: clientId.split('-')[0]!,
|
||||
}
|
||||
}
|
||||
|
||||
/** Local state follows the session: once the SDK has settled, whoever the
|
||||
* stored token names — or nobody — owns the `hanzo*` keys. */
|
||||
function Identity() {
|
||||
const { isLoading, isAuthenticated } = useIam()
|
||||
useEffect(() => {
|
||||
if (!isLoading) own(subject())
|
||||
}, [isLoading, isAuthenticated])
|
||||
return null
|
||||
}
|
||||
|
||||
export function Root() {
|
||||
// Before any surface reads storage: a browser another person left behind is
|
||||
// emptied of their selections.
|
||||
own(subject())
|
||||
return (
|
||||
<IamProvider config={{ ...origin(), postLogoutRedirectUri: `${window.location.origin}/` }}>
|
||||
<Identity />
|
||||
<Ai>
|
||||
<Outlet />
|
||||
</Ai>
|
||||
</IamProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { createBrowserRouter } from 'react-router'
|
||||
|
||||
import { Build } from '~/Build'
|
||||
import { Callback } from '~/callback'
|
||||
import { Frame } from '~/frame'
|
||||
import { Root } from '~/root'
|
||||
import { Session } from '~/Session'
|
||||
import { useOpen } from '~/open'
|
||||
|
||||
/**
|
||||
* The builder, in the two states it has.
|
||||
*
|
||||
* ONE SURFACE. The build pane is the empty state and an open run replaces it in
|
||||
* place — a run is not a second address, it is what this address is showing, and
|
||||
* `?session=` in the URL is what a reload and a shared link both land on.
|
||||
*
|
||||
* `key` on the run, so moving from one to another remounts rather than feeding a
|
||||
* second run's turns into the first one's transcript.
|
||||
*/
|
||||
function Builder() {
|
||||
const { session } = useOpen()
|
||||
return <Frame>{session ? <Session key={session} id={session} /> : <Build />}</Frame>
|
||||
}
|
||||
|
||||
/**
|
||||
* Every address this app answers, in one table.
|
||||
*
|
||||
* `/auth/callback` is where the issuer returns a browser, and it is the address
|
||||
* `origin()` registers, so it is a route rather than a state of the builder.
|
||||
*
|
||||
* Anything else is the builder. A demo with one surface has no second page to
|
||||
* send a stranger to, and forwarding an unknown address to another host would
|
||||
* make this repo depend on one.
|
||||
*/
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
element: <Root />,
|
||||
children: [
|
||||
{ path: '/auth/callback', element: <Callback /> },
|
||||
{ path: '*', element: <Builder /> },
|
||||
],
|
||||
},
|
||||
])
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Starting a coding run, and where it runs.
|
||||
*
|
||||
* The contract, read off the served document:
|
||||
* POST /v1/agents/coding → 202 {sessionId, repo, branch, routed, targetId}
|
||||
*
|
||||
* WHERE A RUN RUNS IS ONE FIELD. Omit `targetId` and the run goes to the
|
||||
* platform's own sandbox; name one and it is routed to a machine the org has
|
||||
* claimed. The answer comes back rather than being assumed: `routed` says
|
||||
* whether it reached a machine and `targetId` says which, so a caller that
|
||||
* asked for a machine and reads `routed: false` was given a sandbox and can say
|
||||
* so rather than draw a screen that claims otherwise.
|
||||
*
|
||||
* That is the whole of the browser-versus-desktop question. A browser has no
|
||||
* machine to offer, so it sends no target and gets a sandbox. Nothing here
|
||||
* sniffs a user agent to decide — the caller says what it has.
|
||||
*
|
||||
* A REFUSAL IS ANSWERED, NEVER SYNTHESISED. 401 is the credential and 403 is
|
||||
* permission, and neither is "try again", so neither says it. A run id this
|
||||
* module invented would open a pane of live controls over nothing.
|
||||
*/
|
||||
import { api } from '~/api'
|
||||
import { scope } from '~/token'
|
||||
|
||||
/** What a coding run answers with. 202 — accepted, not finished. */
|
||||
export interface Run {
|
||||
/** The session this run's activity hangs off. */
|
||||
session: string
|
||||
repo: string
|
||||
/** The branch the run works on; the platform picks one when `base` is empty. */
|
||||
branch: string
|
||||
/** Whether it reached a claimed machine. False means the sandbox took it. */
|
||||
routed: boolean
|
||||
/** Which machine, when one took it. Empty for a sandbox run. */
|
||||
target: string
|
||||
}
|
||||
|
||||
export interface Ask {
|
||||
/** The task, in the words you would use with a colleague. */
|
||||
prompt: string
|
||||
/** Model to route the coding agent through. */
|
||||
model?: string
|
||||
/** `owner/name` in the caller's own org. */
|
||||
repo?: string
|
||||
/** Which harness runs the prompt. */
|
||||
tool?: string
|
||||
/** The branch to start from. Empty takes the repository's default. */
|
||||
base?: string
|
||||
/** Route to a claimed machine. OMITTED IS THE SANDBOX. */
|
||||
targetId?: string
|
||||
}
|
||||
|
||||
export class Refusal extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
/** Starts one autonomous coding run and answers the session it opened. */
|
||||
export async function run(ask: Ask): Promise<Run> {
|
||||
const headers = new Headers({ 'Content-Type': 'application/json' })
|
||||
for (const [k, v] of Object.entries(scope())) headers.set(k, v)
|
||||
|
||||
const res = await fetch(`${api()}/v1/agents/coding`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(ask),
|
||||
// A run is a fact about right now, and a cached refusal survives the fix —
|
||||
// it reads as the platform still being broken long after it is not.
|
||||
cache: 'no-store',
|
||||
headers,
|
||||
})
|
||||
|
||||
if (res.status === 401)
|
||||
throw new Refusal(401, headers.has('Authorization') ? 'Your session expired — sign in again' : 'Sign in to start a run')
|
||||
if (res.status === 403) throw new Refusal(403, 'Your role cannot start runs in this organization')
|
||||
if (!res.ok) {
|
||||
let detail = ''
|
||||
try {
|
||||
const body = (await res.json()) as { error?: { message?: string }; msg?: string }
|
||||
detail = body?.error?.message || body?.msg || ''
|
||||
} catch {
|
||||
// A refusal with no JSON body is still a refusal; the status carries it.
|
||||
}
|
||||
throw new Refusal(res.status, detail || `Could not start the run (${res.status})`)
|
||||
}
|
||||
|
||||
const body = (await res.json()) as Partial<{ sessionId: string; repo: string; branch: string; routed: boolean; targetId: string }>
|
||||
if (!body.sessionId) {
|
||||
// Accepted with no session is not a run anybody can watch. Say so rather
|
||||
// than hand back a half-value the caller renders as success.
|
||||
throw new Refusal(502, 'The run was accepted but named no session')
|
||||
}
|
||||
return {
|
||||
session: body.sessionId,
|
||||
repo: body.repo ?? '',
|
||||
branch: body.branch ?? '',
|
||||
routed: body.routed ?? false,
|
||||
target: body.targetId ?? '',
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
// What to tell a reader when the platform does not answer.
|
||||
//
|
||||
// Printing the caught error reads "mount /v1/s3: no instance running", which
|
||||
// says nothing a reader can act on — it names a mount and an instance, neither
|
||||
// of which is theirs — and puts the shape of the estate on a public page. The
|
||||
// detail stays in the network tab, where the person who can act on it is
|
||||
// looking.
|
||||
//
|
||||
// ONE FUNCTION, because every pane owes the same three answers and three copies
|
||||
// would drift into three vocabularies for one event.
|
||||
|
||||
/**
|
||||
* THE STATUS, WHERE THE FAILURE CARRIES ONE.
|
||||
*
|
||||
* `@hanzo/ai`'s APIError publishes `status` as a number, and a number is a fact
|
||||
* — while the message beside it is prose the SDK is free to reword. Reading the
|
||||
* status first is what makes this survive the next rewording.
|
||||
*/
|
||||
const status = (e: unknown): number | null => {
|
||||
const s = (e as { status?: unknown } | null | undefined)?.status
|
||||
return typeof s === 'number' && Number.isFinite(s) ? s : null
|
||||
}
|
||||
|
||||
/** True where a failure is the platform being absent rather than the reader.
|
||||
* The text is still read, because a network failure raises before any status. */
|
||||
const absent = (text: string): boolean =>
|
||||
/no instance running|not running|service unavailable|503|econnrefused|failed to fetch|network/i.test(
|
||||
text,
|
||||
)
|
||||
|
||||
/**
|
||||
* True where the answer was no, whoever said it.
|
||||
*
|
||||
* Three vocabularies for one event: the gateway says 401 "authentication
|
||||
* required" on a turn and 403 "a validated principal is required" on a read,
|
||||
* and the SDK raises before sending at all when it has no credential to send
|
||||
* with. A reader meets the same wall in every case, so all three read as one.
|
||||
*/
|
||||
const barred = (text: string): boolean =>
|
||||
/401|403|forbidden|unauthori[sz]ed|not authori[sz]ed|authentication required|validated principal|permission|serve anonymous|anonymous completions/i.test(
|
||||
text,
|
||||
)
|
||||
|
||||
/** Whether the platform answered and the answer was no — exported so a surface
|
||||
* can offer the way past a refusal rather than only describing it. */
|
||||
export const refused = (error: unknown): boolean => {
|
||||
const code = status(error)
|
||||
if (code === 401 || code === 403) return true
|
||||
return barred(error instanceof Error ? error.message : String(error ?? ''))
|
||||
}
|
||||
|
||||
/**
|
||||
* A sentence about `subject` — "your projects", "this template" — naming what a
|
||||
* reader can do next. Never the caught text: an error written for an operator
|
||||
* is not an answer for a reader.
|
||||
*/
|
||||
export function say(error: unknown, subject: string, act: 'read' | 'save' = 'read'): string {
|
||||
const text = error instanceof Error ? error.message : String(error ?? '')
|
||||
const code = status(error)
|
||||
// VERB FIRST, so the subject's number never has to agree with anything. Said
|
||||
// subject-first, "your projects is not answering" is what a plural name gets,
|
||||
// and every caller then has to phrase its subject singular.
|
||||
if (code === 502 || code === 503 || code === 504 || absent(text))
|
||||
return `Could not reach ${subject}. Nothing has been lost — try again shortly.`
|
||||
if (code === 401 || code === 403 || barred(text))
|
||||
return act === 'save'
|
||||
? `This account cannot change ${subject}.`
|
||||
: `This account cannot open ${subject}.`
|
||||
return act === 'save' ? `Could not save ${subject}.` : `Could not read ${subject}.`
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// The projects this reader has starred.
|
||||
//
|
||||
// IT LIVES WITH THE READER, in this browser. Starring is a preference, not a
|
||||
// fact about the org: the platform publishes no starred field, and inventing a
|
||||
// server one would make one person's shortlist everybody's.
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
/** One store, one shortlist per kind, so two kinds never collide on an id that
|
||||
* happens to match. */
|
||||
const keyOf = (kind: string) => `hanzo.starred-${kind}`
|
||||
|
||||
const read = (kind: string): Set<string> => {
|
||||
try {
|
||||
return new Set(JSON.parse(localStorage.getItem(keyOf(kind)) || '[]') as string[])
|
||||
} catch {
|
||||
return new Set()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The starred ids, and the toggle that sets them.
|
||||
*
|
||||
* Read in an effect rather than in the initialiser, because storage is a fact
|
||||
* only the browser holds and reading it during the render that hydrates is how
|
||||
* the served markup and the client disagree about a filled star.
|
||||
*
|
||||
* Every hook instance listens for the change, so starring in one place lights
|
||||
* the star in another in the same tick — one set, no prop threaded between the
|
||||
* components that separate them.
|
||||
*/
|
||||
export function useStarred(kind = 'projects'): [Set<string>, (id: string) => void] {
|
||||
const [starred, setStarred] = useState<Set<string>>(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
setStarred(read(kind))
|
||||
const sync = () => setStarred(read(kind))
|
||||
window.addEventListener(keyOf(kind), sync)
|
||||
// `storage` is the OTHER tab. A reader with the app open twice should not
|
||||
// see two different shortlists.
|
||||
window.addEventListener('storage', sync)
|
||||
return () => {
|
||||
window.removeEventListener(keyOf(kind), sync)
|
||||
window.removeEventListener('storage', sync)
|
||||
}
|
||||
}, [kind])
|
||||
|
||||
const toggle = useCallback(
|
||||
(id: string) => {
|
||||
const next = read(kind)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
try {
|
||||
localStorage.setItem(keyOf(kind), JSON.stringify([...next]))
|
||||
} catch {
|
||||
/* a browser that refuses storage still gets the star for this session */
|
||||
}
|
||||
window.dispatchEvent(new Event(keyOf(kind)))
|
||||
},
|
||||
[kind],
|
||||
)
|
||||
|
||||
return [starred, toggle]
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// The tab strip, once.
|
||||
//
|
||||
// The strip is at least 48 tall and every tab at least 44 in both directions,
|
||||
// which is what a thumb needs, and it refuses to shrink — the strip scrolls
|
||||
// instead. A tab you can read and miss is worse than one you have to scroll to.
|
||||
// 48 is a MINIMUM and not a height, because a scrollbar is drawn INSIDE the box
|
||||
// it scrolls: on a platform with classic scrollbars a fixed 48 leaves the tabs
|
||||
// under the floor this file exists to hold.
|
||||
//
|
||||
// The chosen tab is underlined 2px, which outweighs the 1px divider the strip
|
||||
// draws under itself, and its weight does not change — a bolded label measures
|
||||
// wider than the same word unbolded, so every tab to its right would step
|
||||
// sideways on each switch.
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { Box, Text, XStack } from '@hanzo/ui'
|
||||
|
||||
/** One tab: what the code calls it, what the reader calls it. */
|
||||
export interface Tab<Id extends string = string> {
|
||||
id: Id
|
||||
label: string
|
||||
/** Before the label. A strip of plain words leaves it off. */
|
||||
icon?: LucideIcon
|
||||
}
|
||||
|
||||
/**
|
||||
* A row of tabs and the one you are on.
|
||||
*
|
||||
* `onPick` is OPTIONAL because a strip can have a single destination, and a tab
|
||||
* with nowhere else to go is not a control — drawn as a button it would take a
|
||||
* focus stop to say the thing the label already says. So without a handler
|
||||
* these are labels, and the moment there is a second place to be they become
|
||||
* buttons with no other change.
|
||||
*
|
||||
* `after` is what sits past the last tab.
|
||||
*/
|
||||
export function Tabs<Id extends string>({
|
||||
tabs,
|
||||
chosen,
|
||||
onPick,
|
||||
after,
|
||||
}: {
|
||||
// THE LIST DEFINES THE IDS and the other two are checked against it, which is
|
||||
// what `NoInfer` buys and why both carry it. Left inferring, `chosen` widens
|
||||
// `Id` by whatever it is passed — so a typo'd id becomes a legal extra tab
|
||||
// rather than an error — and a `setState` passed straight to `onPick` offers
|
||||
// `SetStateAction<Id>`, a union with a function in it, which is not a string
|
||||
// and collapses `Id` to its constraint. Either way the generic silently stops
|
||||
// checking anything.
|
||||
tabs: readonly Tab<Id>[]
|
||||
chosen: NoInfer<Id>
|
||||
onPick?: (id: NoInfer<Id>) => void
|
||||
after?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<XStack
|
||||
minH={48}
|
||||
shrink={0}
|
||||
items="center"
|
||||
px="$3"
|
||||
gap="$3"
|
||||
overflow="scroll"
|
||||
borderBottomWidth={1}
|
||||
borderColor="$borderColor"
|
||||
>
|
||||
{tabs.map((one) => {
|
||||
const on = one.id === chosen
|
||||
const Icon = one.icon
|
||||
return (
|
||||
<Box
|
||||
key={one.id}
|
||||
{...(onPick ? { render: 'button', onClick: () => onPick(one.id) } : {})}
|
||||
aria-current={on}
|
||||
// Stretched rather than padded, so the rule lands on the strip's own
|
||||
// edge and every tab underlines at the same height whether or not it
|
||||
// carries an icon.
|
||||
self="stretch"
|
||||
items="center"
|
||||
justify="center"
|
||||
shrink={0}
|
||||
minW={44}
|
||||
minH={44}
|
||||
borderBottomWidth={2}
|
||||
borderColor={on ? '$ink' : 'transparent'}
|
||||
>
|
||||
<XStack items="center" gap="$2">
|
||||
{Icon ? <Icon size={14} aria-hidden /> : null}
|
||||
<Text fontSize="$2" color={on ? '$ink' : '$soft'} numberOfLines={1}>
|
||||
{one.label}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
{after}
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* WHERE THE SESSION IS KEPT, named once.
|
||||
*
|
||||
* The @hanzo/iam SDK owns these keys and publishes no reader a plain module can
|
||||
* call — `getSession()` needs `configureIam()`, and this app configures through
|
||||
* `IamProvider` instead. So the names are restated here, in ONE file, and
|
||||
* everything that needs the bearer asks this. Two derivations of one key name
|
||||
* never agree, and the disagreement is silent: a read of the wrong name answers
|
||||
* null, which is indistinguishable from nobody being signed in.
|
||||
*
|
||||
* Every access is guarded, because storage THROWS rather than answering null in
|
||||
* a browser that refuses it, and a caller reaching for the bearer is usually
|
||||
* about to make a request — one that should fail as unauthenticated, not as an
|
||||
* exception.
|
||||
*/
|
||||
|
||||
/** The SDK's own keys, `hanzo_iam_`-prefixed. The far side of a boundary. */
|
||||
const PREFIX = 'hanzo_iam_'
|
||||
const ACCESS = `${PREFIX}access_token`
|
||||
|
||||
/** Who this browser's local state belongs to. */
|
||||
const WHO = 'hanzo:who'
|
||||
|
||||
/** The SDK's per-person selections. Everything else under its prefix is the
|
||||
* session's own machinery — tokens, the login in flight — and stays. */
|
||||
const CHOSEN = [`${PREFIX}current_org`, `${PREFIX}current_project`]
|
||||
|
||||
/** What a change of person leaves in place. */
|
||||
const kept = (key: string): boolean =>
|
||||
key === WHO || (key.startsWith(PREFIX) && !CHOSEN.includes(key))
|
||||
|
||||
/** The access token, or null. */
|
||||
export function bearer(): string | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
try {
|
||||
return window.localStorage.getItem(ACCESS)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** The claims the stored token carries, or null. */
|
||||
function claims(): Record<string, unknown> | null {
|
||||
const token = bearer()
|
||||
if (!token) return null
|
||||
try {
|
||||
const part = token.split('.')[1]
|
||||
if (!part) return null
|
||||
return JSON.parse(atob(part.replace(/-/g, '+').replace(/_/g, '/'))) as Record<string, unknown>
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The subject the stored access token names, or undefined.
|
||||
*
|
||||
* Read off the claim rather than waited for from userinfo, which is what makes
|
||||
* `own` runnable BEFORE anything else reads storage: the token is in place from
|
||||
* the first line of the first script. Expiry is not consulted — a stale token
|
||||
* still says whose browser this is, and the SDK either refreshes it or clears
|
||||
* it, at which point the answer changes here too.
|
||||
*/
|
||||
export function subject(): string | undefined {
|
||||
const sub = claims()?.sub
|
||||
return typeof sub === 'string' && sub ? sub : undefined
|
||||
}
|
||||
|
||||
/** Whether this browser holds a session. */
|
||||
export function hasSession(): boolean {
|
||||
if (typeof window === 'undefined') return false
|
||||
try {
|
||||
return Boolean(bearer() || window.localStorage.getItem(WHO))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind everything this app keeps in the browser to ONE person.
|
||||
*
|
||||
* The starred projects and the SDK's current org are written per browser and
|
||||
* would otherwise be read by whoever signs in next. The subject is recorded
|
||||
* beside them, and when a different subject arrives — or nobody, which is a
|
||||
* sign-out — the last person's keys go before anything reads them. The SDK's
|
||||
* `hanzo_iam_*` keys are the arriving session itself and stay.
|
||||
*
|
||||
* Same subject, no work. Called where the providers mount, so a page's first
|
||||
* read of storage already sees a browser that is the reader's.
|
||||
*/
|
||||
export function own(sub: string | undefined): void {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
const store = window.localStorage
|
||||
if (store.getItem(WHO) === (sub ?? null)) return
|
||||
for (const key of Object.keys(store))
|
||||
if (key.startsWith('hanzo') && !kept(key)) store.removeItem(key)
|
||||
if (sub) store.setItem(WHO, sub)
|
||||
else store.removeItem(WHO)
|
||||
} catch {
|
||||
/* a browser that refuses storage kept nothing to remove */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The organizations the token says this person belongs to, home first. Empty
|
||||
* for an account that has not made one: the SDK's own list falls back to the
|
||||
* `owner` claim, which is the application's org and nobody's to work in.
|
||||
*/
|
||||
export function orgs(): string[] {
|
||||
const set = claims()?.orgs
|
||||
const out: string[] = []
|
||||
for (const ref of Array.isArray(set) ? set : []) {
|
||||
const o = (ref as { org?: unknown } | null)?.org
|
||||
if (typeof o === 'string' && o && !out.includes(o)) out.push(o)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* The organization this browser works in: the SDK's selection, when it is one
|
||||
* of the token's. A sole organization is not a choice and is the answer from
|
||||
* the first render — waiting for a pick would build the client unscoped and
|
||||
* restart every read after first paint.
|
||||
*/
|
||||
export function org(): string | null {
|
||||
try {
|
||||
const mine = orgs()
|
||||
const chosen = window.localStorage.getItem(`${PREFIX}current_org`)
|
||||
if (chosen && mine.includes(chosen)) return chosen
|
||||
return mine.length === 1 ? mine[0]! : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** The headers that scope a request to the signed-in person and their org. */
|
||||
export function scope(): Record<string, string> {
|
||||
const h: Record<string, string> = {}
|
||||
const t = bearer()
|
||||
if (t) h.Authorization = `Bearer ${t}`
|
||||
const o = org()
|
||||
if (o) h['X-Org-Id'] = o
|
||||
return h
|
||||
}
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '@hanzo/font/css'
|
||||
declare module '*.css'
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"types": ["node", "vite/client"],
|
||||
"strict": true,
|
||||
"isolatedModules": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"paths": {
|
||||
"~/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { hanzo } from '@hanzo/vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
/**
|
||||
* The bundler. Resolving the Hanzo runtime — `~/…`, react-native-web, web-first
|
||||
* extensions, one copy of gui — comes from `hanzo()`, stated once for every
|
||||
* Hanzo app. What is left is this app's own.
|
||||
*
|
||||
* There is no CSS pipeline, and none is needed: @hanzo/ui ships one generated
|
||||
* stylesheet that `<Hanzo>` imports, and gui inserts the rules for props a
|
||||
* package could not have known about at first render.
|
||||
*/
|
||||
const API = 'https://api.hanzo.ai'
|
||||
|
||||
/**
|
||||
* Same-origin in front of the gateway, on the dev server and on the preview of
|
||||
* a build. api.hanzo.ai admits an origin by allowlist and by an https, portless
|
||||
* DNS proof; a localhost port satisfies neither, so a credentialed read to the
|
||||
* absolute address fails its preflight and the builder draws as though the
|
||||
* catalog and the org were empty. One statement, both servers — a proxy that
|
||||
* exists only under `vite dev` means the built output is never exercised
|
||||
* against real data before it ships.
|
||||
*/
|
||||
const proxy = { '/v1': { target: API, changeOrigin: true } }
|
||||
|
||||
/**
|
||||
* A single-page app has one document and many addresses, so every address that
|
||||
* is not a file has to arrive at that document. `ghcr.io/hanzoai/spa` answers
|
||||
* index.html for any route; a static plane resolves an object key and otherwise
|
||||
* answers the site's own `404.html`, so emitting the document under that name
|
||||
* is the whole fix and every static host already implements it.
|
||||
*
|
||||
* The status stays 404: the browser runs the bundle regardless and the router
|
||||
* takes the address from there, while a crawler reads "not a page I publish",
|
||||
* which is true of every address the router invents.
|
||||
*/
|
||||
const spaFallback = {
|
||||
name: 'spa-fallback',
|
||||
async writeBundle(options: { dir?: string }) {
|
||||
const { copyFile } = await import('node:fs/promises')
|
||||
const { join } = await import('node:path')
|
||||
const dir = options.dir ?? 'dist'
|
||||
await copyFile(join(dir, 'index.html'), join(dir, '404.html'))
|
||||
},
|
||||
}
|
||||
|
||||
const config = hanzo(
|
||||
{
|
||||
plugins: [react(), spaFallback],
|
||||
server: { port: 3200, allowedHosts: true, proxy },
|
||||
preview: { port: 3200, allowedHosts: true, proxy },
|
||||
},
|
||||
{
|
||||
root: import.meta.dirname,
|
||||
// ONE COPY OF THE THEME. `hanzo()` dedupes `@hanzo/gui` and `@hanzo/ui`, but
|
||||
// the theme and config contexts live a layer below them in `@hanzogui/core`.
|
||||
// A component rendered under one context reading the other throws
|
||||
// "Missing theme."
|
||||
dedupe: ['@hanzogui/core', '@hanzogui/web', '@hanzogui/portal', '@hanzogui/toast'],
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* Dependency optimization is a SECOND resolution pass and inherits none of the
|
||||
* first, so it is told the same thing: on the web, a react-native package's
|
||||
* `.web.js` sibling comes first. Left out, the optimizer follows react-native
|
||||
* into its Flow source and the dev server dies at startup, while `vite build`
|
||||
* uses the resolver above and succeeds.
|
||||
*
|
||||
* The list is read back from the config rather than restated.
|
||||
*/
|
||||
export default {
|
||||
...config,
|
||||
optimizeDeps: {
|
||||
rollupOptions: {
|
||||
resolve: { extensions: (config.resolve as { extensions: string[] }).extensions },
|
||||
},
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user