@hanzo/build 0.2.18 → 0.2.20

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
@@ -193,7 +201,7 @@ picker and open-in-tab; Share and Publish; the console dock under it.
193
201
 
194
202
  | call | what |
195
203
  |---|---|
196
- | `POST /v1/agent/coding` | start a run → 202 `{sessionId, …}` |
204
+ | `POST /v1/agent/coding` | start a run → 202 `{sessionId, …}`; with no `repo` the platform starts a new project named from the ask |
197
205
  | `GET /v1/agent/sessions?kind=coding` | Recents, and a project's runs |
198
206
  | `GET /v1/agent/sessions/{id}` · `GET /v1/agent/sessions/stream?root=` | a run, and its live feed (SSE over fetch) |
199
207
  | `POST /v1/agent/sessions/{id}/message` · `/pause` · `/resume` · `/stop` | steer, pause, resume a machine's run, stop |
package/lib/builder.d.ts CHANGED
@@ -1,4 +1,14 @@
1
+ import { type RailLink } from '@hanzo/ui/chat';
1
2
  import { type Host } from './host.tsx';
3
+ /**
4
+ * The builder's places, as a rail draws them: four in view and the rest under
5
+ * More. Every one moves the builder through `go`, so a host that draws its own
6
+ * rail lists the same places, in the same words, as the builder's rail does.
7
+ */
8
+ export declare function nav(host: Host, go: (path: string) => void): {
9
+ links: RailLink[];
10
+ more: RailLink[];
11
+ };
2
12
  /**
3
13
  * The builder. Mount it under a gui root (`<Hanzo>`), in a box with a height.
4
14
  *
package/lib/builder.js CHANGED
@@ -24,15 +24,15 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
24
24
  * bar and panes — and its mark leads back here.
25
25
  *
26
26
  * A host with a rail of its own mounts `<Builder rail={false}>` and draws the
27
- * builder's places and runs in that rail with `DevSection` (section.tsx): one
28
- * left column, never two.
27
+ * builder's places (`nav`) and runs in that rail (`useSessions`, or `DevSection`,
28
+ * section.tsx): one left column, never two.
29
29
  */
30
30
  import { SizableText, XStack, YStack } from '@hanzo/gui';
31
31
  import { Blocks, BookOpen, CircleDot, Cpu, FolderGit2, Kanban, LayoutTemplate, Menu, Search, SlidersHorizontal, Workflow } from '@hanzogui/lucide-icons-2';
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();
@@ -72,12 +70,31 @@ function Shell() {
72
70
  list.sort((a, b) => Number(b.status === 'running') - Number(a.status === 'running'));
73
71
  return list;
74
72
  }, [recents.value, order]);
75
- const screen = r.kind === 'screen' ? r.screen : '';
76
73
  // The top left, as on claude.ai: the builder's name, which leads to New, and under
77
74
  // it the search that finds a run. The organization is the account's, so it is
78
75
  // switched from the account menu at the foot. Beside the rail from md up, beside
79
76
  // the drawer's button below it — one place at a time.
80
77
  const name = _jsx(Name, { onPress: () => go('') });
