@hanzo/build 0.2.19 → 0.2.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,17 +3,25 @@
3
3
  Say what you want. Hanzo writes it, runs it, and puts it on a live URL — with
4
4
  the database, the sign-in and the storage already there.
5
5
 
6
- The builder is one component, `<Builder host={…} />` from `@hanzo/build`, with
7
- three hosts: this page (hanzo.build), and the Dev section of the Hanzo app at
8
- hanzo.ai/dev and hanzo.app/dev. None of them forks it. hanzo.build is this
9
- frontend. A click stays on this origin — sign-in returns to `/auth/callback`
10
- here — and github.com is opened only for the repository grant.
6
+ The builder is one component, `<Builder host={…} />` from `@hanzo/build`. The
7
+ Hanzo app at hanzo.ai mounts it as its Dev mode, and that is where people use
8
+ it: hanzo.build and build.hanzo.ai redirect there, to the same place. This
9
+ repository's own page is the development host — the same component, signed in
10
+ through hanzo.id — and a click stays on its origin, with github.com opened only
11
+ for the repository grant.
12
+
13
+ The page is signed-in only. A visitor is drawn nothing: the tab goes to
14
+ hanzo.id (no popup) and comes back to the address it asked for; hanzo.id
15
+ answers at once, with no screen, for a person already signed in there. Words
16
+ carried on arrival as `?q=` wait in New's composer, and a draft sent while
17
+ signed out is sent once its person is in (`landing.tsx` `hold`). It signs in as
18
+ `hanzo-app`, the Hanzo App's client.
11
19
 
