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:
2026-09-08 12:27:59 -07:00
co-authored by Hanzo Dev
commit 1b35cea953
34 changed files with 10487 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules
dist
*.tsbuildinfo
+28
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+23
View File
@@ -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
+87
View File
@@ -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`.
+32
View File
@@ -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
View File
@@ -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>
+42
View File
@@ -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:"
}
}
+6822
View File
File diff suppressed because it is too large Load Diff
+49
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+434
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
)
+82
View File
@@ -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;
}
+48
View File
@@ -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>
)
}
+70
View File
@@ -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>
)
}
+25
View File
@@ -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
View File
@@ -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>
)
}
+51
View File
@@ -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
View File
@@ -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,
}
}
+62
View File
@@ -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>
)
}
+43
View File
@@ -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
View File
@@ -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
View File
@@ -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}.`
}
+64
View File
@@ -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]
}
+99
View File
@@ -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
View File
@@ -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
}
+4
View File
@@ -0,0 +1,4 @@
/// <reference types="vite/client" />
declare module '@hanzo/font/css'
declare module '*.css'
+20
View File
@@ -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"]
}
+79
View File
@@ -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 },
},
},
}