78
+ const { links, more } = nav(host, go);
79
+ return (_jsxs(XStack, { flex: 1, minH: 0, minW: 0, bg: "$background", children: [_jsxs(YStack, { position: "relative", shrink: 0, children: [_jsx(SessionRail, { onNew: () => go(''), fresh: r.kind === 'new', links: links, more: more, recents: rows, active: r.kind === 'run' ? r.id : null, onOpen: (id) => go(id), onSort: () => setOrder(order === 'newest' ? 'running' : 'newest'), sortLabel: order === 'newest' ? 'Show running first' : 'Show newest first', empty: _jsx(SizableText, { size: "$1", color: "$soft", px: "$2", children: !host.person ? 'Sign in to see your runs.' : recents.error ? recents.error.message : recents.loading ? 'Reading…' : 'No runs yet.' }), notice: host.person && !slack ? (_jsx(Slack, { onOpen: () => go(path({ kind: 'settings', section: 'integrations' })), onDismiss: () => setSlack(true) })) : undefined, account: host.person
80
+ ? {
81
+ // Who, and in which organization, as claude.ai says who and on which plan.
82
+ name: [host.person.name || host.person.email, host.org].filter(Boolean).join(' · '),
83
+ onPress: () => {
84
+ setDrawer(false);
85
+ setMenu(true);
86
+ },
87
+ }
88
+ : { name: 'Sign in', onPress: () => host.signIn?.() }, collapsed: collapsed, onCollapse: setCollapsed, mark: _jsx(Brand, { org: host.org }), pt: collapsed ? undefined : 92, open: drawer, onOpenChange: setDrawer, label: "Runs" }), collapsed ? null : (_jsxs(YStack, { position: "absolute", t: 10, l: 8, r: 8, z: 2, gap: "$2.5", "$max-md": { display: 'none' }, children: [_jsx(XStack, { px: "$2.5", children: name }), _jsxs(XStack, { render: "button", "aria-label": "Search runs", onPress: () => setFinding(true), items: "center", gap: "$2", px: "$2.5", height: 34, rounded: "$3", borderWidth: 1, borderColor: "$borderColor", bg: "$panel", hoverStyle: { bg: '$hover' }, children: [_jsx(Search, { size: 15, color: "$soft" }), _jsx(SizableText, { size: "$2", color: "$soft", children: "Search" })] })] })), host.person ? _jsx(Who, { open: menu, onOpenChange: setMenu, narrow: collapsed }) : null] }), _jsxs(YStack, { flex: 1, minW: 0, minH: 0, position: "relative", children: [_jsxs(XStack, { height: 44, px: "$2", gap: "$2", items: "center", shrink: 0, $md: { display: 'none' }, children: [_jsx(Button, { variant: "ghost", size: "icon-sm", onPress: () => setDrawer(true), "aria-label": "Open runs", children: _jsx(Menu, { size: 18 }) }), name, _jsx(XStack, { flex: 1 }), _jsx(Button, { variant: "ghost", size: "icon-sm", onPress: () => setFinding(true), "aria-label": "Search runs", children: _jsx(Search, { size: 18 }) })] }), _jsx(Pane, { onStarted: () => recents.reload() })] }), _jsx(Find, { open: finding, onOpenChange: setFinding, recents: rows, onOpen: (id) => { setFinding(false); go(id); } })] }));
89
+ }
90
+ /**
91
+ * The builder's places, as a rail draws them: four in view and the rest under
92
+ * More. Every one moves the builder through `go`, so a host that draws its own
93
+ * rail lists the same places, in the same words, as the builder's rail does.
94
+ */
95
+ export function nav(host, go) {
96
+ const r = route(host.path);
97
+ const screen = r.kind === 'screen' ? r.screen : '';
81
98
  const links = [
82
99
  { id: 'projects', label: 'Projects', icon: _jsx(Kanban, { size: 16 }), onPress: () => go(path({ kind: 'screen', screen: 'projects' })), active: screen === 'projects' },
83
100
  { id: 'artifacts', label: 'Artifacts', icon: _jsx(Blocks, { size: 16 }), onPress: () => go(path({ kind: 'screen', screen: 'artifacts' })), active: screen === 'artifacts' },
@@ -106,42 +123,13 @@ function Shell() {
106
123
  },
107
124
  { id: 'docs', label: 'Docs', icon: _jsx(BookOpen, { size: 16 }), onPress: () => window.open(DOCS, '_blank', 'noopener,noreferrer') },
108
125
  ];
109
- return (_jsxs(XStack, { flex: 1, minH: 0, minW: 0, bg: "$background", children: [_jsxs(YStack, { position: "relative", shrink: 0, children: [_jsx(SessionRail, { onNew: () => go(''), fresh: r.kind === 'new', links: links, more: more, recents: rows, active: r.kind === 'run' ? r.id : null, onOpen: (id) => go(id), onSort: () => setOrder(order === 'newest' ? 'running' : 'newest'), sortLabel: order === 'newest' ? 'Show running first' : 'Show newest first', empty: _jsx(SizableText, { size: "$1", color: "$soft", px: "$2", children: !host.person ? 'Sign in to see your runs.' : recents.error ? recents.error.message : recents.loading ? 'Reading…' : 'No runs yet.' }), notice: host.person && !slack ? (_jsx(Slack, { onOpen: () => go(path({ kind: 'settings', section: 'integrations' })), onDismiss: () => setSlack(true) })) : undefined, account: host.person
110
- ? {
111
- // Who, and in which organization, as claude.ai says who and on which plan.
112
- name: [host.person.name || host.person.email, host.org].filter(Boolean).join(' · '),
113
- onPress: () => {
114
- setDrawer(false);
115
- setMenu(true);
116
- },
117
- }
118
- : { name: 'Sign in', onPress: () => host.signIn?.() }, collapsed: collapsed, onCollapse: setCollapsed, mark: _jsx(Brand, { org: host.org }), pt: collapsed ? undefined : 92, open: drawer, onOpenChange: setDrawer, label: "Runs" }), collapsed ? null : (_jsxs(YStack, { position: "absolute", t: 10, l: 8, r: 8, z: 2, gap: "$2.5", "$max-md": { display: 'none' }, children: [_jsx(XStack, { px: "$2.5", children: name }), _jsxs(XStack, { render: "button", "aria-label": "Search runs", onPress: () => setFinding(true), items: "center", gap: "$2", px: "$2.5", height: 34, rounded: "$3", borderWidth: 1, borderColor: "$borderColor", bg: "$panel", hoverStyle: { bg: '$hover' }, children: [_jsx(Search, { size: 15, color: "$soft" }), _jsx(SizableText, { size: "$2", color: "$soft", children: "Search" })] })] })), host.person ? _jsx(Who, { open: menu, onOpenChange: setMenu, narrow: collapsed }) : null] }), _jsxs(YStack, { flex: 1, minW: 0, minH: 0, position: "relative", children: [_jsxs(XStack, { height: 44, px: "$2", gap: "$2", items: "center", shrink: 0, $md: { display: 'none' }, children: [_jsx(Button, { variant: "ghost", size: "icon-sm", onPress: () => setDrawer(true), "aria-label": "Open runs", children: _jsx(Menu, { size: 18 }) }), name, _jsx(XStack, { flex: 1 }), _jsx(Button, { variant: "ghost", size: "icon-sm", onPress: () => setFinding(true), "aria-label": "Search runs", children: _jsx(Search, { size: 18 }) })] }), _jsx(Pane, { onStarted: () => recents.reload() })] }), _jsx(Find, { open: finding, onOpenChange: setFinding, recents: rows, onOpen: (id) => { setFinding(false); go(id); } })] }));
126
+ return { links, more };
119
127
  }