12
20
  ```
13
21
  pnpm install
14
22
  pnpm dev # http://localhost:3200
15
23
  pnpm test # the transport contract and the address grammar
16
- pnpm site # every screen of https://hanzo.build in a browser (SITE= for another origin)
24
+ pnpm site # every browser spec against the dev server (SITE= for another origin)
17
25
  pnpm e2e # one live agent turn, projected the way a run draws it
18
26
  pnpm cover # unit tests and every browser spec against a dev server, one coverage report in coverage/
19
27
  pnpm build:lib # lib/ — what npm ships
@@ -100,7 +108,7 @@ collide.
100
108
 
101
109
  ## What is on the screen
102
110
 
103
- **The rail** (hanzo.build). At the top, the mark. Signed in to an organization,
111
+ **The rail** (this page, or `rail` left on). At the top, the mark. Signed in to an organization,
104
112
  that spot is the organization switcher: the org, and the projects under it.
105
113
  New, Projects, Artifacts, Customize, Automations, then More (Codebase, Issues,
106
114
  Templates, Machines, Docs), then the org's coding runs newest first with a live
@@ -18,6 +18,7 @@
18
18
  * POST /v1/billing/topup charge a saved card into the balance
19
19
  * GET /v1/billing/usage/rollup the plan's month: included spend and request windows
20
20
  * GET /v1/usage/summary?range=month this month's spend, by category
21
+ * GET /v1/allowance the Free plan's allowance, and the pool it is served from
21
22
  * GET /v1/billing/alerts spend caps (bare array)
22
23
  * POST /v1/billing/alerts open a cap (org admin)
23
24
  * PATCH /v1/billing/alerts/{id} change one (org admin)
@@ -183,6 +184,35 @@ export interface Spend {
183
184
  }[];
184
185
  }
185
186
  export declare function spend(t: Target): Promise<Spend>;
187
+ /** The pool every free user shares, as its vendor accounts last said. */
188
+ export interface Pool {
189
+ /** available, busy or exhausted. */
190
+ state: string;
191
+ /** Accounts in the pool, and how many take a free request now. */
192
+ keys: number;
193
+ ready: number;
194
+ /** RFC 3339: when the first account that is out comes back; '' when none is out. */
195
+ resets: string;
196
+ }
197
+ /**
198
+ * What the caller has left of the Free plan: limited usage, from a pool shared
199
+ * by all free users. `pooled` is false for a paid plan, whose usage is its spend.
200
+ */
201
+ export interface Allowance {
202
+ plan: string;
203
+ /** Requests allowed in `window`; 0 is no bound. */
204
+ limit: number;
205
+ used: number;
206
+ spent: boolean;
207
+ /** hour or day. */
208
+ window: string;
209
+ /** RFC 3339. */
210
+ resets: string;
211
+ pooled: boolean;
212
+ /** Absent when the platform could not read it. */
213
+ pool: Pool | null;
214
+ }
215
+ export declare function allowance(t: Target): Promise<Allowance>;
186
216
  export interface Cap {
187
217
  id: string;
188
218
  title: string;
@@ -18,6 +18,7 @@
18
18
  * POST /v1/billing/topup charge a saved card into the balance
19
19
  * GET /v1/billing/usage/rollup the plan's month: included spend and request windows
20
20
  * GET /v1/usage/summary?range=month this month's spend, by category
21
+ * GET /v1/allowance the Free plan's allowance, and the pool it is served from
21
22
  * GET /v1/billing/alerts spend caps (bare array)
22
23
  * POST /v1/billing/alerts open a cap (org admin)
23
24
  * PATCH /v1/billing/alerts/{id} change one (org admin)
@@ -256,6 +257,21 @@ export async function spend(t) {
256
257
  .filter((c) => c.cents > 0),
257
258
  };
258
259
  }
260
+ const stamp = (unix) => (num(unix) > 0 ? new Date(num(unix) * 1000).toISOString() : '');
261
+ export async function allowance(t) {
262
+ const r = obj(await call(t, 'GET', '/v1/allowance'));
263
+ const p = r.pool && typeof r.pool === 'object' ? obj(r.pool) : null;
264
+ return {
265
+ plan: str(r.plan),
266
+ limit: num(r.limit),
267
+ used: num(r.used),
268
+ spent: r.spent === true,
269
+ window: str(r.window),
270
+ resets: stamp(r.resets),
271
+ pooled: r.pooled === true,
272
+ pool: p && str(p.state) ? { state: str(p.state), keys: num(p.keys), ready: num(p.ready), resets: stamp(p.resets) } : null,
273
+ };
274
+ }
259
275
  export function cap(raw) {
260
276
  const a = obj(raw);
261
277
  return {
package/lib/builder.js CHANGED
@@ -32,7 +32,7 @@ import { Blocks, BookOpen, CircleDot, Cpu, FolderGit2, Kanban, LayoutTemplate, M
32
32
  import { Button } from '@hanzo/ui';
33
33
  import { SessionRail } from '@hanzo/ui/chat';
34
34
  import { HanzoMark } from '@hanzo/ui/product';
35
- import { useEffect, useMemo, useState } from 'react';
35
+ import { useMemo, useState } from 'react';
36
36
  import { pinBoard } from './choice.js';
37
37
  import { Customize } from './customize/index.js';
38
38
  import { useKept, useRecents } from './data.js';
@@ -49,8 +49,6 @@ import { Artifacts, Templates } from './shelf.js';
49
49
  import { Find } from './find.js';
50
50
  import { DOCS, Slack, Who } from './foot.js';
51
51
  import { Settings } from './settings/index.js';
52
- import { Pitch } from './pitch.js';
53
- import { Bar } from './visit.js';
54
52
  function Shell() {
55
53
  const host = useHost();
56
54
  const t = useTarget();
@@ -132,26 +130,6 @@ function Name({ onPress }) {
132
130
  const { name } = useHost();
133
131
  return (_jsx(XStack, { render: "button", "aria-label": name, items: "center", onPress: onPress, children: _jsx(SizableText, { size: "$5", fontWeight: "600", color: "$ink", numberOfLines: 1, children: name }) }));
134
132
  }
135
- /**
136
- * A visitor: no runs to list, so the product's header stands where the rail
137
- * would. On New the composer fills the first screen and the pitch follows it.
138
- */
139
- function Visit() {
140
- const host = useHost();
141
- const r = route(host.path);
142
- // An address that names a part of the pitch (/#features) opens scrolled to it.
143
- useEffect(() => {
144
- const id = window.location.hash.slice(1);
145
- if (id)
146
- document.getElementById(id)?.scrollIntoView();
147
- }, []);
148
- const start = () => {
149
- const page = document.getElementById('visit');
150
- page?.scrollTo({ top: 0, behavior: 'smooth' });
151
- page?.querySelector('textarea, [contenteditable="true"], input[type="text"]')?.focus();
152
- };
153
- return (_jsxs(YStack, { flex: 1, minH: 0, minW: 0, bg: "$background", children: [_jsx(Bar, { name: _jsx(Name, { onPress: () => host.go('') }) }), r.kind === 'new' ? (_jsxs(YStack, { id: "visit", flex: 1, minH: 0, overflow: "scroll", children: [_jsx(YStack, { shrink: 0, style: { height: 'calc(100dvh - 60px)' }, children: _jsx(Pane, {}) }), _jsx(Pitch, { onStart: start })] })) : (_jsx(Pane, {}))] }));
154
- }
155
133
  /** Whichever pane the address names, beside a rail — the builder's or the host's. */
156
134
  function Pane({ onStarted }) {
157
135
  const host = useHost();
@@ -190,9 +168,6 @@ function Screens({ rail }) {
190
168
  return _jsx(Project, { slug: r.slug }, r.slug);
191
169
  if (!rail)
192
170
  return _jsx(Pane, {});
193
- // Signed out with no token to wait on: a token still resolving is a person, not a visitor.
194
- if (!host.person && !host.token() && host.signIn)
195
- return _jsx(Visit, {});
196
171
  return _jsx(Shell, {});
197
172
  }
198
173
  /**
package/lib/landing.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type Attached } from './tools.tsx';
1
2
  /** One vocabulary for the mode, on New and in a workspace. */
2
3
  export declare const MODES: readonly [{
3
4
  readonly id: 'build';
@@ -18,6 +19,26 @@ export declare const EFFORTS: readonly [{
18
19
  readonly id: 'high';
19
20
  readonly label: 'High';
20
21
  }];
22
+ /**
23
+ * What was typed here and not sent, kept in this tab.
24
+ *
25
+ * Signing in leaves the page for hanzo.id and comes back (app/enter.ts), and the
26
+ * sentence is still in the composer after. A draft its person SENT while signed
27
+ * out is sent on the return: pressing Send was the ask, and the sign-in stood
28
+ * between it and the run. Only this page writes `sent`, so no link can start a
29
+ * run on somebody's behalf — an address carrying words only fills the composer.
30
+ *
31
+ * sessionStorage, because a draft belongs to the tab it was typed in and not to
32
+ * the next person at this browser; a browser that refuses storage keeps it for
33
+ * the life of the page, as before.
34
+ */
35
+ export interface Unsent {
36
+ draft: string;
37
+ files: Attached[];
38
+ sent?: boolean;
39
+ }
40
+ /** Hold what New has unsent, or forget it once there is nothing. */
41
+ export declare function hold(u: Unsent): void;
21
42
  export declare function Landing({ onStarted }: {
22
43
  onStarted: (session: string) => void;
23
44
  }): import("react").JSX.Element;
package/lib/landing.js CHANGED
@@ -44,13 +44,32 @@ export const EFFORTS = [
44
44
  ];
45
45
  const FIRST = { repo: null, branch: '', place: '', mode: 'build', model: ENSO, effort: 'medium', ask: '' };
46
46
  const COLUMN = 768;
47
- /**
48
- * What was typed here and not sent, for as long as this document lives. Signing
49
- * in swaps a visitor's page for the rail, which draws New again in another
50
- * place; the popup keeps the document so the sentence can come along, and this
51
- * is where it waits.
52
- */
53
- let unsent = { draft: '', files: [] };
47
+ const UNSENT = 'hanzo.build.unsent';
48
+ let held = { draft: '', files: [] };
49
+ function unsent() {
50
+ try {
51
+ const v = JSON.parse(window.sessionStorage.getItem(UNSENT) ?? 'null');
52
+ if (v && typeof v.draft === 'string' && Array.isArray(v.files))
53
+ return v;
54
+ }
55
+ catch {
56
+ /* a browser that refuses storage, or a value that is not ours */
57
+ }
58
+ return held;
59
+ }
60
+ /** Hold what New has unsent, or forget it once there is nothing. */
61
+ export function hold(u) {
62
+ held = u;
63
+ try {
64
+ if (!u.draft && !u.files.length)
65
+ window.sessionStorage.removeItem(UNSENT);
66
+ else
67
+ window.sessionStorage.setItem(UNSENT, JSON.stringify(u));
68
+ }
69
+ catch {
70
+ /* kept for the life of the page */
71
+ }
72
+ }
54
73
  export function Landing({ onStarted }) {
55
74
  const host = useHost();
56
75
  const t = useTarget();
@@ -60,11 +79,10 @@ export function Landing({ onStarted }) {
60
79
  // Nothing chosen here yet: start from the person's coding defaults (Settings → Code).
61
80
  const kept = stored ?? { ...FIRST, ...prefs.code };
62
81
  const set = (patch) => keep({ ...kept, ...patch });
63
- const [draft, setDraft] = useState(kept.ask || unsent.draft);
64
- const [files, setFiles] = useState(unsent.files);
65
- useEffect(() => {
66
- unsent = { draft, files };
67
- }, [draft, files]);
82
+ const [was] = useState(unsent);
83
+ const [draft, setDraft] = useState(kept.ask || was.draft);
84
+ const [files, setFiles] = useState(was.files);
85
+ useEffect(() => hold({ draft, files }), [draft, files]);
68
86
  const [busy, setBusy] = useState(false);
69
87
  const [note, setNote] = useState('');
70
88
  const [adding, setAdding] = useState(null);
@@ -105,6 +123,7 @@ export function Landing({ onStarted }) {
105
123
  // The composer sends only a draft with words in it, and not while a send is out.
106
124
  const send = async () => {
107
125
  if (!signed) {
126
+ hold({ draft, files, sent: true });
108
127
  host.signIn?.();
109
128
  return;
110
129
  }
@@ -139,6 +158,16 @@ export function Landing({ onStarted }) {
139
158
  setBusy(false);
140
159
  }
141
160
  };
161
+ // A draft sent while signed out goes once its person is in, and only once.
162
+ const owed = useRef(was.sent === true);
163
+ useEffect(() => {
164
+ if (!signed || !owed.current || !draft.trim())
165
+ return;
166
+ owed.current = false;
167
+ void send();
168
+ // Sent once, on the sign-in that it waited for.
169
+ // eslint-disable-next-line react-hooks/exhaustive-deps
170
+ }, [signed]);
142
171
  /** Start the agent that finds this codebase's environment, and open it. A refusal is the dialog's to say. */
143
172
  const setup = async () => {
144
173
  const run = await start(t, { prompt: SETUP, repo: codebase, mode: 'setup' });
@@ -8,7 +8,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
8
8
  import { SizableText, XStack, YStack } from '@hanzo/gui';
9
9
  import { Button, Input } from '@hanzo/ui';
10
10
  import { useState } from 'react';
11
- import { balance, caps, cents, chosen, credit, label, lift, limit, methods, money, month, monthly, spend, topup, } from '../api/billing.js';
11
+ import { allowance, balance, caps, cents, chosen, credit, label, lift, limit, methods, money, month, monthly, spend, topup, } from '../api/billing.js';
12
12
  import { useRead } from '../data.js';
13
13
  import { useHost, useTarget } from '../host.js';
14
14
  import { path } from '../route.js';
@@ -40,11 +40,21 @@ function resets(iso) {
40
40
  return `Resets in ${Math.floor(mins / 60)} hr ${mins % 60} min`;
41
41
  return `Resets ${day(iso)}`;
42
42
  }
43
+ const POOL = { available: 'Available', busy: 'Busy', exhausted: 'Exhausted' };
44
+ /** `Refills at 16:00 UTC`, for a window or the pool: the UTC time it opens again, with the date when not today. */
45
+ function refills(iso) {
46
+ const at = iso ? new Date(iso) : null;
47
+ if (!at || Number.isNaN(at.getTime()))
48
+ return '';
49
+ const hhmm = `${String(at.getUTCHours()).padStart(2, '0')}:${String(at.getUTCMinutes()).padStart(2, '0')} UTC`;
50
+ return at.toISOString().slice(0, 10) === new Date().toISOString().slice(0, 10) ? `Refills at ${hhmm}` : `Refills ${day(iso)}, ${hhmm}`;
51
+ }
43
52
  export function Usage() {
44
53
  const host = useHost();
45
54
  const t = useTarget();
46
55
  const signed = Boolean(host.person);
47
56
  const plan = useRead(signed ? () => month(t) : null, null, [t, signed]);
57
+ const free = useRead(signed ? () => allowance(t) : null, null, [t, signed]);
48
58
  const left = useRead(signed ? () => balance(t) : null, 0, [t, signed]);
49
59
  const granted = useRead(signed ? () => credit(t) : null, 0, [t, signed]);
50
60
  const spent = useRead(signed ? () => spend(t) : null, null, [t, signed]);
@@ -103,7 +113,7 @@ export function Usage() {
103
113
  ceilings.reload();
104
114
  });
105
115
  };
106
- return (_jsxs(YStack, { gap: "$6", children: [_jsx(Heading, { title: "Usage", detail: `What ${host.org ?? 'this organization'} has used, has left, and may spend.` }), _jsx(Group, { title: "Plan usage limits", detail: m?.plan ? `Included with ${m.plan}.` : 'What the plan includes.', children: plan.error ? (_jsx(Soft, { children: plan.error.message })) : plan.loading && !m ? (_jsx(Soft, { children: "Reading the plan\u2019s limits\u2026" })) : !m || (windows.length === 0 && m.included === 0) ? (_jsx(Card, { children: _jsx(Soft, { children: "This plan sets no usage limits. Usage is paid from the balance." }) })) : (_jsxs(Card, { children: [m.included > 0 ? (_jsx(Meter, { first: true, title: "Included spend", used: m.used, of: m.included, says: `${money(m.used)} of ${money(m.included)}${m.overage ? ` · ${money(m.overage)} over` : ''}` })) : null, windows.map((w, i) => (_jsx(Meter, { first: i === 0 && m.included === 0, title: `${SPAN[w.span] ?? w.span} · ${w.used.toLocaleString()} of ${w.limit.toLocaleString()} requests`, used: w.used, of: w.limit, says: `${Math.round((Math.min(w.used, w.limit) / w.limit) * 100)}% used${resets(w.resets) ? ` · ${resets(w.resets)}` : ''}` }, w.span)))] })) }), _jsx(Group, { title: "Balance", detail: "Prepaid money pays for usage past the plan. Credits are spent first.", action: buying || !host.admin ? undefined : (_jsx(Button, { size: "sm", variant: "outline", onPress: () => setBuying(true), children: "Buy more" })), children: _jsxs(Card, { children: [_jsx(Row, { first: true, title: "Balance", detail: "What is left to spend", trailing: _jsx(Figure, { read: left }) }), _jsx(Row, { title: "Credits", detail: "Granted, and spent before the balance", trailing: _jsx(Figure, { read: granted }) }), _jsx(Row, { title: "Spent this month", detail: s && !s.known ? 'The ledger did not answer, so this is not a measurement' : undefined, trailing: _jsx(Figure, { read: { ...spent, value: s?.total ?? 0 } }) }), buying ? (_jsx(YStack, { gap: "$2", px: "$3", py: "$3", borderTopWidth: 1, borderColor: "$borderColor", children: card ? (_jsxs(_Fragment, { children: [_jsxs(XStack, { gap: "$2", items: "center", flexWrap: "wrap", children: [_jsx(YStack, { width: 140, children: _jsx(Input, { value: amount, onChangeText: setAmount, "aria-label": "Amount in dollars", placeholder: "25", inputMode: "decimal" }) }), _jsxs(SizableText, { flex: 1, minW: 160, size: "$1", color: "$soft", children: ["Charged to ", label(card)] })] }), _jsxs(XStack, { gap: "$2", justify: "flex-end", children: [_jsx(Button, { size: "sm", variant: "ghost", onPress: () => setBuying(false), children: "Cancel" }), _jsx(Button, { size: "sm", disabled: working, onPress: buy, children: adding ? `Add ${money(adding)}` : 'Add funds' })] })] })) : (_jsxs(XStack, { gap: "$2", items: "center", flexWrap: "wrap", children: [_jsx(SizableText, { flex: 1, minW: 160, size: "$2", color: "$soft", children: cards.loading ? 'Reading cards…' : 'Add a card in Billing to buy more.' }), _jsx(Button, { size: "sm", variant: "outline", onPress: () => host.go(path({ kind: 'settings', section: 'billing' })), children: "Billing" })] })) })) : null] }) }), _jsx(Group, { title: "Spend this month", detail: "By what it paid for.", children: spent.error ? (_jsx(Soft, { children: spent.error.message })) : !s ? (_jsx(Soft, { children: "Reading spend\u2026" })) : s.categories.length === 0 ? (_jsx(Card, { children: _jsx(Soft, { children: "Nothing spent this month." }) })) : (_jsx(Card, { children: s.categories.map((c, i) => (_jsx(Meter, { first: i === 0, title: c.name, used: c.cents, of: top, says: money(c.cents), limit: false }, c.name))) })) }), _jsxs(Group, { title: "Monthly limit", detail: "Spend past it is refused until the month starts over.", children: [ceilings.error ? (_jsx(Soft, { children: ceilings.error.message })) : (_jsxs(Card, { children: [cap ? (_jsx(Meter, { first: true, title: `${money(cap.threshold)} a month`, used: cap.spent ?? 0, of: cap.threshold, says: [cap.spent === null ? 'Spend unknown' : `${money(cap.spent)} spent`, resets(cap.resets)].filter(Boolean).join(' · ') })) : (_jsx(Row, { first: true, title: "No monthly limit", detail: "Spend is bounded by the balance alone." })), others.map((c) => (_jsx(Row, { title: c.title || 'Limit', detail: [c.project && `project ${c.project}`, c.service && `service ${c.service}`, c.enforce ? 'refuses spend past it' : 'warns only']
116
+ return (_jsxs(YStack, { gap: "$6", children: [_jsx(Heading, { title: "Usage", detail: `What ${host.org ?? 'this organization'} has used, has left, and may spend.` }), free.value?.pooled && free.value.limit > 0 ? (_jsx(Group, { title: "Free plan", detail: "Free \u2014 limited usage, from a pool shared by all free users.", action: _jsx(Button, { size: "sm", variant: "outline", onPress: () => host.go(path({ kind: 'screen', screen: 'plans' })), children: "Upgrade" }), children: _jsxs(Card, { children: [_jsx(Meter, { first: true, title: `${Math.max(0, free.value.limit - free.value.used).toLocaleString()} of ${free.value.limit.toLocaleString()} left ${free.value.window === 'hour' ? 'this hour' : free.value.window === 'day' ? 'today' : 'this period'}`, used: free.value.used, of: free.value.limit, says: [`${free.value.used.toLocaleString()} used`, refills(free.value.resets)].filter(Boolean).join(' · ') }), free.value.pool ? (_jsx(Row, { title: "Shared pool", detail: `${free.value.pool.ready} of ${free.value.pool.keys} accounts serving${free.value.pool.state === 'exhausted' && refills(free.value.pool.resets) ? ` · ${refills(free.value.pool.resets)}` : ''}`, trailing: _jsx(SizableText, { size: "$2", color: free.value.pool.state === 'exhausted' ? '$red10' : '$ink', children: POOL[free.value.pool.state] ?? free.value.pool.state }) })) : null] }) })) : null, _jsx(Group, { title: "Plan usage limits", detail: m?.plan ? `Included with ${m.plan}.` : 'What the plan includes.', children: plan.error ? (_jsx(Soft, { children: plan.error.message })) : plan.loading && !m ? (_jsx(Soft, { children: "Reading the plan\u2019s limits\u2026" })) : !m || (windows.length === 0 && m.included === 0) ? (_jsx(Card, { children: _jsx(Soft, { children: "This plan sets no usage limits. Usage is paid from the balance." }) })) : (_jsxs(Card, { children: [m.included > 0 ? (_jsx(Meter, { first: true, title: "Included spend", used: m.used, of: m.included, says: `${money(m.used)} of ${money(m.included)}${m.overage ? ` · ${money(m.overage)} over` : ''}` })) : null, windows.map((w, i) => (_jsx(Meter, { first: i === 0 && m.included === 0, title: `${SPAN[w.span] ?? w.span} · ${w.used.toLocaleString()} of ${w.limit.toLocaleString()} requests`, used: w.used, of: w.limit, says: `${Math.round((Math.min(w.used, w.limit) / w.limit) * 100)}% used${resets(w.resets) ? ` · ${resets(w.resets)}` : ''}` }, w.span)))] })) }), _jsx(Group, { title: "Balance", detail: "Prepaid money pays for usage past the plan. Credits are spent first.", action: buying || !host.admin ? undefined : (_jsx(Button, { size: "sm", variant: "outline", onPress: () => setBuying(true), children: "Buy more" })), children: _jsxs(Card, { children: [_jsx(Row, { first: true, title: "Balance", detail: "What is left to spend", trailing: _jsx(Figure, { read: left }) }), _jsx(Row, { title: "Credits", detail: "Granted, and spent before the balance", trailing: _jsx(Figure, { read: granted }) }), _jsx(Row, { title: "Spent this month", detail: s && !s.known ? 'The ledger did not answer, so this is not a measurement' : undefined, trailing: _jsx(Figure, { read: { ...spent, value: s?.total ?? 0 } }) }), buying ? (_jsx(YStack, { gap: "$2", px: "$3", py: "$3", borderTopWidth: 1, borderColor: "$borderColor", children: card ? (_jsxs(_Fragment, { children: [_jsxs(XStack, { gap: "$2", items: "center", flexWrap: "wrap", children: [_jsx(YStack, { width: 140, children: _jsx(Input, { value: amount, onChangeText: setAmount, "aria-label": "Amount in dollars", placeholder: "25", inputMode: "decimal" }) }), _jsxs(SizableText, { flex: 1, minW: 160, size: "$1", color: "$soft", children: ["Charged to ", label(card)] })] }), _jsxs(XStack, { gap: "$2", justify: "flex-end", children: [_jsx(Button, { size: "sm", variant: "ghost", onPress: () => setBuying(false), children: "Cancel" }), _jsx(Button, { size: "sm", disabled: working, onPress: buy, children: adding ? `Add ${money(adding)}` : 'Add funds' })] })] })) : (_jsxs(XStack, { gap: "$2", items: "center", flexWrap: "wrap", children: [_jsx(SizableText, { flex: 1, minW: 160, size: "$2", color: "$soft", children: cards.loading ? 'Reading cards…' : 'Add a card in Billing to buy more.' }), _jsx(Button, { size: "sm", variant: "outline", onPress: () => host.go(path({ kind: 'settings', section: 'billing' })), children: "Billing" })] })) })) : null] }) }), _jsx(Group, { title: "Spend this month", detail: "By what it paid for.", children: spent.error ? (_jsx(Soft, { children: spent.error.message })) : !s ? (_jsx(Soft, { children: "Reading spend\u2026" })) : s.categories.length === 0 ? (_jsx(Card, { children: _jsx(Soft, { children: "Nothing spent this month." }) })) : (_jsx(Card, { children: s.categories.map((c, i) => (_jsx(Meter, { first: i === 0, title: c.name, used: c.cents, of: top, says: money(c.cents), limit: false }, c.name))) })) }), _jsxs(Group, { title: "Monthly limit", detail: "Spend past it is refused until the month starts over.", children: [ceilings.error ? (_jsx(Soft, { children: ceilings.error.message })) : (_jsxs(Card, { children: [cap ? (_jsx(Meter, { first: true, title: `${money(cap.threshold)} a month`, used: cap.spent ?? 0, of: cap.threshold, says: [cap.spent === null ? 'Spend unknown' : `${money(cap.spent)} spent`, resets(cap.resets)].filter(Boolean).join(' · ') })) : (_jsx(Row, { first: true, title: "No monthly limit", detail: "Spend is bounded by the balance alone." })), others.map((c) => (_jsx(Row, { title: c.title || 'Limit', detail: [c.project && `project ${c.project}`, c.service && `service ${c.service}`, c.enforce ? 'refuses spend past it' : 'warns only']
107
117
  .filter(Boolean)
108
118
  .join(' · '), trailing: _jsx(SizableText, { size: "$2", color: "$ink", children: c.threshold ? money(c.threshold) : '—' }) }, c.id))), host.admin ? (_jsxs(XStack, { gap: "$2", items: "center", px: "$3", py: "$3", borderTopWidth: 1, borderColor: "$borderColor", flexWrap: "wrap", children: [_jsx(YStack, { width: 160, children: _jsx(Input, { value: ceiling, onChangeText: setCeiling, "aria-label": "Monthly limit in dollars", placeholder: cap ? String(cap.threshold / 100) : '500', inputMode: "decimal" }) }), _jsxs(XStack, { flex: 1, gap: "$2", justify: "flex-end", children: [cap ? (_jsx(Button, { size: "sm", variant: "ghost", disabled: working, onPress: () => void act(() => lift(t, cap.id), 'The monthly limit is lifted', ceilings.reload), children: "Remove" })) : null, _jsx(Button, { size: "sm", variant: "outline", disabled: working || !ceiling.trim(), onPress: setLimit, children: cap ? 'Change limit' : 'Set limit' })] })] })) : null] })), !host.admin ? (_jsx(SizableText, { size: "$1", color: "$soft", children: "An org admin sets the monthly limit." })) : null] }), _jsx(Note, { children: note })] }));
109
119
  }
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@hanzo/build",
3
- "version": "0.2.19",
3
+ "version": "0.2.21",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0 OR MIT",
6
- "description": "Hanzo Build — say what you want, watch it built, deployed and running. The builder as a page (hanzo.build) and as a component (hanzo.ai/dev).",
6
+ "description": "Hanzo Build — say what you want, watch it built, deployed and running. The builder as a component, mounted by the Hanzo app at hanzo.ai.",
7
7
  "engines": {
8
8
  "node": ">=22.18"
9
9
  },
package/lib/pitch.d.ts DELETED
@@ -1,4 +0,0 @@
1
- export declare const INSTALL = "curl -fsSL hanzo.sh | sh";
2
- export declare function Pitch({ onStart }: {
3
- onStart: () => void;
4
- }): import("react").JSX.Element;
package/lib/pitch.js DELETED
@@ -1,39 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- /**
3
- * What a visitor reads under New on hanzo.build: the same agent on their own
4
- * computer, one line to install, and what it brings to a run. Each card names
5
- * something this page or the CLI does today, and nothing it does not.
6
- */
7
- import { SizableText, XStack, YStack } from '@hanzo/gui';
8
- import { Check, Copy, GitPullRequest, LayoutTemplate, ListChecks, MonitorPlay, Plug, Server, Sparkles, Workflow } from '@hanzogui/lucide-icons-2';
9
- import { Button } from '@hanzo/ui';
10
- import { useState } from 'react';
11
- import { DOCS } from './foot.js';
12
- export const INSTALL = 'curl -fsSL hanzo.sh | sh';
13
- const FEATURES = [
14
- { icon: _jsx(Sparkles, { size: 18 }), title: 'Skills', body: 'Teach it a workflow once. A skill loads when a task matches, or when you call it by name.' },
15
- { icon: _jsx(ListChecks, { size: 18 }), title: 'Plan first', body: 'Start a run in plan mode. Nothing is built until you approve the plan.' },
16
- { icon: _jsx(Plug, { size: 18 }), title: 'Connectors and plugins', body: 'MCP servers, skills and agents behind one install. Your org’s credentials reach a run through a relay, never the sandbox.' },
17
- { icon: _jsx(Server, { size: 18 }), title: 'Environments', body: 'Point it at a repository. An agent works out install and start, and every later run begins from that setup.' },
18
- { icon: _jsx(MonitorPlay, { size: 18 }), title: 'Desktop and terminal', body: 'Watch each run’s browser and shell live, beside the conversation.' },
19
- { icon: _jsx(GitPullRequest, { size: 18 }), title: 'Review', body: 'Every run ends as a diff on its own branch, with commits and a pull request to review.' },
20
- { icon: _jsx(LayoutTemplate, { size: 18 }), title: 'Templates to live', body: 'Start from a working app and publish it to a live address.' },
21
- { icon: _jsx(Workflow, { size: 18 }), title: 'Automations', body: 'Hand it repeating work, and it runs on your schedule.' },
22
- ];
23
- /** One line to copy, with a button that copies it. */
24
- function Line({ text }) {
25
- const [copied, setCopied] = useState(false);
26
- const copy = () => {
27
- void navigator.clipboard.writeText(text).then(() => {
28
- setCopied(true);
29
- setTimeout(() => setCopied(false), 1500);
30
- });
31
- };
32
- return (_jsxs(XStack, { items: "center", gap: "$3", px: "$4", height: 52, rounded: "$4", borderWidth: 1, borderColor: "$borderColor", bg: "$panel", width: "100%", maxW: 560, children: [_jsx(SizableText, { size: "$3", color: "$soft", fontFamily: "$mono", "aria-hidden": true, children: "$" }), _jsx(SizableText, { flex: 1, size: "$3", color: "$ink", fontFamily: "$mono", numberOfLines: 1, children: text }), _jsx(Button, { variant: "ghost", size: "icon-sm", "aria-label": copied ? 'Copied' : 'Copy the install command', onPress: copy, children: copied ? _jsx(Check, { size: 16 }) : _jsx(Copy, { size: 16 }) })] }));
33
- }
34
- function Card({ icon, title, body }) {
35
- return (_jsxs(YStack, { gap: "$2", p: "$5", rounded: "$4", borderWidth: 1, borderColor: "$borderColor", bg: "$panel", flex: 1, minW: 240, children: [_jsxs(XStack, { items: "center", gap: "$2.5", children: [icon, _jsx(SizableText, { size: "$4", fontWeight: "600", color: "$ink", children: title })] }), _jsx(SizableText, { size: "$3", color: "$soft", children: body })] }));
36
- }
37
- export function Pitch({ onStart }) {
38
- return (_jsxs(YStack, { items: "center", px: "$4", pb: "$12", gap: "$12", children: [_jsxs(YStack, { id: "features", width: "100%", maxW: 1080, items: "center", gap: "$5", pt: "$6", children: [_jsx(SizableText, { size: "$2", color: "$soft", fontFamily: "$mono", children: "Powered by Zen 6 \u00B7 routed by Enso" }), _jsx(SizableText, { role: "heading", "aria-level": 2, size: "$9", color: "$ink", style: { textAlign: 'center' }, children: "Hanzo Dev" }), _jsx(SizableText, { size: "$4", color: "$soft", maxW: 620, style: { textAlign: 'center' }, children: "The agent. It runs in your terminal, on any codebase, in any language. This page is that same agent in the browser." }), _jsx(Line, { text: INSTALL }), _jsx(SizableText, { size: "$2", color: "$soft", fontFamily: "$mono", children: "dev opens it. dev \"fix the failing test\" hands it a task." }), _jsx("a", { href: DOCS, style: { color: 'inherit', textDecoration: 'none' }, children: _jsx(XStack, { items: "center", height: 34, px: "$3", rounded: "$3", borderWidth: 1, borderColor: "$borderColor", hoverStyle: { bg: '$hover' }, children: _jsx(SizableText, { size: "$3", color: "$ink", children: "Read the docs" }) }) })] }), _jsxs(YStack, { width: "100%", maxW: 1080, gap: "$5", children: [_jsx(SizableText, { role: "heading", "aria-level": 2, size: "$7", color: "$ink", children: "Everything a run needs" }), _jsx(XStack, { flexWrap: "wrap", gap: "$3", children: FEATURES.map((f) => (_jsx(Card, { ...f }, f.title))) })] }), _jsxs(YStack, { width: "100%", maxW: 1080, items: "center", gap: "$4", py: "$8", rounded: "$6", borderWidth: 1, borderColor: "$borderColor", bg: "$panel", children: [_jsx(SizableText, { role: "heading", "aria-level": 2, size: "$7", color: "$ink", style: { textAlign: 'center' }, children: "Say what you want built." }), _jsx(SizableText, { size: "$3", color: "$soft", style: { textAlign: 'center' }, px: "$4", children: "In the browser here, or in your terminal." }), _jsx(Button, { variant: "primary", size: "sm", onPress: onStart, children: "Start building" })] })] }));
39
- }
package/lib/visit.d.ts DELETED
@@ -1,31 +0,0 @@
1
- import { type ReactNode } from 'react';
2
- export declare const PLACES: readonly [{
3
- readonly label: 'Download';
4
- readonly href: '/#features';
5
- }, {
6
- readonly label: 'Enterprise';
7
- readonly href: "https://hanzo.ai/enterprise";
8
- }, {
9
- readonly label: 'Pricing';
10
- readonly href: "https://hanzo.ai/pricing";
11
- }];
12
- export declare const RESOURCES: readonly [{
13
- readonly label: 'Docs';
14
- readonly href: 'https://docs.hanzo.ai';
15
- }, {
16
- readonly label: 'Blog';
17
- readonly href: "https://hanzo.ai/blog";
18
- }, {
19
- readonly label: 'Customers';
20
- readonly href: "https://hanzo.ai/customers";
21
- }, {
22
- readonly label: 'Learn';
23
- readonly href: "https://hanzo.ai/learn";
24
- }, {
25
- readonly label: 'Support';
26
- readonly href: "https://hanzo.ai/support";
27
- }];
28
- /** The header a visitor sees. `name` is the wordmark the rail shows once signed in. */
29
- export declare function Bar({ name }: {
30
- name: ReactNode;
31
- }): import("react").JSX.Element;
package/lib/visit.js DELETED
@@ -1,53 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- /**
3
- * The page to a visitor: the product's header over New.
4
- *
5
- * Signed out there are no runs to list, so the rail gives way to a header —
6
- * Download (Hanzo Dev, under New, pitch.tsx), Resources, Enterprise, Pricing — and the two
7
- * doors in, which are one door: IAM's page offers both. New stays the page, and
8
- * sending from it asks the visitor to sign in with the sentence kept
9
- * (landing.tsx). Below md the four places fold into one menu beside Log in.
10
- */
11
- import { Popover, SizableText, XStack, YStack } from '@hanzo/gui';
12
- import { ChevronDown, Menu } from '@hanzogui/lucide-icons-2';
13
- import { Button } from '@hanzo/ui';
14
- import { MenuLabel, MenuRow, MenuRule } from '@hanzo/ui/product';
15
- import { useState } from 'react';
16
- import { useHost } from './host.js';
17
- const SITE = 'https://hanzo.ai';
18
- export const PLACES = [
19
- { label: 'Download', href: '/#features' },
20
- { label: 'Enterprise', href: `${SITE}/enterprise` },
21
- { label: 'Pricing', href: `${SITE}/pricing` },
22
- ];
23
- export const RESOURCES = [
24
- { label: 'Docs', href: 'https://docs.hanzo.ai' },
25
- { label: 'Blog', href: `${SITE}/blog` },
26
- { label: 'Customers', href: `${SITE}/customers` },
27
- { label: 'Learn', href: `${SITE}/learn` },
28
- { label: 'Support', href: `${SITE}/support` },
29
- ];
30
- const leave = (href) => window.location.assign(href);
31
- /** One place in the header: an address, drawn as text. */
32
- function Place({ href, children }) {
33
- return (_jsx("a", { href: href, style: { color: 'inherit', textDecoration: 'none' }, children: _jsx(XStack, { items: "center", height: 34, px: "$2.5", rounded: "$3", hoverStyle: { bg: '$hover' }, children: _jsx(SizableText, { size: "$3", color: "$ink", children: children }) }) }));
34
- }
35
- function Resources() {
36
- const [open, setOpen] = useState(false);
37
- return (_jsxs(Popover, { open: open, onOpenChange: setOpen, placement: "bottom-start", offset: 6, children: [_jsx(Popover.Trigger, { asChild: true, children: _jsxs(XStack, { render: "button", "aria-label": "Resources", "aria-expanded": open, items: "center", gap: "$1", height: 34, px: "$2.5", rounded: "$3", hoverStyle: { bg: '$hover' }, children: [_jsx(SizableText, { size: "$3", color: "$ink", children: "Resources" }), _jsx(ChevronDown, { size: 14, color: "$soft" })] }) }), _jsx(Popover.Content, { role: "menu", "aria-label": "Resources", items: "stretch", borderWidth: 1, elevation: "$2", p: "$1", width: 220, bg: "$panel", borderColor: "$borderColor", children: _jsx(YStack, { gap: "$1", children: RESOURCES.map((r) => (_jsx(MenuRow, { label: r.label, onPress: () => leave(r.href) }, r.label))) }) })] }));
38
- }
39
- /** Below md: every place, the resources and the door, in one menu. */
40
- function Fold({ onEnter }) {
41
- const [open, setOpen] = useState(false);
42
- return (_jsxs(Popover, { open: open, onOpenChange: setOpen, placement: "bottom-end", offset: 6, stayInFrame: true, children: [_jsx(Popover.Trigger, { asChild: true, children: _jsx(Button, { variant: "ghost", size: "icon-sm", "aria-label": "Menu", children: _jsx(Menu, { size: 18 }) }) }), _jsx(Popover.Content, { role: "menu", "aria-label": "Menu", items: "stretch", borderWidth: 1, elevation: "$2", p: "$1", width: 260, bg: "$panel", borderColor: "$borderColor", children: _jsxs(YStack, { gap: "$1", children: [PLACES.map((p) => (_jsx(MenuRow, { label: p.label, onPress: () => leave(p.href) }, p.label))), _jsx(MenuRule, {}), _jsx(MenuLabel, { children: "Resources" }), RESOURCES.map((r) => (_jsx(MenuRow, { label: r.label, onPress: () => leave(r.href) }, r.label))), _jsx(MenuRule, {}), _jsx(MenuRow, { label: "Sign up", onPress: () => {
43
- setOpen(false);
44
- onEnter();
45
- } })] }) })] }));
46
- }
47
- /** The header a visitor sees. `name` is the wordmark the rail shows once signed in. */
48
- export function Bar({ name }) {
49
- const host = useHost();
50
- const enter = () => host.signIn?.();
51
- const [features, enterprise, pricing] = PLACES;
52
- return (_jsxs(XStack, { role: "banner", height: 60, px: "$4", gap: "$2", items: "center", shrink: 0, $md: { px: '$6' }, children: [name, _jsxs(XStack, { role: "navigation", "aria-label": "Site", items: "center", gap: "$1", ml: "$4", "$max-md": { display: 'none' }, children: [_jsx(Place, { href: features.href, children: features.label }), _jsx(Resources, {}), _jsx(Place, { href: enterprise.href, children: enterprise.label }), _jsx(Place, { href: pricing.href, children: pricing.label })] }), _jsx(XStack, { flex: 1 }), _jsx(Button, { variant: "ghost", size: "sm", onPress: enter, children: "Log in" }), _jsx(XStack, { "$max-md": { display: 'none' }, children: _jsx(Button, { size: "sm", variant: "primary", onPress: enter, children: "Sign up" }) }), _jsx(XStack, { $md: { display: 'none' }, children: _jsx(Fold, { onEnter: enter }) })] }));
53
- }