mirror of
https://github.com/hanzoai/build.git
synced 2026-10-23 02:18:08 +00:00
Port the v2 workspace onto the builder and retire the v3 shell
The project address now opens the v2 editor's workspace in the same window: runs as a conversation with Build/Plan, the deployed page framed, the repository at the run's branch as a tree and as read-only code, picked elements as Layers, the run's console as a dock, Share and Publish. Publish and a repo row's Add to project are one dialog over /v1/platform/apps: a review on a branch for everyone, main for an org admin once the build is green. A verdict on a run lands on the event bus. The GitHub client follows the contract the platform published: default-first branches with their commit, the person's own connection, connect answering authorizeUrl. v3's frame, tabs, open store and @hanzo/ai client are gone; links out go through one Out. Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
@@ -10,14 +10,28 @@ 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).
|
||||
(github.com/hanzoai/app). Its project workspace (src/project.tsx) is a port
|
||||
of the Hanzo App v2 editor (github.com/hanzoai/build-v2), which is derived
|
||||
from OSW Studio by Otto Stahl (github.com/o-stahl/osw-studio, MIT, Copyright
|
||||
(c) 2025 Otto Stahl), itself derived from DeepSite v2 by enzostvs and the
|
||||
DeepSite authors (huggingface.co/spaces/enzostvs/deepsite, MIT). Both are
|
||||
used under the MIT License; their notices are reproduced here:
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
@hanzogui/lucide-icons-2 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
|
||||
|
||||
@@ -3,97 +3,118 @@
|
||||
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.
|
||||
The builder is one component with two hosts: this page (hanzo.build), and the
|
||||
platform console at platform.hanzo.ai/dev. Both mount `<Builder host={…} />`
|
||||
from `@hanzo/build`; neither forks it.
|
||||
|
||||
```
|
||||
pnpm install
|
||||
pnpm dev # http://localhost:3200
|
||||
pnpm test # the transport contract and the address grammar
|
||||
pnpm build:lib # lib/ — what npm ships
|
||||
```
|
||||
|
||||
The port is not a preference. `vite.config.ts` pins 3200 for both `dev` and
|
||||
`preview`, and IAM lists `http://localhost:3200/auth/callback` among this
|
||||
client's redirects — sign in from any other port and the authorize call is
|
||||
refused, because an unlisted redirect is the one thing OAuth must never accept.
|
||||
refused.
|
||||
|
||||
`/v1` is proxied to `api.hanzo.ai` from the dev server AND from the preview of a
|
||||
build. The gateway admits an origin by allowlist and by an https, portless DNS
|
||||
proof, and 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. Same statement for both servers, so the built output is
|
||||
exercised against real data before it ships.
|
||||
`/v1` is proxied to `api.hanzo.ai` from the dev server and from the preview of
|
||||
a build: the gateway admits an origin by allowlist and a localhost port is not
|
||||
on it.
|
||||
|
||||
## Mounting it
|
||||
|
||||
```tsx
|
||||
import { Builder, type Host } from '@hanzo/build'
|
||||
|
||||
<Hanzo theme="dark">
|
||||
<div style={{ height: '100dvh', display: 'flex' }}>
|
||||
<Builder host={host} />
|
||||
</div>
|
||||
</Hanzo>
|
||||
```
|
||||
|
||||
`Host` is everything that differs between hosts: where the platform is
|
||||
(`api`), the bearer (`token()`, read at call time), the org (`org`), who is
|
||||
signed in (`person`), whether they administer the org (`admin`), the address
|
||||
under the mount (`path`) and how to move (`go`), and where to link out
|
||||
(`links`, `open`). Peers: `@hanzo/ui`, `@hanzo/gui`, `@hanzogui/lucide-icons-2`,
|
||||
`react`.
|
||||
|
||||
## Addresses
|
||||
|
||||
| path | screen |
|
||||
|---|---|
|
||||
| `''` | New — the empty state and the composer |
|
||||
| `sess_<32 hex>` | one run, live |
|
||||
| `-/artifacts` | what the org has built |
|
||||
| `-/templates` | the public starters |
|
||||
| `<slug>` | a project's workspace |
|
||||
|
||||
A project slug holds no `_` and never starts with `-`, so the forms cannot
|
||||
collide.
|
||||
|
||||
## 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 rail.** New, Artifacts, Customize (the platform's plugins), More
|
||||
(Templates, Machines, Docs), then the org's coding runs newest first with a
|
||||
live status dot, and the account. Collapse is an explicit toggle kept in this
|
||||
browser.
|
||||
|
||||
**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.
|
||||
**New.** "What's up next?", and at the foot the composer: where the run runs
|
||||
(Default is the platform's sandbox; the org's machines follow), the repository
|
||||
and the branch — both searchable, paged popovers opening upward — then the
|
||||
ask. Under it: attach (files ride the prompt as text), dictate, Build or Plan,
|
||||
and the model and effort. A repository row can be added to a project.
|
||||
|
||||
**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 run.** The transcript as it streams, steering while it works, Stop, and
|
||||
the pull request once it pushes one.
|
||||
|
||||
**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.
|
||||
**A project.** The v2 workspace in the same window: its runs as a
|
||||
conversation with suggestions and a Build/Plan composer on the left; Preview,
|
||||
Files, Code and Layers on the right with desktop/mobile, reload, the page
|
||||
picker and open-in-tab; Share and Publish; the console dock under it.
|
||||
|
||||
## Configuration
|
||||
## The contract
|
||||
|
||||
Everything has a working default. Each is a Vite `VITE_` variable, read at build
|
||||
time.
|
||||
|
||||
| | |
|
||||
| call | what |
|
||||
|---|---|
|
||||
| `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. |
|
||||
| `POST /v1/agent/coding` | start a run → 202 `{sessionId, …}` |
|
||||
| `GET /v1/agent/sessions?kind=coding` | Recents, and a project's runs |
|
||||
| `GET /v1/agent/sessions/{id}` · `GET /v1/agent/sessions/stream?root=` | a run, and its live feed (SSE over fetch) |
|
||||
| `POST /v1/agent/sessions/{id}/message` · `/stop` | steer, stop |
|
||||
| `GET /v1/agent/targets` | the org's machines |
|
||||
| `GET /v1/provider/github/repos` · `…/{owner}/{repo}/branches` | the chips' pagers |
|
||||
| `POST /v1/provider/github/user/connect` | connect a person's GitHub |
|
||||
| `GET /v1/projects` · `POST /v1/projects/fork` · `GET /v1/templates` | artifacts, templates |
|
||||
| `GET /v1/git/repos/{name}/tree` · `/blob` | Files and Code |
|
||||
| `POST /v1/platform/apps` · `GET /v1/platform/builds` | Add to project, Publish |
|
||||
| `GET /v1/models` · `POST /v1/event` | the model list, a verdict |
|
||||
|
||||
**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.
|
||||
`mode`, `model` and `effort` are sent with a run as asked; the platform
|
||||
honours them where it does and nothing here simulates them.
|
||||
|
||||
## 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
|
||||
index.ts the library surface
|
||||
builder.tsx the rail and the pane the address names
|
||||
landing.tsx New
|
||||
run.tsx one run
|
||||
project.tsx a project's workspace
|
||||
shelf.tsx Artifacts and Templates
|
||||
publish.tsx Add to project / Publish
|
||||
account.tsx the account and find-a-run dialogs
|
||||
data.ts reads as hooks
|
||||
host.tsx what a host provides
|
||||
route.ts the address grammar
|
||||
api/ one typed client per surface, over one call()
|
||||
app/ this page's host: IAM, the router, the mount
|
||||
```
|
||||
|
||||
## Licence
|
||||
|
||||
Apache-2.0 OR MIT, at your option. See `NOTICE`.
|
||||
Apache-2.0 OR MIT, at your option. See `NOTICE` — the project workspace is a
|
||||
port of the v2 editor, derived from OSW Studio and DeepSite (MIT).
|
||||
|
||||
Generated
+18
-50
@@ -6,9 +6,6 @@ settings:
|
||||
|
||||
catalogs:
|
||||
default:
|
||||
'@hanzo/ai':
|
||||
specifier: 0.6.16
|
||||
version: 0.6.16
|
||||
'@hanzo/font':
|
||||
specifier: 1.9.3
|
||||
version: 1.9.3
|
||||
@@ -33,9 +30,6 @@ catalogs:
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^6.1.1
|
||||
version: 6.1.1
|
||||
lucide-react:
|
||||
specifier: ^1.42.0
|
||||
version: 1.43.0
|
||||
next-themes:
|
||||
specifier: ^0.4.6
|
||||
version: 0.4.6
|
||||
@@ -80,10 +74,7 @@ overrides:
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@hanzo/ai':
|
||||
specifier: 'catalog:'
|
||||
version: 0.6.16([email protected])
|
||||
devDependencies:
|
||||
'@hanzo/font':
|
||||
specifier: 'catalog:'
|
||||
version: 1.9.3([email protected](@babel/[email protected]([email protected]))(@types/[email protected])([email protected]([email protected]))([email protected]))
|
||||
@@ -96,12 +87,27 @@ importers:
|
||||
'@hanzo/ui':
|
||||
specifier: 'catalog:'
|
||||
version: 8.27.12(d472dd43e9076e25e350e015862ee38b)
|
||||
'@hanzo/vite':
|
||||
specifier: 'catalog:'
|
||||
version: 0.1.4([email protected])([email protected](@types/[email protected])([email protected])([email protected]))
|
||||
'@hanzogui/lucide-icons-2':
|
||||
specifier: 8.3.5
|
||||
version: 8.3.5([email protected]([email protected]))([email protected]([email protected](@babel/[email protected]([email protected]))(@react-native/[email protected](@babel/[email protected]([email protected]))([email protected]))(@types/[email protected])([email protected])([email protected]))([email protected]))([email protected])
|
||||
'@hanzogui/next-theme':
|
||||
specifier: 8.3.5
|
||||
version: 8.3.5([email protected](@babel/[email protected]([email protected]))(@types/[email protected])([email protected]([email protected]))([email protected]))([email protected])
|
||||
lucide-react:
|
||||
'@types/node':
|
||||
specifier: 'catalog:'
|
||||
version: 1.43.0([email protected])
|
||||
version: 26.5.0
|
||||
'@types/react':
|
||||
specifier: 'catalog:'
|
||||
version: 19.2.18
|
||||
'@types/react-dom':
|
||||
specifier: 'catalog:'
|
||||
version: 19.2.7(@types/[email protected])
|
||||
'@vitejs/plugin-react':
|
||||
specifier: 'catalog:'
|
||||
version: 6.1.1([email protected](@types/[email protected])([email protected])([email protected]))
|
||||
next-themes:
|
||||
specifier: 'catalog:'
|
||||
version: 0.4.6([email protected]([email protected]))([email protected])
|
||||
@@ -120,22 +126,6 @@ importers:
|
||||
react-router:
|
||||
specifier: 'catalog:'
|
||||
version: 8.3.1([email protected]([email protected]))([email protected])
|
||||
devDependencies:
|
||||
'@hanzo/vite':
|
||||
specifier: 'catalog:'
|
||||
version: 0.1.4([email protected])([email protected](@types/[email protected])([email protected])([email protected]))
|
||||
'@types/node':
|
||||
specifier: 'catalog:'
|
||||
version: 26.5.0
|
||||
'@types/react':
|
||||
specifier: 'catalog:'
|
||||
version: 19.2.18
|
||||
'@types/react-dom':
|
||||
specifier: 'catalog:'
|
||||
version: 19.2.7(@types/[email protected])
|
||||
'@vitejs/plugin-react':
|
||||
specifier: 'catalog:'
|
||||
version: 6.1.1([email protected](@types/[email protected])([email protected])([email protected]))
|
||||
typescript:
|
||||
specifier: 'catalog:'
|
||||
version: 7.0.2
|
||||
@@ -659,15 +649,6 @@ packages:
|
||||
'@floating-ui/[email protected]':
|
||||
resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==}
|
||||
|
||||
'@hanzo/[email protected]':
|
||||
resolution: {integrity: sha512-MgnRooi4D+3n1YnJ9BDmYBB15noscN+nhRerYrGm/zpI5jC002Il1jj53uNIRhVX736/fOxFJjErlpnKjy4C1w==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
react: '>=18'
|
||||
peerDependenciesMeta:
|
||||
react:
|
||||
optional: true
|
||||
|
||||
'@hanzo/[email protected]':
|
||||
resolution: {integrity: sha512-HsH2wT82EbjHF1zY/TWubUQv7N5tBolVyBtXuQD8I+3E7xQ+Qoy7u79Xz1lw0EVkBESM2dr6VMaPWWThnytzZg==}
|
||||
peerDependencies:
|
||||
@@ -2552,11 +2533,6 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-ubtnda1fVK5ky0PNEpOmB0wiwhpZUyJiol4K14KCm+QKvuCkfUu54Et/rIjyupJpwiJ5Hg+BLQE86/GEsS+IgQ==}
|
||||
peerDependencies:
|
||||
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
@@ -3843,10 +3819,6 @@ snapshots:
|
||||
|
||||
'@floating-ui/[email protected]': {}
|
||||
|
||||
'@hanzo/[email protected]([email protected])':
|
||||
optionalDependencies:
|
||||
react: 19.2.8
|
||||
|
||||
'@hanzo/[email protected]([email protected])':
|
||||
dependencies:
|
||||
'@hanzo/design': 0.5.24
|
||||
@@ -6210,10 +6182,6 @@ snapshots:
|
||||
dependencies:
|
||||
yallist: 3.1.1
|
||||
|
||||
[email protected]([email protected]):
|
||||
dependencies:
|
||||
react: 19.2.8
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.6.0
|
||||
|
||||
-1338
File diff suppressed because it is too large
Load Diff
-434
@@ -1,434 +0,0 @@
|
||||
// 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
@@ -1,60 +0,0 @@
|
||||
// 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
@@ -1,38 +0,0 @@
|
||||
/**
|
||||
* 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(/\/+$/, '')
|
||||
}
|
||||
+32
-16
@@ -74,7 +74,7 @@ describe('call', () => {
|
||||
|
||||
it('names the status when the refusal says nothing', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response('<html>', { status: 502 })))
|
||||
const err = await call(T, 'GET', '/v1/agent/sessions?limit=1').catch((e) => e)
|
||||
const err = (await call(T, "GET", "/v1/agent/sessions?limit=1").catch((e: unknown) => e)) as Error
|
||||
expect(err).toBeInstanceOf(Refusal)
|
||||
expect(err.message).toBe('GET /v1/agent/sessions answered 502')
|
||||
})
|
||||
@@ -91,7 +91,7 @@ describe('github', () => {
|
||||
repos: [{ owner: 'hanzo-inc', name: 'cloud', full_name: 'hanzo-inc/cloud', private: true, default_branch: 'main', pushed_at: '2026-09-24T00:00:00Z', installation_id: 7 }],
|
||||
next: 'c2',
|
||||
total: 180,
|
||||
unread: 1,
|
||||
unread: ['hanzo-labs'],
|
||||
connected: true,
|
||||
})
|
||||
const page = await github.repos(T, { q: ' clo ', after: 'c1' })
|
||||
@@ -100,7 +100,7 @@ describe('github', () => {
|
||||
repos: [{ owner: 'hanzo-inc', name: 'cloud', full_name: 'hanzo-inc/cloud', private: true, default_branch: 'main', pushed_at: '2026-09-24T00:00:00Z', installation_id: 7 }],
|
||||
next: 'c2',
|
||||
total: 180,
|
||||
unread: 1,
|
||||
unread: ['hanzo-labs'],
|
||||
connected: true,
|
||||
})
|
||||
})
|
||||
@@ -109,30 +109,32 @@ describe('github', () => {
|
||||
answer(200, { repos: [{ owner: 'a', name: 'b' }, { owner: 'x' }], connected: false })
|
||||
const page = await github.repos(T)
|
||||
expect(page.repos).toEqual([{ owner: 'a', name: 'b', full_name: 'a/b', private: false, default_branch: '', pushed_at: '', installation_id: 0 }])
|
||||
expect(page).toMatchObject({ next: '', total: 0, unread: 0, connected: false })
|
||||
expect(page).toMatchObject({ next: '', total: 0, unread: [], connected: false })
|
||||
})
|
||||
|
||||
it('escapes owner and name in the branches address', async () => {
|
||||
const seen = answer(200, { branches: [{ name: 'main', protected: true, sha: 'abc' }, { name: '' }], next: '' })
|
||||
const seen = answer(200, { branches: [{ name: 'main', commit: '9f2c1e7a', default: true }, { name: '' }], next: '', total: 1 })
|
||||
const page = await github.branches(T, 'hanzo inc', 'cl/oud', { q: 'fe', limit: 20 })
|
||||
expect(seen[0].url).toBe('https://api.hanzo.ai/v1/provider/github/repos/hanzo%20inc/cl%2Foud/branches?q=fe&limit=20')
|
||||
expect(page.branches).toEqual([{ name: 'main', protected: true, sha: 'abc' }])
|
||||
expect(page).toEqual({ branches: [{ name: 'main', commit: '9f2c1e7a', default: true }], next: '', total: 1 })
|
||||
})
|
||||
|
||||
it('connects only through a GitHub address', async () => {
|
||||
const seen = answer(200, { url: 'https://github.com/login/oauth/authorize?x=1' })
|
||||
expect(await github.connect(T, 'https://platform.hanzo.ai/dev')).toBe('https://github.com/login/oauth/authorize?x=1')
|
||||
expect(seen[0]).toMatchObject({ method: 'POST', url: 'https://api.hanzo.ai/v1/provider/github/user/connect', body: { redirect: 'https://platform.hanzo.ai/dev' } })
|
||||
answer(200, { url: 'javascript:alert(1)' })
|
||||
await expect(github.connect(T, 'x')).rejects.toThrow('did not name a GitHub address')
|
||||
const seen = answer(200, { authorizeUrl: 'https://github.com/login/oauth/authorize?client_id=x&state=y' })
|
||||
expect(await github.connect(T)).toBe('https://github.com/login/oauth/authorize?client_id=x&state=y')
|
||||
expect(seen[0]).toMatchObject({ method: 'POST', url: 'https://api.hanzo.ai/v1/provider/github/user/connect', body: undefined })
|
||||
answer(200, { authorizeUrl: 'javascript:alert(1)' })
|
||||
await expect(github.connect(T)).rejects.toThrow('did not name a GitHub address')
|
||||
answer(200, { authorizeUrl: 'https://github.com.evil.example/x' })
|
||||
await expect(github.connect(T)).rejects.toThrow('did not name a GitHub address')
|
||||
})
|
||||
|
||||
it('reads the connection and disconnects', async () => {
|
||||
answer(200, { connected: true, login: 'zeekay' })
|
||||
expect(await github.connection(T)).toEqual({ connected: true, login: 'zeekay' })
|
||||
const seen = answer(204, undefined)
|
||||
answer(200, { configured: true, connected: true, login: 'zeekay', connectedAt: '2026-09-24T12:00:00Z' })
|
||||
expect(await github.connection(T)).toEqual({ configured: true, connected: true, login: 'zeekay' })
|
||||
const seen = answer(200, { disconnected: true })
|
||||
await github.disconnect(T)
|
||||
expect(seen[0]).toMatchObject({ method: 'DELETE', url: 'https://api.hanzo.ai/v1/provider/github/user' })
|
||||
expect(seen[0]).toMatchObject({ method: 'POST', url: 'https://api.hanzo.ai/v1/provider/github/user/disconnect' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -272,7 +274,6 @@ describe('models', () => {
|
||||
it('lists the catalog with the router first and batch lanes dropped', async () => {
|
||||
const seen = answer(200, { data: [{ id: 'zen5-coder' }, { id: 'anthropic/claude-opus-5.5' }, { id: 'anthropic/claude-opus-5.5:batch' }, { id: 'enso' }] })
|
||||
const { models } = await import('./models.ts')
|
||||
expect(seen).toHaveLength(0)
|
||||
const list = await models(T)
|
||||
expect(seen[0].url).toBe('https://api.hanzo.ai/v1/models')
|
||||
expect(list).toEqual([
|
||||
@@ -282,3 +283,18 @@ describe('models', () => {
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('verdict', () => {
|
||||
it('records a verdict on the event bus and says when it did not land', async () => {
|
||||
const seen = answer(200, { accepted: 1, dropped: 0 })
|
||||
const { verdict } = await import('./verdict.ts')
|
||||
await verdict(T, 'sess_1', 'shop', 'up')
|
||||
expect(seen[0]).toMatchObject({
|
||||
method: 'POST',
|
||||
url: 'https://api.hanzo.ai/v1/event',
|
||||
body: { type: 'track', event: 'build.verdict', sessionId: 'sess_1', properties: { verdict: 'up', project: 'shop' } },
|
||||
})
|
||||
answer(200, { accepted: 0, dropped: 1 })
|
||||
await expect(verdict(T, 'sess_1', 'shop', 'down')).rejects.toThrow('not recorded')
|
||||
})
|
||||
})
|
||||
|
||||
+31
-22
@@ -1,15 +1,20 @@
|
||||
/**
|
||||
* GitHub, as the platform's GitHub App sees it for the signed-in person.
|
||||
*
|
||||
* GET /v1/provider/github/repos?q=&owner=&limit=&after=
|
||||
* GET /v1/provider/github/repos/{owner}/{repo}/branches?q=&limit=&after=
|
||||
* GET /v1/provider/github/user the person's own connection
|
||||
* POST /v1/provider/github/user/connect → {url} to authorize at GitHub
|
||||
* DELETE /v1/provider/github/user disconnect
|
||||
* GET /v1/provider/github/repos?q=&owner=&limit=&after= most recently pushed first
|
||||
* GET /v1/provider/github/repos/{owner}/{repo}/branches?q=&limit=&after= default first
|
||||
* GET /v1/provider/github/user the person's own connection
|
||||
* POST /v1/provider/github/user/connect → {authorizeUrl}
|
||||
* POST /v1/provider/github/user/disconnect
|
||||
*
|
||||
* Paged by an opaque `after` cursor; `next` is the cursor for the page after
|
||||
* this one, or empty when there is none. Rows are read defensively: a field the
|
||||
* this one, or empty on the last. Rows are read defensively: a field the
|
||||
* platform left out is an empty value here, never `undefined` in a label.
|
||||
*
|
||||
* `connected` false with repositories is an org admin seeing the org's
|
||||
* installations; false with none is the cue to connect. GitHub returns a
|
||||
* connecting person to the console's /connectors?complete=github&grant=<id>,
|
||||
* which completes it.
|
||||
*/
|
||||
import { call, query, seg, type Target } from './call.ts'
|
||||
|
||||
@@ -29,27 +34,30 @@ export interface Repo {
|
||||
export interface Repos {
|
||||
repos: Repo[]
|
||||
next: string
|
||||
/** Every repository the grant reaches, when the platform counted them. */
|
||||
/** How many repositories match, across every page. */
|
||||
total: number
|
||||
/** How many of those this page could not list (unreadable installations). */
|
||||
unread: number
|
||||
/** Whether the person has a GitHub connection at all. */
|
||||
/** The installations that could not be read: a short list is then not the whole one. */
|
||||
unread: string[]
|
||||
/** Whether this answer is the person's OWN GitHub. */
|
||||
connected: boolean
|
||||
}
|
||||
|
||||
export interface Branch {
|
||||
name: string
|
||||
protected: boolean
|
||||
/** The tip, or ''. */
|
||||
sha: string
|
||||
/** The sha it points at, or ''. */
|
||||
commit: string
|
||||
default: boolean
|
||||
}
|
||||
|
||||
export interface Branches {
|
||||
branches: Branch[]
|
||||
next: string
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface Connection {
|
||||
/** Whether this deployment can connect anyone at all. */
|
||||
configured: boolean
|
||||
connected: boolean
|
||||
/** The GitHub login, when connected. */
|
||||
login: string
|
||||
@@ -77,7 +85,7 @@ export function repo(raw: unknown): Repo {
|
||||
|
||||
export function branch(raw: unknown): Branch {
|
||||
const b = obj(raw)
|
||||
return { name: str(b.name), protected: b.protected === true, sha: str(b.sha) }
|
||||
return { name: str(b.name), commit: str(b.commit), default: b.default === true }
|
||||
}
|
||||
|
||||
export interface RepoQuery {
|
||||
@@ -101,8 +109,8 @@ export async function repos(t: Target, p: RepoQuery = {}, signal?: AbortSignal):
|
||||
repos: (Array.isArray(raw.repos) ? raw.repos : []).map(repo).filter((r) => r.name),
|
||||
next: str(raw.next),
|
||||
total: num(raw.total),
|
||||
unread: num(raw.unread),
|
||||
connected: raw.connected !== false,
|
||||
unread: (Array.isArray(raw.unread) ? raw.unread : []).filter((x): x is string => typeof x === 'string'),
|
||||
connected: raw.connected === true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,22 +139,23 @@ export async function branches(
|
||||
return {
|
||||
branches: (Array.isArray(raw.branches) ? raw.branches : []).map(branch).filter((b) => b.name),
|
||||
next: str(raw.next),
|
||||
total: num(raw.total),
|
||||
}
|
||||
}
|
||||
|
||||
export async function connection(t: Target): Promise<Connection> {
|
||||
const raw = obj(await call<unknown>(t, 'GET', '/v1/provider/github/user'))
|
||||
return { connected: raw.connected === true, login: str(raw.login) }
|
||||
return { configured: raw.configured === true, connected: raw.connected === true, login: str(raw.login) }
|
||||
}
|
||||
|
||||
/** Where to send the person to authorize the platform at GitHub. */
|
||||
export async function connect(t: Target, back: string): Promise<string> {
|
||||
const raw = obj(await call<unknown>(t, 'POST', '/v1/provider/github/user/connect', { redirect: back }))
|
||||
const url = str(raw.url)
|
||||
/** Where to send the person to authorize the platform's GitHub App — github.com only. */
|
||||
export async function connect(t: Target): Promise<string> {
|
||||
const raw = obj(await call<unknown>(t, 'POST', '/v1/provider/github/user/connect'))
|
||||
const url = str(raw.authorizeUrl)
|
||||
if (!/^https:\/\/github\.com\//.test(url)) throw new Error('The platform did not name a GitHub address to connect at')
|
||||
return url
|
||||
}
|
||||
|
||||
export async function disconnect(t: Target): Promise<void> {
|
||||
await call<unknown>(t, 'DELETE', '/v1/provider/github/user')
|
||||
await call<unknown>(t, 'POST', '/v1/provider/github/user/disconnect')
|
||||
}
|
||||
|
||||
@@ -107,3 +107,11 @@ describe('turns', () => {
|
||||
expect(merge(a, b).map((e) => e.seq)).toEqual([1, 2, 3])
|
||||
})
|
||||
})
|
||||
|
||||
describe('who', () => {
|
||||
it('shortens a subject to its org and first block', async () => {
|
||||
const { who } = await import('./turn.ts')
|
||||
expect(who('hanzo/2d4d67ab-30f1-474e-b81f-f60461852259')).toBe('hanzo/2d4d67ab')
|
||||
expect(who('agent')).toBe('agent')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -118,3 +118,10 @@ export function merge<T extends Pick<Event, 'id' | 'seq'>>(...lists: T[][]): T[]
|
||||
for (const list of lists) for (const e of list) seen.set(e.seq ? `s${e.seq}` : `i${e.id}`, e)
|
||||
return [...seen.values()].sort((a, b) => a.seq - b.seq)
|
||||
}
|
||||
|
||||
/** The actor, short enough to label a block: `hanzo/2d4d67ab`, not the whole subject. */
|
||||
export function who(actor: string): string {
|
||||
const [org = '', rest = ''] = actor.split('/')
|
||||
const head = rest.split('-')[0] ?? ''
|
||||
return head ? `${org}/${head}` : actor
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* A person's verdict on what a run said, recorded on the platform's own event
|
||||
* bus in the org it was said in.
|
||||
*
|
||||
* POST /v1/event {type: 'track', event: 'build.verdict', sessionId, properties}
|
||||
*
|
||||
* The receipt says whether it landed; a verdict that did not is reported, not
|
||||
* shown as recorded.
|
||||
*/
|
||||
import { call, type Target } from './call.ts'
|
||||
|
||||
export type Verdict = 'up' | 'down' | null
|
||||
|
||||
export async function verdict(t: Target, session: string, project: string, v: Verdict): Promise<void> {
|
||||
const out = await call<{ accepted?: number }>(t, 'POST', '/v1/event', {
|
||||
type: 'track',
|
||||
event: 'build.verdict',
|
||||
sessionId: session,
|
||||
properties: { verdict: v ?? 'cleared', project },
|
||||
})
|
||||
if (!out || !out.accepted) throw new Error('The verdict was not recorded')
|
||||
}
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
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>
|
||||
)
|
||||
@@ -1,82 +0,0 @@
|
||||
/* 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; gui inserts the atomic rules for this app's own
|
||||
props at first render. 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;
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
// 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>
|
||||
)
|
||||
}
|
||||
+55
-2
@@ -6,12 +6,13 @@
|
||||
* signed in is not attempted — the platform would refuse it and the refusal
|
||||
* would read as an outage.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import type { Target } from './api/call.ts'
|
||||
import { places, type Place } from './api/places.ts'
|
||||
import { projects, type Project } from './api/projects.ts'
|
||||
import { list, watch, type Session } from './api/sessions.ts'
|
||||
import { get, list, watch, type Detail, type Event, type Session } from './api/sessions.ts'
|
||||
import { merge } from './api/turn.ts'
|
||||
|
||||
export interface Read<T> {
|
||||
value: T
|
||||
@@ -132,3 +133,55 @@ export function useKept<T>(key: string, initial: T): [T, (v: T) => void] {
|
||||
)
|
||||
return [value, set]
|
||||
}
|
||||
|
||||
export interface RunState {
|
||||
detail: Read<Detail | null>
|
||||
/** Every turn, recorded and live, in order. */
|
||||
events: Event[]
|
||||
/** The run's status as last reported by the record or the feed. */
|
||||
status: string
|
||||
/** Why the feed stopped, when it was refused. */
|
||||
refused: string
|
||||
}
|
||||
|
||||
/**
|
||||
* One run: the recorded detail, then the live feed added to it. A reconnect
|
||||
* after a drop re-reads the detail — frames missed while dropped are only in
|
||||
* the record. `id` null reads nothing.
|
||||
*/
|
||||
export function useRun(t: Target, id: string | null): RunState {
|
||||
const detail = useRead<Detail | null>(id ? () => get(t, id) : null, null, [t, id])
|
||||
const [live, setLive] = useState<Event[]>([])
|
||||
const [status, setStatus] = useState('')
|
||||
const [refused, setRefused] = useState('')
|
||||
const reload = useRef(detail.reload)
|
||||
reload.current = detail.reload
|
||||
|
||||
useEffect(() => {
|
||||
setLive([])
|
||||
setStatus('')
|
||||
setRefused('')
|
||||
if (!id) return
|
||||
const ctl = new AbortController()
|
||||
void watch(
|
||||
t,
|
||||
id,
|
||||
{
|
||||
event: (e) => {
|
||||
if (e.sessionId === id) setLive((prev) => [...prev, e])
|
||||
},
|
||||
session: (s) => {
|
||||
if (s.id === id) setStatus(s.status)
|
||||
},
|
||||
open: (n) => {
|
||||
if (n > 0) reload.current()
|
||||
},
|
||||
},
|
||||
ctl.signal,
|
||||
).catch((e: unknown) => setRefused(e instanceof Error ? e.message : 'The feed was refused'))
|
||||
return () => ctl.abort()
|
||||
}, [t, id])
|
||||
|
||||
const events = useMemo(() => merge(detail.value?.recent ?? [], live), [detail.value, live])
|
||||
return { detail, events, status: status || detail.value?.status || '', refused }
|
||||
}
|
||||
|
||||
-128
@@ -1,128 +0,0 @@
|
||||
// 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>
|
||||
)
|
||||
}
|
||||
+15
-10
@@ -8,8 +8,9 @@
|
||||
* kept in this browser, per org, so the next New starts where the last one did.
|
||||
*/
|
||||
import { SizableText, XStack, YStack } from '@hanzo/gui'
|
||||
import { ChevronDown, Cloud, Code2, GitBranch, Mic, MicOff, Monitor, Paperclip, Plus, X } from '@hanzogui/lucide-icons-2'
|
||||
import { ChevronDown, Cloud, Mic, MicOff, Monitor, Paperclip, Plus, X } from '@hanzogui/lucide-icons-2'
|
||||
import { ModeSelect } from '@hanzo/ui/agents'
|
||||
import { Button } from '@hanzo/ui'
|
||||
import { Composer, EmptyPrompt } from '@hanzo/ui/chat'
|
||||
import { BranchSelect, ChipSelect, HanzoMark, RepoSelect, type Repo as RowRepo } from '@hanzo/ui/product'
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
@@ -196,7 +197,15 @@ export function Landing({ onStarted }: { onStarted: (session: string) => void })
|
||||
render="button"
|
||||
onPress={async () => {
|
||||
try {
|
||||
window.location.assign(await connect(t, window.location.href))
|
||||
const to = await connect(t)
|
||||
// The console completes the connection at /connectors and
|
||||
// brings the person back here; a path, never an origin.
|
||||
try {
|
||||
window.sessionStorage.setItem('hanzo.return', window.location.pathname + window.location.search)
|
||||
} catch {
|
||||
/* they land on the connectors page and come back themselves */
|
||||
}
|
||||
window.location.assign(to)
|
||||
} catch (e) {
|
||||
setNote(e instanceof Error ? e.message : 'Could not start the GitHub connection')
|
||||
}
|
||||
@@ -266,21 +275,17 @@ export function Landing({ onStarted }: { onStarted: (session: string) => void })
|
||||
</XStack>
|
||||
<input ref={picker} type="file" multiple hidden onChange={(e) => void attach(e.currentTarget.files).then(() => (e.currentTarget.value = ''))} />
|
||||
<XStack items="center">
|
||||
<XStack
|
||||
render="button"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={voice.on ? 'Stop dictation' : 'Dictate'}
|
||||
aria-pressed={voice.on}
|
||||
disabled={!voice.able}
|
||||
onPress={voice.toggle}
|
||||
title={voice.able ? undefined : 'Dictation is not available in this browser'}
|
||||
hitSlop={8}
|
||||
p="$1"
|
||||
rounded="$2"
|
||||
opacity={voice.able ? 1 : 0.4}
|
||||
hoverStyle={{ bg: '$hover' }}
|
||||
>
|
||||
{voice.on ? <MicOff size={14} /> : <Mic size={14} />}
|
||||
</XStack>
|
||||
</Button>
|
||||
<ChipSelect
|
||||
quiet
|
||||
label="Dictation language"
|
||||
|
||||
-100
@@ -1,100 +0,0 @@
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* A link out of the builder: a new tab, no handle back to this page, no
|
||||
* referrer. Only ever handed an https address the caller has already checked.
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export function Out({ href, label, children }: { href: string; label?: string; children: ReactNode }) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={label}
|
||||
style={{ color: 'inherit', textDecoration: 'none', display: 'inline-flex', alignItems: 'center', gap: 6 }}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
// What this is, for a reader who has not signed in.
|
||||
//
|
||||
// THE PITCH IS UNDER THE ASK, not over it. Someone who already knows what they
|
||||
// want types it and never reads this; someone who does not scrolls. Putting the
|
||||
// explanation first would make the second reader's page the first reader's
|
||||
// obstacle.
|
||||
//
|
||||
// EVERY CLAIM HERE IS ONE THE CODE MAKES. The modes are Build.tsx's, the rows
|
||||
// are `/v1/projects`, the starters are `/v1/templates`, and the address is the
|
||||
// `?session=` a run already lives at. A landing page that promises a sixth
|
||||
// thing is a landing page that goes stale the first time nobody updates it.
|
||||
|
||||
import { Text, XStack, YStack } from '@hanzo/ui'
|
||||
|
||||
/** One claim, and the sentence that makes it concrete. */
|
||||
interface Claim {
|
||||
title: string
|
||||
says: string
|
||||
}
|
||||
|
||||
const CLAIMS: Claim[] = [
|
||||
{
|
||||
title: 'Say it, or ask what it would take',
|
||||
says: 'Build writes the thing. Plan answers what building it would involve, before anything is written. The mode rides with the ask, so the surface that answers knows which one you meant.',
|
||||
},
|
||||
{
|
||||
title: 'It runs where it ships',
|
||||
says: 'A project here is a real row, the same one a deployed site is served from. There is no draft store to promote out of — what you are looking at is what is live.',
|
||||
},
|
||||
{
|
||||
title: 'The parts are already there',
|
||||
says: 'The database, the sign-in and the storage exist before you ask. Nothing to provision, nothing to wire, and no keys to paste in.',
|
||||
},
|
||||
{
|
||||
title: 'Start from a picture',
|
||||
says: 'The starters are a public catalog, so you pick one by looking at it. Choosing takes a copy rather than asking a model to reinvent a starter that already exists.',
|
||||
},
|
||||
{
|
||||
title: 'A run has an address',
|
||||
says: 'Every run is at its own URL. A reload lands back on it and so does anyone you send it to.',
|
||||
},
|
||||
]
|
||||
|
||||
export function Pitch() {
|
||||
return (
|
||||
<YStack pt="$6" pb="$9" gap="$6" items="center" px="$4">
|
||||
<YStack maxW={560} gap="$2">
|
||||
<Text render="h2" fontSize="$7" lineHeight="$7" fontWeight="500" color="$ink" text="center">
|
||||
Describe an app. Watch it built.
|
||||
</Text>
|
||||
<Text fontSize="$3" color="$soft" text="center">
|
||||
Hanzo Build takes a sentence and gives back something running on a URL you can send to
|
||||
someone.
|
||||
</Text>
|
||||
</YStack>
|
||||
|
||||
{/* A column of claims rather than a grid of cards: these are read in
|
||||
order and the last one is the one that closes, which a grid loses. */}
|
||||
<YStack width={720} maxW="100%" gap="$5">
|
||||
{CLAIMS.map((claim) => (
|
||||
<XStack key={claim.title} gap="$4" items="flex-start">
|
||||
{/* The rule stands in for a number. It orders the list without
|
||||
claiming these are steps — they are not, they are true at once. */}
|
||||
<YStack width={2} self="stretch" bg="$borderColor" rounded="$1" />
|
||||
<YStack flex={1} gap="$1">
|
||||
<Text fontSize="$4" fontWeight="500" color="$ink">
|
||||
{claim.title}
|
||||
</Text>
|
||||
<Text fontSize="$3" lineHeight="$4" color="$soft">
|
||||
{claim.says}
|
||||
</Text>
|
||||
</YStack>
|
||||
</XStack>
|
||||
))}
|
||||
</YStack>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
+588
@@ -0,0 +1,588 @@
|
||||
/**
|
||||
* A project's workspace, in the same window: the chat on the left, what it
|
||||
* built on the right.
|
||||
*
|
||||
* bar mark · project · history · chat toggle | Preview Files Code Layers ·
|
||||
* device · reload · page · open | Share · Publish
|
||||
* left the project's runs as a conversation, suggestions, and the ask
|
||||
* right the deployed page, framed; the repository at the run's branch as a
|
||||
* tree and as read-only code; the element a person picked
|
||||
* dock the run's console — its steps and its log, as they stream
|
||||
*
|
||||
* A chat turn IS a coding run: `POST /v1/agent/coding` with the project, the
|
||||
* mode, and the previous run as `after`, so the next ask builds on the branch
|
||||
* the last one pushed. Files and Code read native git at that branch. The
|
||||
* preview is the project's own deployed address; a run's sandbox serves no
|
||||
* address the platform publishes, and nothing here invents one.
|
||||
*
|
||||
* Derived from the Hanzo App v2 editor (github.com/hanzoai/build-v2), itself
|
||||
* derived from OSW Studio and DeepSite (MIT). See NOTICE.
|
||||
*/
|
||||
import { SizableText, XStack, YStack } from '@hanzo/gui'
|
||||
import {
|
||||
Clock,
|
||||
ExternalLink,
|
||||
MousePointerClick,
|
||||
PanelLeft,
|
||||
RefreshCcw,
|
||||
Share2,
|
||||
Upload,
|
||||
} from '@hanzogui/lucide-icons-2'
|
||||
import {
|
||||
Attachments,
|
||||
Console,
|
||||
DEVICES,
|
||||
Feedback,
|
||||
FileTabs,
|
||||
FileTree,
|
||||
ModeSelect,
|
||||
PageSelect,
|
||||
PreviewFrame,
|
||||
ProjectChip,
|
||||
Suggestions,
|
||||
SUGGESTIONS,
|
||||
VIEWS,
|
||||
Views,
|
||||
Workspace,
|
||||
fold,
|
||||
type Attachment,
|
||||
type FrameEvent,
|
||||
type Line,
|
||||
type OpenFile,
|
||||
type PreviewHandle,
|
||||
type Verdict,
|
||||
} from '@hanzo/ui/agents'
|
||||
import { Composer, Message } from '@hanzo/ui/chat'
|
||||
import { Button, Dialog, DialogContent, DialogTitle } from '@hanzo/ui'
|
||||
import { HanzoMark } from '@hanzo/ui/product'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { start, type Mode } from './api/coding.ts'
|
||||
import { blob, tree } from './api/git.ts'
|
||||
import { name as repoName, type Project as Row } from './api/projects.ts'
|
||||
import { list, message, stop, type Session } from './api/sessions.ts'
|
||||
import { outcome, said, who } from './api/turn.ts'
|
||||
import { verdict as record } from './api/verdict.ts'
|
||||
import { useKept, useProjects, useRead, useRun } from './data.ts'
|
||||
import { useHost, useTarget } from './host.tsx'
|
||||
import { MODES } from './landing.tsx'
|
||||
import { Out } from './out.tsx'
|
||||
import { Publish, type Source } from './publish.tsx'
|
||||
|
||||
type ViewId = 'preview' | 'files' | 'code' | 'layers'
|
||||
|
||||
const LIVE = new Set(['running', 'paused'])
|
||||
|
||||
/** `owner/name` from a GitHub clone URL, or '' for a repository that lives elsewhere. */
|
||||
function github(clone: string): string {
|
||||
const m = /^https:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/.exec(clone)
|
||||
return m ? `${m[1]}/${m[2]}` : ''
|
||||
}
|
||||
|
||||
/** One run in the conversation: what was asked, and what it came to. */
|
||||
function Turn({ run, open, onOpen, project }: { run: Session; open: boolean; onOpen: () => void; project: string }) {
|
||||
const t = useTarget()
|
||||
const [v, setV] = useState<Verdict>(null)
|
||||
const [note, setNote] = useState('')
|
||||
return (
|
||||
<YStack gap="$2">
|
||||
<Message role="user">
|
||||
<SizableText size="$2" color="$ink">
|
||||
{run.title || 'Untitled run'}
|
||||
</SizableText>
|
||||
</Message>
|
||||
<Message
|
||||
role="assistant"
|
||||
busy={LIVE.has(run.status)}
|
||||
actions={
|
||||
<XStack items="center" gap="$2">
|
||||
<Feedback
|
||||
text={run.title}
|
||||
verdict={v}
|
||||
onVerdict={(next) => {
|
||||
setV(next)
|
||||
record(t, run.id, project, next).catch((e: unknown) => {
|
||||
setV(null)
|
||||
setNote(e instanceof Error ? e.message : 'Not recorded')
|
||||
})
|
||||
}}
|
||||
/>
|
||||
{note ? (
|
||||
<SizableText size="$1" color="$soft">
|
||||
{note}
|
||||
</SizableText>
|
||||
) : null}
|
||||
{!open ? (
|
||||
<XStack render="button" onPress={onOpen} hitSlop={8}>
|
||||
<SizableText size="$1" color="$soft" textDecorationLine="underline">
|
||||
Show steps
|
||||
</SizableText>
|
||||
</XStack>
|
||||
) : null}
|
||||
</XStack>
|
||||
}
|
||||
>
|
||||
<SizableText size="$2" color="$ink">
|
||||
{run.status === 'done' ? 'Done.' : run.status === 'error' ? 'This run hit an error.' : run.status === 'stopped' ? 'Stopped.' : 'Working…'}
|
||||
</SizableText>
|
||||
</Message>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
export function Project({ slug }: { slug: string }) {
|
||||
const host = useHost()
|
||||
const t = useTarget()
|
||||
const signed = Boolean(host.person)
|
||||
const all = useProjects(t, signed)
|
||||
const project: Row | null = all.value.find((p) => p.slug === slug) ?? null
|
||||
|
||||
const runs = useRead(signed ? () => list(t, { kind: 'coding', project: slug, limit: 50 }) : null, [] as Session[], [t, signed, slug])
|
||||
const ordered = useMemo(() => [...runs.value].reverse(), [runs.value])
|
||||
const [chosen, setChosen] = useState<string | null>(null)
|
||||
const current = chosen ?? runs.value[0]?.id ?? null
|
||||
const run = useRun(t, current)
|
||||
const end = outcome(run.events)
|
||||
const running = LIVE.has(run.status || end.status)
|
||||
|
||||
const [view, setView] = useKept<ViewId>(`hanzo.build.view.${slug}`, 'preview')
|
||||
const [device, setDevice] = useState<'desktop' | 'mobile'>('desktop')
|
||||
const [collapsed, setCollapsed] = useKept('hanzo.build.chat', false)
|
||||
const [pane, setPane] = useState<'chat' | 'view'>('chat')
|
||||
const [dock, setDock] = useKept('hanzo.build.dock', 36)
|
||||
const [dismissed, setDismissed] = useKept(`hanzo.build.hints.${slug}`, false)
|
||||
const [mode, setMode] = useKept<Mode>('hanzo.build.mode', 'build')
|
||||
const [page, setPage] = useState('/')
|
||||
const [draft, setDraft] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [note, setNote] = useState('')
|
||||
const [picked, setPicked] = useState<Attachment[]>([])
|
||||
const [bridge, setBridge] = useState(false)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [pageLines, setPageLines] = useState<Line[]>([])
|
||||
const [history, setHistory] = useState(false)
|
||||
const [publish, setPublish] = useState<Source | null>(null)
|
||||
const [switcher, setSwitcher] = useState(false)
|
||||
const frame = useRef<PreviewHandle | null>(null)
|
||||
|
||||
// Files and Code: native git, at the branch the open run pushed (or the project's own).
|
||||
const repo = project?.repo ? repoName(project.repo) : ''
|
||||
const ref = end.branch || project?.branch || 'main'
|
||||
const [files, setFiles] = useState<OpenFile[]>([])
|
||||
const [file, setFile] = useState<string | null>(null)
|
||||
useEffect(() => {
|
||||
setFiles([])
|
||||
setFile(null)
|
||||
}, [repo, ref])
|
||||
|
||||
const openFile = async (path: string) => {
|
||||
setFile(path)
|
||||
setView('code')
|
||||
if (files.some((f) => f.path === path)) return
|
||||
setFiles((was) => [...was, { path, readOnly: true }])
|
||||
try {
|
||||
const b = await blob(t, repo, ref, path)
|
||||
const content = b.binary ? '' : b.truncated ? '' : b.text
|
||||
const error = b.binary ? 'A binary file — nothing to show as text.' : b.truncated ? 'Past the 1 MiB view cap — clone the repository to read it.' : undefined
|
||||
setFiles((was) => was.map((f) => (f.path === path ? { ...f, content, error } : f)))
|
||||
} catch (e) {
|
||||
setFiles((was) => was.map((f) => (f.path === path ? { ...f, error: e instanceof Error ? e.message : 'Could not read this file' } : f)))
|
||||
}
|
||||
}
|
||||
|
||||
// Pages: the HTML files at the repository's root, as the page picker's list.
|
||||
const root = useRead(repo ? () => tree(t, repo, ref, '') : null, [], [t, repo, ref])
|
||||
const pages = useMemo(
|
||||
() => [
|
||||
{ id: '/', label: 'Homepage' },
|
||||
...root.value
|
||||
.filter((e) => !e.dir && /\.html?$/i.test(e.name) && !/^index\.html?$/i.test(e.name))
|
||||
.map((e) => ({ id: `/${e.path}`, label: e.name.replace(/\.html?$/i, '') })),
|
||||
],
|
||||
[root.value],
|
||||
)
|
||||
|
||||
const src = project?.live ? new URL(page, project.live).toString() : null
|
||||
|
||||
const lines: Line[] = useMemo(
|
||||
() => [
|
||||
...run.events
|
||||
.filter((e) => e.kind === 'log' || e.kind === 'tool-call' || e.kind === 'status')
|
||||
.map((e): Line => ({
|
||||
id: `r${e.seq || e.id}`,
|
||||
level: e.kind === 'status' && /error/.test(said(e).toLowerCase()) ? 'error' : e.kind === 'tool-call' ? 'info' : 'log',
|
||||
text: said(e),
|
||||
source: e.kind === 'status' ? 'run' : who(e.actor),
|
||||
}))
|
||||
.filter((l) => l.text),
|
||||
...pageLines,
|
||||
],
|
||||
[run.events, pageLines],
|
||||
)
|
||||
|
||||
const onBridge = (e: FrameEvent) => {
|
||||
if (e.type === 'preview:ready') setBridge(true)
|
||||
else if (e.type === 'preview:select')
|
||||
setPicked([{ id: e.info.selector, kind: 'element', label: `${e.info.tag}${e.info.id ? `#${e.info.id}` : ''}` }])
|
||||
else if (e.type === 'preview:navigate') setPage(e.path)
|
||||
else if (e.type === 'preview:console')
|
||||
setPageLines((was) => [...was.slice(-400), { id: `p${was.length}-${Date.now()}`, level: e.level, text: e.text, source: 'page' }])
|
||||
}
|
||||
|
||||
const send = async (text?: string) => {
|
||||
const ask = (text ?? draft).trim()
|
||||
if (!ask || busy) return
|
||||
if (!signed) return host.signIn?.()
|
||||
setBusy(true)
|
||||
setNote('')
|
||||
try {
|
||||
const context = picked.length ? `\n\nAbout the element ${picked.map((p) => p.id).join(', ')} on ${page}.` : ''
|
||||
const gh = project ? github(project.repo) : ''
|
||||
const next = await start(t, {
|
||||
prompt: `${ask}${context}`,
|
||||
project: slug,
|
||||
repo: gh || undefined,
|
||||
base: gh ? project?.branch || undefined : undefined,
|
||||
after: current ?? undefined,
|
||||
mode,
|
||||
})
|
||||
setDraft('')
|
||||
setPicked([])
|
||||
setChosen(next.session)
|
||||
runs.reload()
|
||||
} catch (e) {
|
||||
setNote(e instanceof Error ? e.message : 'The run could not start')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const steer = async () => {
|
||||
if (!current || !draft.trim()) return
|
||||
try {
|
||||
await message(t, current, draft)
|
||||
setDraft('')
|
||||
setNote('Sent — the run reads it before its next step')
|
||||
} catch (e) {
|
||||
setNote(e instanceof Error ? e.message : 'Could not reach this run')
|
||||
}
|
||||
}
|
||||
|
||||
const share = async () => {
|
||||
const link = project?.live || `${window.location.origin}${window.location.pathname}`
|
||||
try {
|
||||
await navigator.clipboard.writeText(link)
|
||||
setNote(`Copied ${link}`)
|
||||
} catch {
|
||||
setNote(link)
|
||||
}
|
||||
}
|
||||
|
||||
if (!signed) {
|
||||
return (
|
||||
<YStack flex={1} items="center" justify="center" gap="$3">
|
||||
<SizableText size="$4" color="$ink">
|
||||
Sign in to open this project.
|
||||
</SizableText>
|
||||
<Button onPress={() => host.signIn?.()}>Sign in</Button>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
if (!all.loading && !project && !all.error) {
|
||||
return (
|
||||
<YStack flex={1} items="center" justify="center" gap="$3" px="$4">
|
||||
<SizableText size="$4" color="$ink">
|
||||
{host.org ? `${host.org} has no project named ${slug}.` : `There is no project named ${slug}.`}
|
||||
</SizableText>
|
||||
<Button variant="outline" onPress={() => host.go('-/artifacts')}>
|
||||
See what this organization has built
|
||||
</Button>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
const chat = (
|
||||
<YStack flex={1} minH={0}>
|
||||
<YStack flex={1} minH={0} overflow="scroll" px="$3" py="$4" gap="$4">
|
||||
{ordered.length === 0 ? (
|
||||
<SizableText size="$2" color="$ink" px="$2">
|
||||
{runs.loading
|
||||
? 'Reading this project’s runs…'
|
||||
: `${project?.name ?? slug} is loaded — it is in the preview, and every run on it is under the clock above. Say what to change and it gets built.`}
|
||||
</SizableText>
|
||||
) : (
|
||||
ordered.map((r) => (
|
||||
<YStack key={r.id} gap="$2">
|
||||
<Turn run={r} open={r.id === current} onOpen={() => setChosen(r.id)} project={slug} />
|
||||
{r.id === current ? (
|
||||
<YStack gap="$1" pl="$2" borderLeftWidth={1} borderColor="$borderColor">
|
||||
{fold(run.events.map((e) => ({ kind: e.kind, actor: who(e.actor), seq: e.seq, id: e.id, text: said(e) })).filter((b) => b.text)).map((b) => (
|
||||
<SizableText key={b.key} size="$1" color="$soft">
|
||||
{b.text}
|
||||
</SizableText>
|
||||
))}
|
||||
{end.pr ? (
|
||||
<Out href={end.pr}>
|
||||
<SizableText size="$1" color="$ink" textDecorationLine="underline">
|
||||
{end.label || 'Pull request'}
|
||||
</SizableText>
|
||||
<ExternalLink size={11} />
|
||||
</Out>
|
||||
) : null}
|
||||
</YStack>
|
||||
) : null}
|
||||
</YStack>
|
||||
))
|
||||
)}
|
||||
</YStack>
|
||||
<YStack px="$3" pb="$3" gap="$2">
|
||||
{!dismissed && !running ? <Suggestions items={SUGGESTIONS} onPick={(s) => void send(s)} onDismiss={() => setDismissed(true)} /> : null}
|
||||
{note ? (
|
||||
<SizableText size="$1" color="$soft" role="status">
|
||||
{note}
|
||||
</SizableText>
|
||||
) : null}
|
||||
{picked.length ? <Attachments items={picked} onRemove={() => setPicked([])} /> : null}
|
||||
<Composer
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
onSend={() => void (running ? steer() : send())}
|
||||
onStop={current ? () => void stop(t, current).then(() => setNote('Stop requested — the run keeps its work on its branch')) : undefined}
|
||||
busy={busy || (running && !draft.trim())}
|
||||
rows={3}
|
||||
placeholder={running ? 'Steer this run' : 'Ask Hanzo for edits'}
|
||||
label="Ask Hanzo for edits"
|
||||
>
|
||||
<ModeSelect modes={MODES} value={mode} onChange={(m) => setMode(m as Mode)} />
|
||||
</Composer>
|
||||
</YStack>
|
||||
</YStack>
|
||||
)
|
||||
|
||||
const body =
|
||||
view === 'preview' ? (
|
||||
<PreviewFrame
|
||||
ref={frame}
|
||||
src={src}
|
||||
title={`${project?.name ?? slug} preview`}
|
||||
device={device}
|
||||
onBridge={onBridge}
|
||||
empty={
|
||||
<YStack items="center" gap="$2" p="$6">
|
||||
<SizableText size="$3" color="$ink">
|
||||
Nothing deployed yet
|
||||
</SizableText>
|
||||
<SizableText size="$2" color="$soft" text="center">
|
||||
Publish this project and its page appears here.
|
||||
</SizableText>
|
||||
</YStack>
|
||||
}
|
||||
toolbar={
|
||||
bridge ? (
|
||||
<XStack
|
||||
render="button"
|
||||
aria-pressed={editing}
|
||||
aria-label={editing ? 'Stop picking elements' : 'Pick an element to edit'}
|
||||
onPress={() => {
|
||||
const next = !editing
|
||||
setEditing(next)
|
||||
frame.current?.post({ type: 'preview:editable', active: next })
|
||||
}}
|
||||
p="$2"
|
||||
rounded="$10"
|
||||
>
|
||||
<MousePointerClick size={16} />
|
||||
</XStack>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
) : view === 'files' ? (
|
||||
repo ? (
|
||||
<FileTree
|
||||
load={async (dir) => (await tree(t, repo, ref, dir)).map((e) => ({ path: e.path, kind: e.dir ? ('dir' as const) : ('file' as const) }))}
|
||||
value={file}
|
||||
onSelect={(p) => void openFile(p)}
|
||||
label={`${repo} at ${ref}`}
|
||||
/>
|
||||
) : (
|
||||
<SizableText size="$2" color="$soft" p="$4">
|
||||
This project has no repository yet. Its first run creates one.
|
||||
</SizableText>
|
||||
)
|
||||
) : view === 'code' ? (
|
||||
<FileTabs
|
||||
files={files}
|
||||
value={file}
|
||||
onSelect={setFile}
|
||||
onClose={(p) => {
|
||||
setFiles((was) => was.filter((f) => f.path !== p))
|
||||
if (file === p) setFile(null)
|
||||
}}
|
||||
empty={
|
||||
<SizableText size="$2" color="$soft" p="$4">
|
||||
Open a file from Files. Code here is read at {ref}; ask in the chat to change it.
|
||||
</SizableText>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<YStack p="$4" gap="$2">
|
||||
{picked.length ? (
|
||||
picked.map((p) => (
|
||||
<SizableText key={p.id} size="$2" color="$ink">
|
||||
{p.label} — {p.id}
|
||||
</SizableText>
|
||||
))
|
||||
) : (
|
||||
<SizableText size="$2" color="$soft">
|
||||
{bridge
|
||||
? 'Pick an element in the preview to see it here and attach it to your next ask.'
|
||||
: 'Layers are read through the preview bridge, and this page does not run it. Its elements cannot be listed from here.'}
|
||||
</SizableText>
|
||||
)}
|
||||
</YStack>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Workspace
|
||||
start={
|
||||
<XStack items="center" gap="$2">
|
||||
<XStack render="button" aria-label="All runs" onPress={() => host.go('')} p="$1">
|
||||
<HanzoMark size={20} />
|
||||
</XStack>
|
||||
<ProjectChip name={project?.name ?? slug} onPress={() => setSwitcher(true)} />
|
||||
<XStack render="button" aria-label="History" onPress={() => setHistory(true)} p="$1.5" rounded="$3" hoverStyle={{ bg: '$hover' }}>
|
||||
<Clock size={16} />
|
||||
</XStack>
|
||||
<XStack
|
||||
render="button"
|
||||
aria-label={collapsed ? 'Show the chat' : 'Hide the chat'}
|
||||
aria-pressed={!collapsed}
|
||||
onPress={() => setCollapsed(!collapsed)}
|
||||
p="$1.5"
|
||||
rounded="$3"
|
||||
hoverStyle={{ bg: '$hover' }}
|
||||
>
|
||||
<PanelLeft size={16} />
|
||||
</XStack>
|
||||
</XStack>
|
||||
}
|
||||
middle={
|
||||
<XStack items="center" gap="$2">
|
||||
<Views views={VIEWS} value={view} onChange={(v) => { setView(v as ViewId); setPane('view') }} label="View" />
|
||||
<Views views={DEVICES} value={device} onChange={(d) => setDevice(d as 'desktop' | 'mobile')} label="Device" labels="none" />
|
||||
<XStack render="button" aria-label="Reload the preview" onPress={() => frame.current?.reload()} p="$1.5" rounded="$3" hoverStyle={{ bg: '$hover' }}>
|
||||
<RefreshCcw size={16} />
|
||||
</XStack>
|
||||
<PageSelect pages={pages} value={page} onChange={setPage} />
|
||||
{src ? (
|
||||
<Out href={src} label="Open in a new tab">
|
||||
<XStack p="$1.5" rounded="$3" hoverStyle={{ bg: '$hover' }}>
|
||||
<ExternalLink size={16} />
|
||||
</XStack>
|
||||
</Out>
|
||||
) : null}
|
||||
</XStack>
|
||||
}
|
||||
end={
|
||||
<XStack items="center" gap="$2">
|
||||
<Button variant="outline" size="sm" onPress={() => void share()}>
|
||||
<Share2 size={14} /> Share
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!project?.repo}
|
||||
onPress={() =>
|
||||
project &&
|
||||
setPublish({ repo: project.repo, title: project.name, ref, name: project.slug, project: project.slug })
|
||||
}
|
||||
>
|
||||
<Upload size={14} /> Publish
|
||||
</Button>
|
||||
</XStack>
|
||||
}
|
||||
chat={chat}
|
||||
dock={
|
||||
<Console
|
||||
lines={lines}
|
||||
height={dock}
|
||||
onHeight={setDock}
|
||||
onClear={() => setPageLines([])}
|
||||
status={run.status || end.status || undefined}
|
||||
label="Console"
|
||||
/>
|
||||
}
|
||||
collapsed={collapsed}
|
||||
pane={pane}
|
||||
>
|
||||
{body}
|
||||
</Workspace>
|
||||
|
||||
<Dialog open={history} onOpenChange={setHistory}>
|
||||
<DialogContent maxW={480}>
|
||||
<DialogTitle>Runs on {project?.name ?? slug}</DialogTitle>
|
||||
<YStack gap="$1">
|
||||
{runs.value.length === 0 ? (
|
||||
<SizableText size="$2" color="$soft">
|
||||
No runs yet.
|
||||
</SizableText>
|
||||
) : (
|
||||
runs.value.map((r) => (
|
||||
<XStack
|
||||
key={r.id}
|
||||
render="button"
|
||||
onPress={() => {
|
||||
setChosen(r.id)
|
||||
setHistory(false)
|
||||
}}
|
||||
justify="space-between"
|
||||
px="$2"
|
||||
py="$1.5"
|
||||
rounded="$3"
|
||||
hoverStyle={{ bg: '$hover' }}
|
||||
>
|
||||
<SizableText size="$2" color="$ink" numberOfLines={1} flex={1}>
|
||||
{r.title || 'Untitled run'}
|
||||
</SizableText>
|
||||
<SizableText size="$1" color="$soft">
|
||||
{r.status}
|
||||
</SizableText>
|
||||
</XStack>
|
||||
))
|
||||
)}
|
||||
</YStack>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={switcher} onOpenChange={setSwitcher}>
|
||||
<DialogContent maxW={420}>
|
||||
<DialogTitle>Projects</DialogTitle>
|
||||
<YStack gap="$1" maxH={420} overflow="scroll">
|
||||
{all.value.map((p) => (
|
||||
<XStack
|
||||
key={p.slug}
|
||||
render="button"
|
||||
onPress={() => {
|
||||
setSwitcher(false)
|
||||
host.go(p.slug)
|
||||
}}
|
||||
px="$2"
|
||||
py="$1.5"
|
||||
rounded="$3"
|
||||
bg={p.slug === slug ? '$hover' : undefined}
|
||||
hoverStyle={{ bg: '$hover' }}
|
||||
>
|
||||
<SizableText size="$2" color="$ink" numberOfLines={1}>
|
||||
{p.name}
|
||||
</SizableText>
|
||||
</XStack>
|
||||
))}
|
||||
</YStack>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Publish source={publish} onClose={() => setPublish(null)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
+3
-2
@@ -18,6 +18,7 @@ import { useEffect, useState } from 'react'
|
||||
|
||||
import { builds, declare, label, type Declared } from './api/platform.ts'
|
||||
import { useHost, useTarget } from './host.tsx'
|
||||
import { Out } from './out.tsx'
|
||||
|
||||
export interface Source {
|
||||
/** The https clone URL. */
|
||||
@@ -135,12 +136,12 @@ export function Publish({ source, onClose }: { source: Source | null; onClose: (
|
||||
: `Build ${out.build?.id ?? ''}: ${status || 'building'}`}
|
||||
</SizableText>
|
||||
{out.review ? (
|
||||
<XStack render="a" href={out.review} target="_blank" rel="noopener noreferrer" items="center" gap="$1.5">
|
||||
<Out href={out.review}>
|
||||
<SizableText size="$2" color="$ink" textDecorationLine="underline">
|
||||
Open the review
|
||||
</SizableText>
|
||||
<ExternalLink size={12} opacity={0.6} />
|
||||
</XStack>
|
||||
</Out>
|
||||
) : null}
|
||||
</YStack>
|
||||
) : null}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
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
@@ -1,103 +0,0 @@
|
||||
/**
|
||||
* 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 ?? '',
|
||||
}
|
||||
}
|
||||
+20
-68
@@ -14,59 +14,23 @@ import { SizableText, XStack, YStack } from '@hanzo/gui'
|
||||
import { ExternalLink, GitPullRequest } from '@hanzogui/lucide-icons-2'
|
||||
import { Transcript, fold, type Turn } from '@hanzo/ui/agents'
|
||||
import { Composer } from '@hanzo/ui/chat'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { Refusal } from './api/call.ts'
|
||||
import { get, message, stop, watch, type Detail, type Event } from './api/sessions.ts'
|
||||
import { merge, outcome, said } from './api/turn.ts'
|
||||
import { useRead } from './data.ts'
|
||||
import { message, stop } from './api/sessions.ts'
|
||||
import { outcome, said, who } from './api/turn.ts'
|
||||
import { useRun } from './data.ts'
|
||||
import { useTarget } from './host.tsx'
|
||||
import { Out } from './out.tsx'
|
||||
|
||||
const LIVE = new Set(['running', 'paused', ''])
|
||||
|
||||
/** The actor, short enough to label a block: `hanzo/2d4d67ab`. */
|
||||
const who = (actor: string): string => {
|
||||
const [org = '', rest = ''] = actor.split('/')
|
||||
const head = rest.split('-')[0] ?? ''
|
||||
return head ? `${org}/${head}` : actor
|
||||
}
|
||||
|
||||
export function Run({ id }: { id: string }) {
|
||||
const t = useTarget()
|
||||
const detail = useRead<Detail | null>(() => get(t, id), null, [t, id])
|
||||
const [live, setLive] = useState<Event[]>([])
|
||||
const [status, setStatus] = useState('')
|
||||
const { detail, events, status, refused } = useRun(t, id)
|
||||
const [draft, setDraft] = useState('')
|
||||
const [note, setNote] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const reload = useRef(detail.reload)
|
||||
reload.current = detail.reload
|
||||
|
||||
useEffect(() => {
|
||||
setLive([])
|
||||
const ctl = new AbortController()
|
||||
void watch(
|
||||
t,
|
||||
id,
|
||||
{
|
||||
event: (e) => {
|
||||
if (e.sessionId === id) setLive((prev) => [...prev, e])
|
||||
},
|
||||
session: (s) => {
|
||||
if (s.id === id) setStatus(s.status)
|
||||
},
|
||||
open: (n) => {
|
||||
if (n > 0) reload.current()
|
||||
},
|
||||
},
|
||||
ctl.signal,
|
||||
).catch((e: unknown) => {
|
||||
if (e instanceof Refusal) setNote(e.message)
|
||||
})
|
||||
return () => ctl.abort()
|
||||
}, [t, id])
|
||||
|
||||
const events = useMemo(() => merge(detail.value?.recent ?? [], live), [detail.value, live])
|
||||
const blocks = useMemo(
|
||||
() =>
|
||||
fold(
|
||||
@@ -77,7 +41,7 @@ export function Run({ id }: { id: string }) {
|
||||
[events],
|
||||
)
|
||||
const end = outcome(events)
|
||||
const state = status || end.status || detail.value?.status || ''
|
||||
const state = status || end.status
|
||||
const running = LIVE.has(state) && !detail.error
|
||||
|
||||
const send = async () => {
|
||||
@@ -120,29 +84,16 @@ export function Run({ id }: { id: string }) {
|
||||
</SizableText>
|
||||
</YStack>
|
||||
{end.pr ? (
|
||||
<XStack
|
||||
render="a"
|
||||
// A platform-issued https address (turn.ts refuses anything else),
|
||||
// opened away from this page and with no handle back to it.
|
||||
href={end.pr}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
items="center"
|
||||
gap="$1.5"
|
||||
px="$2.5"
|
||||
py="$1.5"
|
||||
rounded="$3"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
hoverStyle={{ bg: '$hover' }}
|
||||
aria-label={`Open pull request ${end.label}`.trim()}
|
||||
>
|
||||
<GitPullRequest size={14} />
|
||||
<SizableText size="$2" color="$ink">
|
||||
{end.label || 'Pull request'}
|
||||
</SizableText>
|
||||
<ExternalLink size={12} opacity={0.6} />
|
||||
</XStack>
|
||||
// A platform-issued https address; turn.ts refuses anything else.
|
||||
<Out href={end.pr} label={`Open pull request ${end.label}`.trim()}>
|
||||
<XStack items="center" gap="$1.5" px="$2.5" py="$1.5" rounded="$3" borderWidth={1} borderColor="$borderColor" hoverStyle={{ bg: '$hover' }}>
|
||||
<GitPullRequest size={14} />
|
||||
<SizableText size="$2" color="$ink">
|
||||
{end.label || 'Pull request'}
|
||||
</SizableText>
|
||||
<ExternalLink size={12} opacity={0.6} />
|
||||
</XStack>
|
||||
</Out>
|
||||
) : null}
|
||||
</XStack>
|
||||
|
||||
@@ -161,12 +112,13 @@ export function Run({ id }: { id: string }) {
|
||||
The branch is pushed; the pull request could not be opened: {end.problem}
|
||||
</SizableText>
|
||||
) : null}
|
||||
{note ? (
|
||||
{note || refused ? (
|
||||
<SizableText size="$1" color="$soft" role="status">
|
||||
{note}
|
||||
{note || refused}
|
||||
</SizableText>
|
||||
) : null}
|
||||
<Composer
|
||||
inline
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
onSend={() => void send()}
|
||||
|
||||
-70
@@ -1,70 +0,0 @@
|
||||
// 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}.`
|
||||
}
|
||||
+9
-14
@@ -14,6 +14,7 @@ import { useState } from 'react'
|
||||
import { fork, templates, type Project, type Template } from './api/projects.ts'
|
||||
import { useProjects, useRead } from './data.ts'
|
||||
import { useHost, useTarget } from './host.tsx'
|
||||
import { Out } from './out.tsx'
|
||||
|
||||
const SHOTS = 'https://hanzo.ai/templates'
|
||||
|
||||
@@ -188,20 +189,14 @@ export function Templates() {
|
||||
))}
|
||||
</XStack>
|
||||
)}
|
||||
<XStack
|
||||
render="a"
|
||||
href="https://gallery.hanzo.ai"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
items="center"
|
||||
gap="$1.5"
|
||||
pt="$5"
|
||||
>
|
||||
<SizableText size="$2" color="$soft">
|
||||
The whole catalog
|
||||
</SizableText>
|
||||
<ExternalLink size={12} opacity={0.6} />
|
||||
</XStack>
|
||||
<YStack pt="$5">
|
||||
<Out href="https://gallery.hanzo.ai">
|
||||
<SizableText size="$2" color="$soft">
|
||||
The whole catalog
|
||||
</SizableText>
|
||||
<ExternalLink size={12} opacity={0.6} />
|
||||
</Out>
|
||||
</YStack>
|
||||
</YStack>
|
||||
</YStack>
|
||||
)
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
// 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]
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
// 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user