120
128
  /** The wordmark: the product's name, as its host calls it, which leads to New. */
121
129
  function Name({ onPress }) {
122
130
  const { name } = useHost();
123
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 }) }));
124
132
  }
125
- /**
126
- * A visitor: no runs to list, so the product's header stands where the rail
127
- * would. On New the composer fills the first screen and the pitch follows it.
128
- */
129
- function Visit() {
130
- const host = useHost();
131
- const r = route(host.path);
132
- // An address that names a part of the pitch (/#features) opens scrolled to it.
133
- useEffect(() => {
134
- const id = window.location.hash.slice(1);
135
- if (id)
136
- document.getElementById(id)?.scrollIntoView();
137
- }, []);
138
- const start = () => {
139
- const page = document.getElementById('visit');
140
- page?.scrollTo({ top: 0, behavior: 'smooth' });
141
- page?.querySelector('textarea, [contenteditable="true"], input[type="text"]')?.focus();
142
- };
143
- 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, {}))] }));
144
- }
145
133
  /** Whichever pane the address names, beside a rail — the builder's or the host's. */
146
134
  function Pane({ onStarted }) {
147
135
  const host = useHost();
@@ -180,9 +168,6 @@ function Screens({ rail }) {
180
168
  return _jsx(Project, { slug: r.slug }, r.slug);
181
169
  if (!rail)
182
170
  return _jsx(Pane, {});
183
- // Signed out with no token to wait on: a token still resolving is a person, not a visitor.
184
- if (!host.person && !host.token() && host.signIn)
185
- return _jsx(Visit, {});
186
171
  return _jsx(Shell, {});
187
172
  }
188
173
  /**
package/lib/index.d.ts CHANGED
@@ -3,12 +3,16 @@
3
3
  *
4
4
  * Mount `<Builder host={…} />` under a gui root in a box with a height. The
5
5
  * host says where the platform is, who is signed in, and how to move; the
6
- * builder does everything else. hanzo.build hosts it whole; hanzo.ai and the
7
- * Hanzo App mount it at /dev with `rail={false}` and list its places and runs in
8
- * their own rail with `DevSection`.
6
+ * builder does everything else. hanzo.build hosts it whole. A host with a rail
7
+ * of its own mounts it with `rail={false}` and draws the builder's parts in that
8
+ * rail: its places (`nav`), its runs (`useSessions`, or `DevSection` for both),
9
+ * and at the foot the Slack card and the account menu (`Slack`, `Who`), under
10
+ * `HostProvider`.
9
11
  */
10
- export { Builder } from './builder.tsx';
12
+ export { Builder, nav } from './builder.tsx';
11
13
  export { DevSection, DOTS, useSessions } from './section.tsx';
14
+ export { HostProvider } from './host.tsx';
15
+ export { Slack, Who } from './foot.tsx';
12
16
  export { administers } from './claims.ts';
13
17
  export type { Host, Person } from './host.tsx';
14
18
  export type { Session, Status } from './api/sessions.ts';
package/lib/index.js CHANGED
@@ -3,11 +3,15 @@
3
3
  *
4
4
  * Mount `<Builder host={…} />` under a gui root in a box with a height. The
5
5
  * host says where the platform is, who is signed in, and how to move; the
6
- * builder does everything else. hanzo.build hosts it whole; hanzo.ai and the
7
- * Hanzo App mount it at /dev with `rail={false}` and list its places and runs in
8
- * their own rail with `DevSection`.
6
+ * builder does everything else. hanzo.build hosts it whole. A host with a rail
7
+ * of its own mounts it with `rail={false}` and draws the builder's parts in that
8
+ * rail: its places (`nav`), its runs (`useSessions`, or `DevSection` for both),
9
+ * and at the foot the Slack card and the account menu (`Slack`, `Who`), under
10
+ * `HostProvider`.
9
11
  */
10
- export { Builder } from './builder.js';
12
+ export { Builder, nav } from './builder.js';
11
13
  export { DevSection, DOTS, useSessions } from './section.js';
14
+ export { HostProvider } from './host.js';
15
+ export { Slack, Who } from './foot.js';
12
16
  export { administers } from './claims.js';
13
17
  export { path, route, SESSION, SLUG } from './route.js';
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
@@ -6,8 +6,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
6
6
  * which repository and branch it starts from, and the ask itself.
7
7
  * Under it: attach, dictate, the mode, and the model and effort on the right.
8
8
  *
9
- * Sending starts a coding run and opens it. The choices a person made here are
10
- * kept in this browser, per org, so the next New starts where the last one did.
9
+ * Sending starts a coding run and opens it. The codebase is optional: in the
10
+ * sandbox a run with none starts a new project named from the ask, as the
11
+ * platform does for a run that names no repository; a machine works in its own
12
+ * checkout, so a run there names one. The choices a person made here are kept in
13
+ * this browser, per org, so the next New starts where the last one did.
11
14
  */
12
15
  import { SizableText, XStack, YStack } from '@hanzo/gui';
13
16
  import { Cloud, Monitor } from '@hanzogui/lucide-icons-2';
@@ -41,13 +44,32 @@ export const EFFORTS = [
41
44
  ];
42
45
  const FIRST = { repo: null, branch: '', place: '', mode: 'build', model: ENSO, effort: 'medium', ask: '' };
43
46
  const COLUMN = 768;
44
- /**
45
- * What was typed here and not sent, for as long as this document lives. Signing
46
- * in swaps a visitor's page for the rail, which draws New again in another
47
- * place; the popup keeps the document so the sentence can come along, and this
48
- * is where it waits.
49
- */
50
- 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
+ }
51
73
  export function Landing({ onStarted }) {
52
74
  const host = useHost();
53
75
  const t = useTarget();
@@ -57,11 +79,10 @@ export function Landing({ onStarted }) {
57
79
  // Nothing chosen here yet: start from the person's coding defaults (Settings → Code).
58
80
  const kept = stored ?? { ...FIRST, ...prefs.code };
59
81
  const set = (patch) => keep({ ...kept, ...patch });
60
- const [draft, setDraft] = useState(kept.ask || unsent.draft);
61
- const [files, setFiles] = useState(unsent.files);
62
- useEffect(() => {
63
- unsent = { draft, files };
64
- }, [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]);
65
86
  const [busy, setBusy] = useState(false);
66
87
  const [note, setNote] = useState('');
67
88
  const [adding, setAdding] = useState(null);
@@ -102,11 +123,13 @@ export function Landing({ onStarted }) {
102
123
  // The composer sends only a draft with words in it, and not while a send is out.
103
124
  const send = async () => {
104
125
  if (!signed) {
126
+ hold({ draft, files, sent: true });
105
127
  host.signIn?.();
106
128
  return;
107
129
  }
108
- if (!isForge(kept.repo)) {
109
- setNote('Choose a codebase on the forge.');
130
+ const repo = isForge(kept.repo) ? kept.repo : null;
131
+ if (!repo && place.id) {
132
+ setNote(`A run on ${place.label} works in its own checkout: choose the codebase, or run it in Cloud.`);
110
133
  return;
111
134
  }
112
135
  setBusy(true);
@@ -115,9 +138,10 @@ export function Landing({ onStarted }) {
115
138
  const run = await start(t, {
116
139
  prompt: compose(draft.trim(), files),
117
140
  // The name alone. The org rides the request, and a slash is not a repo name.
141
+ // No codebase is not sent, and the run starts a new project.
118
142
  // An empty base or place is not sent: the default branch, and the sandbox.
119
- repo: kept.repo.name,
120
- base: kept.branch,
143
+ repo: repo?.name,
144
+ base: repo ? kept.branch : undefined,
121
145
  targetId: place.id,
122
146
  mode: kept.mode,
123
147
  model: kept.model === ENSO ? undefined : kept.model,
@@ -134,6 +158,16 @@ export function Landing({ onStarted }) {
134
158
  setBusy(false);
135
159
  }
136
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]);
137
171
  /** Start the agent that finds this codebase's environment, and open it. A refusal is the dialog's to say. */
138
172
  const setup = async () => {
139
173
  const run = await start(t, { prompt: SETUP, repo: codebase, mode: 'setup' });
@@ -172,7 +206,7 @@ export function Landing({ onStarted }) {
172
206
  name: r.name,
173
207
  });
174
208
  },
175
- }, placeholder: "Codebase", disabled: !signed }, round), isForge(kept.repo) ? _jsx(BranchSelect, { value: branch, onChange: (b) => set({ branch: b }), load: loadBranches }) : null, _jsx(Files, { files: files, onFiles: setFiles })] }));
209
+ }, placeholder: "New project", disabled: !signed }, round), isForge(kept.repo) ? _jsx(BranchSelect, { value: branch, onChange: (b) => set({ branch: b }), load: loadBranches }) : null, _jsx(Files, { files: files, onFiles: setFiles })] }));
176
210
  const foot = (_jsxs(XStack, { flex: 1, items: "center", gap: "$2", flexWrap: "wrap", rowGap: "$1", children: [_jsx(Attach, { files: files, onFiles: setFiles, onNote: setNote }), _jsx(Dictate, { onText: (said) => setDraft((d) => (d ? `${d} ${said}` : said)), onNote: setNote }), _jsx(ModeSelect, { modes: MODES, value: kept.mode, onChange: (m) => set({ mode: m }), bg: "transparent", minH: 24, px: "$1.5", self: "center" }), _jsx(XStack, { flex: 1 }), _jsx(ChipSelect, { quiet: true, name: "Model", label: catalog.value.find((m) => m.id === kept.model)?.label ?? 'Enso', chosen: catalog.value.find((m) => m.id === kept.model) ?? null, items: catalog.value, onChange: (m) => set({ model: m.id }), placeholder: "Search models\u2026", placement: "top-end", loading: catalog.loading, error: catalog.error?.message ?? null }), _jsx(ChipSelect, { quiet: true, name: "Effort", label: EFFORTS.find((e) => e.id === kept.effort)?.label ?? 'Medium', chosen: EFFORTS.find((e) => e.id === kept.effort) ?? null, items: EFFORTS, onChange: (e) => set({ effort: e.id }), placement: "top-end", width: 180 })] }));
177
211
  return (_jsxs(YStack, { flex: 1, minH: 0, minW: 0, items: "center", justify: "center", px: "$4", pb: "$10", overflow: "scroll", children: [_jsxs(YStack, { width: "100%", maxW: COLUMN, gap: "$5", children: [_jsxs(XStack, { role: "heading", "aria-level": 1, justify: "center", items: "center", gap: "$3", flexWrap: "wrap", children: [_jsx(HanzoMark, { size: 26 }), _jsx(SizableText, { size: "$8", color: "$ink", style: { textAlign: 'center' }, children: prefs.callName ? `What’s up next, ${prefs.callName}?` : 'What’s up next?' })] }), _jsxs(YStack, { gap: "$2", children: [env.value && env.value.state !== 'ready' && !place.id ? (_jsxs(XStack, { items: "center", justify: "space-between", gap: "$3", px: "$3", py: "$2", rounded: "$10", borderWidth: 1, borderColor: "$borderColor", children: [_jsx(SizableText, { size: "$2", color: "$ink", flex: 1, minW: 0, children: env.value.state === 'proposed'
178
212
  ? `A proposed environment for ${codebase} is waiting for review.`
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@hanzo/build",
3
- "version": "0.2.18",
3
+ "version": "0.2.20",
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
- }