@hanzo/build 0.2.4 → 0.2.6

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/lib/host.d.ts CHANGED
@@ -26,24 +26,24 @@ export interface Host {
26
26
  person: Person | null;
27
27
  /** Whether the person administers `org`: an admin publishes to main, anyone else opens a review. */
28
28
  admin: boolean;
29
- /** The address under the mount: '' | a session id | a project slug | '-/<screen>'. */
29
+ /** The address under the mount: '' | a session id | a slug | '-/codebases' | '-/projects' | '-/issues' | '-/artifacts' | '-/templates'. */
30
30
  path: string;
31
31
  /** Move under the mount. */
32
32
  go: (path: string, how?: {
33
33
  replace?: boolean;
34
34
  }) => void;
35
- /** Where the builder links out to. Same-window addresses on the host, or https. */
35
+ /** Where the builder links out. On hanzo.build these are addresses of this page. */
36
36
  links: {
37
- /** Troubleshoot GitHub connection: the GitHub pane of the platform's plugins. */
37
+ /** GitHub for this page: bringing repositories onto the forge. */
38
38
  github: string;
39
- /** Customize: the platform's plugins (skills, MCP, connectors). */
39
+ /** Customize, an address of this host. */
40
40
  customize: string;
41
- /** Account settings. */
41
+ /** Account settings, an address of this host. */
42
42
  settings: string;
43
- /** The platform itself, where the mark leads. */
43
+ /** This host's home. On hanzo.build it is the page itself. */
44
44
  home: string;
45
45
  };
46
- /** Leave the builder for a host address, same window. */
46
+ /** Open a host address in this window. hanzo.build keeps the window on its own origin. */
47
47
  open: (href: string) => void;
48
48
  signIn?: () => void;
49
49
  signOut?: () => void;
package/lib/landing.js CHANGED
@@ -10,19 +10,19 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
10
10
  */
11
11
  import { SizableText, XStack, YStack } from '@hanzo/gui';
12
12
  import { Cloud, Monitor } from '@hanzogui/lucide-icons-2';
13
- import { Button } from '@hanzo/ui';
14
13
  import { ModeSelect } from '@hanzo/ui/agents';
15
14
  import { Composer, EmptyPrompt } from '@hanzo/ui/chat';
16
15
  import { BranchSelect, ChipSelect, HanzoMark, RepoSelect } from '@hanzo/ui/product';
17
- import { useCallback, useMemo, useState } from 'react';
16
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
18
17
  import { start, unhonoured } from './api/coding.js';
19
- import { branches, connect, repos } from './api/github.js';
18
+ import { asRepo, chosen, codebases, one } from './api/codebases.js';
20
19
  import { ENSO, models } from './api/models.js';
21
20
  import { ready, SANDBOX } from './api/places.js';
22
- import { clone } from './api/platform.js';
21
+ import { isForge } from './choice.js';
23
22
  import { useKept, usePlaces, useRead } from './data.js';
24
23
  import { useHost, useTarget } from './host.js';
25
24
  import { Publish } from './publish.js';
25
+ import { path } from './route.js';
26
26
  import { Attach, compose, Dictate, Files } from './tools.js';
27
27
  /** One vocabulary for the mode, on New and in a workspace. */
28
28
  export const MODES = [
@@ -34,7 +34,7 @@ const EFFORTS = [
34
34
  { id: 'medium', label: 'Medium' },
35
35
  { id: 'high', label: 'High' },
36
36
  ];
37
- const FIRST = { repo: null, branch: '', place: '', mode: 'build', model: ENSO, effort: 'medium' };
37
+ const FIRST = { repo: null, branch: '', place: '', mode: 'build', model: ENSO, effort: 'medium', ask: '' };
38
38
  const COLUMN = 768;
39
39
  export function Landing({ onStarted }) {
40
40
  const host = useHost();
@@ -42,43 +42,38 @@ export function Landing({ onStarted }) {
42
42
  const signed = Boolean(host.person);
43
43
  const [kept, keep] = useKept(`hanzo.build.new.${host.org ?? 'none'}`, FIRST);
44
44
  const set = (patch) => keep({ ...kept, ...patch });
45
- const [draft, setDraft] = useState('');
45
+ const [draft, setDraft] = useState(kept.ask || '');
46
46
  const [files, setFiles] = useState([]);
47
47
  const [busy, setBusy] = useState(false);
48
48
  const [note, setNote] = useState('');
49
- const [connected, setConnected] = useState(true);
50
49
  const [adding, setAdding] = useState(null);
50
+ // A codebase or an issue hands its words over once. Left in the kept choice,
51
+ // every later visit to New would put them back.
52
+ useEffect(() => {
53
+ if (kept.ask)
54
+ set({ ask: '' });
55
+ // The handover is read on this mount only.
56
+ // eslint-disable-next-line react-hooks/exhaustive-deps
57
+ }, []);
51
58
  const places = usePlaces(t, signed);
52
59
  const catalog = useRead(signed ? () => models(t) : null, [], [t, signed]);
53
60
  const place = places.value.find((p) => p.id === kept.place) ?? SANDBOX;
54
- const loadRepos = useCallback(async (q, after) => {
55
- const page = await repos(t, { q, after: after ?? undefined });
56
- setConnected(page.connected);
57
- return { repos: page.repos, next: page.next || null };
61
+ const known = useRef(new Map());
62
+ const loadRepos = useCallback(async (q) => {
63
+ const page = await codebases(t, q);
64
+ const repos = page.map(asRepo);
65
+ for (const r of repos)
66
+ known.current.set(r.name, r);
67
+ return { repos, next: null };
58
68
  }, [t]);
59
- const loadBranches = useCallback(async (q, after) => {
69
+ const loadBranches = useCallback(async (q) => {
60
70
  if (!kept.repo)
61
71
  return { branches: [], next: null };
62
- const page = await branches(t, kept.repo.owner, kept.repo.name, { q, after: after ?? undefined });
63
- return { branches: page.branches.map((b) => ({ name: b.name })), next: page.next || null };
72
+ const found = await one(t, kept.repo.name);
73
+ const needle = q.trim().toLowerCase();
74
+ const names = (found?.branches ?? [kept.repo.default_branch || 'main']).filter((b) => !needle || b.toLowerCase().includes(needle));
75
+ return { branches: names.map((name) => ({ name })), next: null };
64
76
  }, [t, kept.repo]);
65
- const link = async () => {
66
- try {
67
- const to = await connect(t);
68
- // The console completes the connection at /connectors and brings the
69
- // person back here: a path, never an origin.
70
- try {
71
- window.sessionStorage.setItem('hanzo.return', window.location.pathname + window.location.search);
72
- }
73
- catch {
74
- /* they land on the connectors page and come back themselves */
75
- }
76
- window.location.assign(to);
77
- }
78
- catch (e) {
79
- setNote(e instanceof Error ? e.message : 'Could not start the GitHub connection');
80
- }
81
- };
82
77
  const send = async () => {
83
78
  const prompt = draft.trim();
84
79
  if (!prompt || busy)
@@ -87,13 +82,18 @@ export function Landing({ onStarted }) {
87
82
  host.signIn?.();
88
83
  return;
89
84
  }
85
+ if (!isForge(kept.repo)) {
86
+ setNote('Choose a codebase on the forge.');
87
+ return;
88
+ }
90
89
  setBusy(true);
91
90
  setNote('');
92
91
  try {
93
92
  const run = await start(t, {
94
93
  prompt: compose(prompt, files),
95
- repo: kept.repo ? `${kept.repo.owner}/${kept.repo.name}` : undefined,
96
- base: kept.repo ? kept.branch || kept.repo.default_branch || undefined : undefined,
94
+ // The name alone. The org rides the request, and a slash is not a repo name.
95
+ repo: isForge(kept.repo) ? kept.repo.name : undefined,
96
+ base: isForge(kept.repo) ? kept.branch || kept.repo.default_branch || undefined : undefined,
97
97
  targetId: place.id || undefined,
98
98
  mode: kept.mode,
99
99
  model: kept.model === ENSO ? undefined : kept.model,
@@ -118,15 +118,24 @@ export function Landing({ onStarted }) {
118
118
  place: p,
119
119
  })), [places.value]);
120
120
  const branch = kept.branch || kept.repo?.default_branch || 'main';
121
- const head = (_jsxs(XStack, { gap: 6, items: "center", flexWrap: "wrap", children: [_jsx(ChipSelect, { name: "Where the run runs", icon: place.id ? _jsx(Monitor, { size: 13 }) : _jsx(Cloud, { size: 13 }), label: place.id ? place.label : 'Default', chosen: placeItems.find((i) => i.place.id === place.id) ?? null, items: placeItems, onChange: (i) => set({ place: i.place.id }), placeholder: "Search machines\u2026", loading: places.loading, error: places.error?.message ?? null, footer: _jsx(SizableText, { size: "$1", color: "$soft", children: "A run on a machine uses that machine\u2019s checkout and credentials." }) }), _jsx(RepoSelect, { value: kept.repo, onChange: (r) => set({ repo: r, branch: r.default_branch || 'main' }), load: loadRepos, troubleshoot: { label: 'Troubleshoot GitHub connection', onPress: () => host.open(host.links.github) }, note: "Not all repositories are shown. Type to search.", connect: !connected && !host.admin ? (_jsx(Button, { size: "sm", onPress: () => void link(), children: "Connect GitHub" })) : undefined, action: {
121
+ const head = (_jsxs(XStack, { gap: 6, items: "center", flexWrap: "wrap", children: [_jsx(ChipSelect, { name: "Where the run runs", icon: place.id ? _jsx(Monitor, { size: 13 }) : _jsx(Cloud, { size: 13 }), label: place.id ? place.label : 'Default', chosen: placeItems.find((i) => i.place.id === place.id) ?? null, items: placeItems, onChange: (i) => set({ place: i.place.id }), placeholder: "Search machines\u2026", loading: places.loading, error: places.error?.message ?? null, footer: _jsx(SizableText, { size: "$1", color: "$soft", children: "A run on a machine uses that machine\u2019s checkout and credentials." }) }), _jsx(RepoSelect, { value: isForge(kept.repo) ? kept.repo : null, onChange: (r) => {
122
+ const row = known.current.get(r.name) ?? chosen(r);
123
+ set({ repo: row, branch: row.default_branch });
124
+ }, load: loadRepos, troubleshoot: {
125
+ label: 'Browse codebases',
126
+ onPress: () => host.go(path({ kind: 'screen', screen: 'codebases' })),
127
+ }, note: "Repositories on the forge. Type to search.", action: {
122
128
  label: 'Add to project',
123
- onPress: (r) => setAdding({
124
- repo: clone(`${r.owner}/${r.name}`),
125
- title: `${r.owner}/${r.name}`,
126
- ref: r.default_branch || 'main',
127
- name: r.name,
128
- }),
129
- }, placeholder: "Repository", disabled: !signed }), kept.repo ? _jsx(BranchSelect, { value: branch, onChange: (b) => set({ branch: b }), load: loadBranches }) : null, _jsx(Files, { files: files, onFiles: setFiles })] }));
129
+ onPress: (r) => {
130
+ const row = known.current.get(r.name);
131
+ setAdding({
132
+ repo: row?.clone || `${t.api}/v1/git/${r.owner}/${r.name}.git`,
133
+ title: row?.full_name || `${r.owner}/${r.name}`,
134
+ ref: r.default_branch || 'main',
135
+ name: r.name,
136
+ });
137
+ },
138
+ }, placeholder: "Codebase", disabled: !signed }), isForge(kept.repo) ? _jsx(BranchSelect, { value: branch, onChange: (b) => set({ branch: b }), load: loadBranches }) : null, _jsx(Files, { files: files, onFiles: setFiles })] }));
130
139
  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 })] }));
131
140
  return (_jsxs(YStack, { flex: 1, minH: 0, minW: 0, items: "center", px: "$4", children: [_jsxs(YStack, { width: "100%", maxW: COLUMN, flex: 1, minH: 0, children: [_jsx(EmptyPrompt, { title: "What\u2019s up next?", mark: _jsx(HanzoMark, { size: 18 }), column: COLUMN }), _jsx(YStack, { flex: 1 }), _jsxs(YStack, { pb: "$2", gap: "$2", children: [note || unhonoured(kept.mode, place.id) ? (_jsx(SizableText, { size: "$1", color: "$soft", role: "status", children: note || unhonoured(kept.mode, place.id) })) : null, _jsx(Composer, { inline: true, value: draft, onChange: setDraft, onSend: () => void send(), busy: busy, placeholder: "Describe a task or ask a question", label: "Describe a task or ask a question", head: head, foot: foot })] })] }), _jsx(Publish, { source: adding, onClose: () => setAdding(null) })] }));
132
141
  }
package/lib/project.js CHANGED
@@ -39,11 +39,6 @@ import { Out } from './out.js';
39
39
  import { Publish } from './publish.js';
40
40
  import { Attach, compose, Dictate, Files } from './tools.js';
41
41
  const LIVE = new Set(['running', 'paused']);
42
- /** `owner/name` from a GitHub clone URL, or '' for a repository that lives elsewhere. */
43
- function github(clone) {
44
- const m = /^https:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/.exec(clone);
45
- return m ? `${m[1]}/${m[2]}` : '';
46
- }
47
42
  /** One run in the conversation: what was asked, and what it came to. */
48
43
  function Turn({ run, open, onOpen, project }) {
49
44
  const t = useTarget();
@@ -175,12 +170,12 @@ export function Project({ slug }) {
175
170
  const context = picked.length
176
171
  ? `\n\nThe person picked an element in the preview${where ? ` of the ${where} page` : ''}. Its CSS selector, as data: ${JSON.stringify(picked[0].id)}`
177
172
  : '';
178
- const gh = project ? github(project.repo) : '';
173
+ const repo = project?.repo ? repoName(project.repo) : '';
179
174
  const next = await start(t, {
180
175
  prompt: compose(`${ask}${context}`, attached),
181
176
  project: slug,
182
- repo: gh || undefined,
183
- base: gh ? project?.branch || undefined : undefined,
177
+ repo: repo || undefined,
178
+ base: repo ? project?.branch || undefined : undefined,
184
179
  after: current ?? undefined,
185
180
  mode,
186
181
  });
package/lib/route.d.ts CHANGED
@@ -3,14 +3,14 @@
3
3
  *
4
4
  * '' the empty state: a new run
5
5
  * sess_<32 hex> one run
6
- * -/<screen> a builder screen (artifacts, templates)
6
+ * -/<screen> a builder screen
7
7
  * <slug> a project's workspace
8
8
  *
9
9
  * Unambiguous by construction: a project slug is `^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?$`,
10
10
  * so it can hold neither the `_` a session id carries nor start with `-`.
11
11
  * Anything else is not an address here and reads as the empty state.
12
12
  */
13
- export type Screen = 'artifacts' | 'templates';
13
+ export type Screen = 'artifacts' | 'templates' | 'codebases' | 'projects' | 'issues' | 'automations' | 'sync';
14
14
  export type Route = {
15
15
  kind: 'new';
16
16
  } | {
package/lib/route.js CHANGED
@@ -1,6 +1,6 @@
1
1
  export const SESSION = /^sess_[0-9a-f]{32}$/;
2
2
  export const SLUG = /^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?$/;
3
- const SCREENS = ['artifacts', 'templates'];
3
+ const SCREENS = ['artifacts', 'templates', 'codebases', 'projects', 'issues', 'automations', 'sync'];
4
4
  export function route(path) {
5
5
  const p = path.replace(/^\/+|\/+$/g, '');
6
6
  if (SESSION.test(p))
package/lib/run.js CHANGED
@@ -12,13 +12,15 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
12
12
  * RECORDED, and the status is left to the feed to correct.
13
13
  */
14
14
  import { SizableText, XStack, YStack } from '@hanzo/gui';
15
- import { ExternalLink, GitPullRequest } from '@hanzogui/lucide-icons-2';
15
+ import { ExternalLink, GitPullRequest, PanelRight } from '@hanzogui/lucide-icons-2';
16
+ import { Button } from '@hanzo/ui';
16
17
  import { Transcript, fold } from '@hanzo/ui/agents';
17
18
  import { Composer } from '@hanzo/ui/chat';
18
- import { useMemo, useState } from 'react';
19
+ import { useEffect, useMemo, useState } from 'react';
19
20
  import { message, stop } from './api/sessions.js';
20
- import { outcome, pull, said, who } from './api/turn.js';
21
- import { useRun } from './data.js';
21
+ import { outcome, pull, said, steps, who } from './api/turn.js';
22
+ import { useKept, useRun } from './data.js';
23
+ import { Desk } from './desk.js';
22
24
  import { useTarget } from './host.js';
23
25
  import { Out } from './out.js';
24
26
  const LIVE = new Set(['running', 'paused', '']);
@@ -28,13 +30,39 @@ export function Run({ id }) {
28
30
  const [draft, setDraft] = useState('');
29
31
  const [note, setNote] = useState('');
30
32
  const [busy, setBusy] = useState(false);
33
+ const [notify, setNotify] = useState(false);
34
+ const [desk, setDesk] = useKept('hanzo.build.desk', true);
31
35
  const blocks = useMemo(() => fold(events
32
36
  .map((e) => ({ kind: e.kind, actor: who(e.actor), seq: e.seq, id: e.id, text: said(e, record?.mode) }))
33
37
  .filter((b) => b.text)), [events, record?.mode]);
34
38
  const end = outcome(events);
39
+ const plan = useMemo(() => steps(events), [events]);
35
40
  const state = status || end.status;
36
41
  const pr = pull(record?.pr ?? '', record?.repo ?? '');
37
42
  const running = LIVE.has(state) && !detail.error;
43
+ const title = detail.value?.title || (detail.loading ? '' : 'Untitled run');
44
+ useEffect(() => {
45
+ if (!notify || running)
46
+ return;
47
+ if (typeof Notification === 'undefined' || Notification.permission !== 'granted')
48
+ return;
49
+ const n = new Notification(title || 'Run finished', { body: 'This run has finished.' });
50
+ setNotify(false);
51
+ return () => n.close();
52
+ }, [notify, running, title]);
53
+ const ask = async () => {
54
+ if (typeof Notification === 'undefined') {
55
+ setNote('This browser cannot send a notification.');
56
+ return;
57
+ }
58
+ const perm = Notification.permission === 'granted' ? 'granted' : await Notification.requestPermission();
59
+ if (perm !== 'granted') {
60
+ setNote('Notifications stay off until this browser allows them.');
61
+ return;
62
+ }
63
+ setNotify(true);
64
+ setNote('');
65
+ };
38
66
  const send = async () => {
39
67
  if (!draft.trim() || busy)
40
68
  return;
@@ -62,6 +90,8 @@ export function Run({ id }) {
62
90
  setNote(e instanceof Error ? e.message : 'Could not stop this run');
63
91
  }
64
92
  };
65
- const title = detail.value?.title || (detail.loading ? '' : 'Untitled run');
66
- return (_jsx(YStack, { flex: 1, minH: 0, minW: 0, width: "100%", items: "center", children: _jsxs(YStack, { flex: 1, minH: 0, width: "100%", maxW: 816, px: "$6", children: [_jsxs(XStack, { pt: "$3", pb: "$2", gap: "$3", items: "center", minH: 44, children: [_jsxs(YStack, { flex: 1, minW: 0, children: [_jsx(SizableText, { render: "h1", size: "$5", color: "$ink", numberOfLines: 1, children: title }), _jsx(SizableText, { size: "$1", color: "$soft", numberOfLines: 1, children: [state || (detail.loading ? 'reading' : ''), record?.repo, record?.branch].filter(Boolean).join(' · ') })] }), pr.href ? (_jsx(Out, { href: pr.href, label: `Open pull request ${pr.label}`, children: _jsxs(XStack, { items: "center", gap: "$1.5", px: "$2.5", py: "$1.5", rounded: "$3", borderWidth: 1, borderColor: "$borderColor", hoverStyle: { bg: '$hover' }, children: [_jsx(GitPullRequest, { size: 14 }), _jsx(SizableText, { size: "$2", color: "$ink", children: pr.label }), _jsx(ExternalLink, { size: 12, opacity: 0.6 })] }) })) : null] }), _jsx(Transcript, { blocks: blocks, empty: _jsx(SizableText, { size: "$2", color: "$soft", children: detail.error ? detail.error.message : detail.loading ? 'Reading this run…' : 'Waiting for the run’s first step…' }) }), _jsxs(YStack, { pb: "$4", pt: "$2", gap: "$2", children: [end.problem ? (_jsx(SizableText, { size: "$1", color: "$soft", children: "The branch is pushed, and the pull request could not be opened." })) : null, note || refused ? (_jsx(SizableText, { size: "$1", color: "$soft", role: "status", children: note || refused })) : null, _jsx(Composer, { inline: true, value: draft, onChange: setDraft, onSend: () => void send(), onStop: () => void halt(), busy: running && !draft.trim(), disabled: !running || busy, placeholder: running ? 'Steer this run' : 'This run has finished', label: "Steer this run" })] })] }) }));
93
+ return (_jsxs(XStack, { flex: 1, minH: 0, minW: 0, width: "100%", children: [_jsxs(YStack, { flex: 1, minH: 0, minW: 0, width: "100%", px: "$6", children: [_jsxs(XStack, { pt: "$3", pb: "$2", gap: "$3", items: "center", minH: 44, children: [_jsxs(YStack, { flex: 1, minW: 0, children: [_jsx(SizableText, { render: "h1", size: "$5", color: "$ink", numberOfLines: 1, children: title }), _jsx(SizableText, { size: "$1", color: "$soft", numberOfLines: 1, children: [state || (detail.loading ? 'reading' : ''), record?.repo, record?.branch].filter(Boolean).join(' · ') })] }), desk ? null : (_jsx(XStack, { render: "button", "aria-label": "Show the side pane", px: "$2", py: "$1", rounded: "$2", hoverStyle: { bg: '$hover' }, onPress: () => setDesk(true), children: _jsx(PanelRight, { size: 16 }) })), pr.href ? (_jsx(Out, { href: pr.href, label: `Open pull request ${pr.label}`, children: _jsxs(XStack, { items: "center", gap: "$1.5", px: "$2.5", py: "$1.5", rounded: "$3", borderWidth: 1, borderColor: "$borderColor", hoverStyle: { bg: '$hover' }, children: [_jsx(GitPullRequest, { size: 14 }), _jsx(SizableText, { size: "$2", color: "$ink", children: pr.label }), _jsx(ExternalLink, { size: 12, opacity: 0.6 })] }) })) : null] }), _jsx(Transcript, { blocks: blocks, empty: _jsx(SizableText, { size: "$2", color: "$soft", children: detail.error ? detail.error.message : detail.loading ? 'Reading this run…' : running ? 'Setting up environment' : 'Waiting for the run’s first step…' }) }), _jsxs(YStack, { pb: "$4", pt: "$2", gap: "$2", children: [end.problem ? (_jsx(SizableText, { size: "$1", color: "$soft", children: "The branch is pushed, and the pull request could not be opened." })) : null, note || refused ? (_jsx(SizableText, { size: "$1", color: "$soft", role: "status", children: note || refused })) : null, plan.length ? (_jsxs(YStack, { borderWidth: 1, borderColor: "$borderColor", rounded: "$3", overflow: "hidden", children: [_jsxs(XStack, { px: "$3", py: "$2", justify: "space-between", items: "center", children: [_jsx(SizableText, { size: "$2", color: "$ink", children: "Steps" }), _jsx(SizableText, { size: "$1", color: "$soft", children: String(plan.length) })] }), plan.map((s, i) => {
94
+ const current = !s.done && plan.findIndex((x) => !x.done) === i;
95
+ return (_jsxs(XStack, { items: "center", gap: "$2", px: "$3", py: "$1.5", borderTopWidth: 1, borderColor: "$borderColor", children: [_jsx(SizableText, { size: "$2", color: s.done || current ? '$ink' : '$soft', children: s.done ? '✓' : current ? '●' : '○' }), _jsx(SizableText, { size: "$2", color: s.done ? '$soft' : '$ink', numberOfLines: 1, children: s.name })] }, s.name));
96
+ })] })) : null, running ? (_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", children: "Environment setup takes several minutes." }), _jsx(Button, { size: "sm", disabled: notify, onPress: () => void ask(), children: notify ? 'You will be notified' : 'Notify me' })] })) : null, _jsx(Composer, { inline: true, value: draft, onChange: setDraft, onSend: () => void send(), onStop: () => void halt(), busy: running && !draft.trim(), disabled: !running || busy, placeholder: running ? 'Add a follow up' : 'This run has finished', label: "Steer this run" })] })] }), desk ? _jsx(Desk, { id: id, repo: record?.repo ?? '', branch: record?.branch ?? '', base: record?.base ?? '', environment: record?.environment ?? '', mode: record?.mode ?? '', pr: pr, events: events, live: running, refused: refused || (detail.error ? detail.error.message : ''), onRetry: detail.reload, onHide: () => setDesk(false) }) : null] }));
67
97
  }
package/lib/section.d.ts CHANGED
@@ -8,9 +8,10 @@ export declare const DOTS: Record<string, SessionStatus>;
8
8
  /** The org's coding runs, newest first, kept live by the org's feed. */
9
9
  export declare function useSessions(host: Host): Read<Session[]>;
10
10
  /**
11
- * New, Artifacts, Templates, then the runs. `children` sit between the places
12
- * and the runs — where a host lists its projects. `onPick` fires after a move,
13
- * so a host that draws its rail as a drawer on a phone can close it.
11
+ * New, Automations, the forge (Codebase, Projects, Issues), Artifacts, Templates, then the
12
+ * runs. `children` sit between the places and the runs — where a host lists its
13
+ * own. `onPick` fires after a move, so a host that draws its rail as a drawer
14
+ * on a phone can close it.
14
15
  */
15
16
  export declare function DevSection({ host, onPick, label, children, }: {
16
17
  host: Host;
package/lib/section.js CHANGED
@@ -11,9 +11,10 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
11
11
  * host's own `go`.
12
12
  */
13
13
  import { SizableText } from '@hanzo/gui';
14
- import { Blocks, LayoutTemplate, SquarePen } from '@hanzogui/lucide-icons-2';
14
+ import { Blocks, CircleDot, FolderGit2, Kanban, LayoutTemplate, SquarePen, Workflow } from '@hanzogui/lucide-icons-2';
15
15
  import { SidebarItem, SidebarSection, StatusDot } from '@hanzo/ui/chat';
16
16
  import { useMemo } from 'react';
17
+ import { pinBoard } from './choice.js';
17
18
  import { useRecents } from './data.js';
18
19
  import { path, route } from './route.js';
19
20
  /** A run's status, as the rail's dot draws it. Anything else is idle. */
@@ -31,9 +32,10 @@ export function useSessions(host) {
31
32
  }
32
33
  const Note = ({ children }) => (_jsx(SizableText, { size: "$1", color: "$soft", px: "$2", py: "$1", children: children }));
33
34
  /**
34
- * New, Artifacts, Templates, then the runs. `children` sit between the places
35
- * and the runs — where a host lists its projects. `onPick` fires after a move,
36
- * so a host that draws its rail as a drawer on a phone can close it.
35
+ * New, Automations, the forge (Codebase, Projects, Issues), Artifacts, Templates, then the
36
+ * runs. `children` sit between the places and the runs — where a host lists its
37
+ * own. `onPick` fires after a move, so a host that draws its rail as a drawer
38
+ * on a phone can close it.
37
39
  */
38
40
  export function DevSection({ host, onPick, label = 'Runs', children, }) {
39
41
  const sessions = useSessions(host);
@@ -43,5 +45,8 @@ export function DevSection({ host, onPick, label = 'Runs', children, }) {
43
45
  onPick?.();
44
46
  };
45
47
  const screen = r.kind === 'screen' ? r.screen : '';
46
- return (_jsxs(_Fragment, { children: [_jsx(SidebarItem, { icon: _jsx(SquarePen, { size: 16, "aria-hidden": true }), active: r.kind === 'new', onPress: () => go(''), children: "New run" }), _jsx(SidebarItem, { icon: _jsx(Blocks, { size: 16, "aria-hidden": true }), active: screen === 'artifacts', onPress: () => go(path({ kind: 'screen', screen: 'artifacts' })), children: "Artifacts" }), _jsx(SidebarItem, { icon: _jsx(LayoutTemplate, { size: 16, "aria-hidden": true }), active: screen === 'templates', onPress: () => go(path({ kind: 'screen', screen: 'templates' })), children: "Templates" }), children, _jsx(SidebarSection, { label: label, children: !host.person ? (_jsx(Note, { children: "Sign in to see your runs." })) : sessions.value.length ? (sessions.value.map((s) => (_jsx(SidebarItem, { icon: _jsx(StatusDot, { status: DOTS[s.status] ?? 'idle' }), active: r.kind === 'run' && r.id === s.id, onPress: () => go(s.id), children: s.title || 'Untitled run' }, s.id)))) : (_jsx(Note, { children: sessions.error ? sessions.error.message : sessions.loading ? 'Reading…' : 'No runs yet. Say what to build and it lands here.' })) })] }));
48
+ return (_jsxs(_Fragment, { children: [_jsx(SidebarItem, { icon: _jsx(SquarePen, { size: 16, "aria-hidden": true }), active: r.kind === 'new', onPress: () => go(''), children: "New run" }), _jsx(SidebarItem, { icon: _jsx(Workflow, { size: 16, "aria-hidden": true }), active: screen === 'automations', onPress: () => go(path({ kind: 'screen', screen: 'automations' })), children: "Automations" }), _jsx(SidebarItem, { icon: _jsx(FolderGit2, { size: 16, "aria-hidden": true }), active: screen === 'codebases', onPress: () => go(path({ kind: 'screen', screen: 'codebases' })), children: "Codebase" }), _jsx(SidebarItem, { icon: _jsx(Kanban, { size: 16, "aria-hidden": true }), active: screen === 'projects', onPress: () => go(path({ kind: 'screen', screen: 'projects' })), children: "Projects" }), _jsx(SidebarItem, { icon: _jsx(CircleDot, { size: 16, "aria-hidden": true }), active: screen === 'issues', onPress: () => {
49
+ pinBoard(host.org, '');
50
+ go(path({ kind: 'screen', screen: 'issues' }));
51
+ }, children: "Issues" }), _jsx(SidebarItem, { icon: _jsx(Blocks, { size: 16, "aria-hidden": true }), active: screen === 'artifacts', onPress: () => go(path({ kind: 'screen', screen: 'artifacts' })), children: "Artifacts" }), _jsx(SidebarItem, { icon: _jsx(LayoutTemplate, { size: 16, "aria-hidden": true }), active: screen === 'templates', onPress: () => go(path({ kind: 'screen', screen: 'templates' })), children: "Templates" }), children, _jsx(SidebarSection, { label: label, children: !host.person ? (_jsx(Note, { children: "Sign in to see your runs." })) : sessions.value.length ? (sessions.value.map((s) => (_jsx(SidebarItem, { icon: _jsx(StatusDot, { status: DOTS[s.status] ?? 'idle' }), active: r.kind === 'run' && r.id === s.id, onPress: () => go(s.id), children: s.title || 'Untitled run' }, s.id)))) : (_jsx(Note, { children: sessions.error ? sessions.error.message : sessions.loading ? 'Reading…' : 'No runs yet. Say what to build and it lands here.' })) })] }));
47
52
  }
package/lib/sync.d.ts ADDED
@@ -0,0 +1 @@
1
+ export declare function Sync(): import("react").JSX.Element;
package/lib/sync.js ADDED
@@ -0,0 +1,124 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Bring repositories the GitHub connection already grants onto the forge.
4
+ *
5
+ * The list is GET /v1/provider/github/repos. The queue is POST
6
+ * /v1/provider/github/repos/import, which mirrors `owner/name` into this
7
+ * organization's forge in the background. An account named unread and holding
8
+ * no repositories is shown as needing an organization admin.
9
+ */
10
+ import { SizableText, XStack, YStack } from '@hanzo/gui';
11
+ import { Check, ChevronLeft, ChevronRight, Plus, X } from '@hanzogui/lucide-icons-2';
12
+ import { Button, Input } from '@hanzo/ui';
13
+ import { useEffect, useMemo, useState } from 'react';
14
+ import { accounts, bring, connect, grants } from './api/github.js';
15
+ import { readPending, writePending } from './choice.js';
16
+ import { useRead } from './data.js';
17
+ import { useHost, useTarget } from './host.js';
18
+ import { path } from './route.js';
19
+ const EMPTY = { repos: [], unread: [] };
20
+ function noteOf(e, fallback) {
21
+ return e instanceof Error ? e.message : fallback;
22
+ }
23
+ export function Sync() {
24
+ const host = useHost();
25
+ const t = useTarget();
26
+ const signed = Boolean(host.person);
27
+ const [q, setQ] = useState('');
28
+ const [owner, setOwner] = useState('');
29
+ const [step, setStep] = useState('pick');
30
+ const [picked, setPicked] = useState([]);
31
+ const [busy, setBusy] = useState(false);
32
+ const [syncing, setSyncing] = useState(false);
33
+ const [error, setError] = useState('');
34
+ const list = useRead(signed ? () => grants(t) : null, EMPTY, [t, signed]);
35
+ const rows = list.value.repos;
36
+ const orgs = useMemo(() => accounts(list.value), [list.value]);
37
+ const needle = q.trim().toLowerCase();
38
+ const shownOrgs = orgs.filter((a) => !needle || a.name.toLowerCase().includes(needle));
39
+ const ofOwner = rows.filter((r) => r.owner === owner);
40
+ const shownRepos = ofOwner.filter((r) => !needle || r.name.toLowerCase().includes(needle) || r.fullName.toLowerCase().includes(needle));
41
+ const chosen = rows.filter((r) => picked.includes(r.fullName));
42
+ const lead = chosen[0]?.name ?? '';
43
+ const more = Math.max(0, chosen.length - 1);
44
+ const dest = host.org || 'Forge';
45
+ const toggle = (fullName) => {
46
+ setPicked((cur) => (cur.includes(fullName) ? cur.filter((n) => n !== fullName) : [...cur, fullName]));
47
+ };
48
+ const selectShown = () => {
49
+ const names = shownRepos.map((r) => r.fullName);
50
+ const all = names.every((n) => picked.includes(n));
51
+ setPicked((cur) => (all ? cur.filter((n) => !names.includes(n)) : [...new Set([...cur, ...names])]));
52
+ };
53
+ const addOrg = async () => {
54
+ setError('');
55
+ try {
56
+ const url = await connect(t);
57
+ host.open(url);
58
+ }
59
+ catch (e) {
60
+ setError(noteOf(e, 'Could not open the GitHub connection'));
61
+ }
62
+ };
63
+ const send = async () => {
64
+ setBusy(true);
65
+ setError('');
66
+ try {
67
+ await bring(t, picked);
68
+ const next = [
69
+ ...readPending(host.org).filter((p) => !picked.includes(p.fullName)),
70
+ ...chosen.map((r) => ({ fullName: r.fullName, name: r.name })),
71
+ ];
72
+ writePending(host.org, next);
73
+ setSyncing(true);
74
+ }
75
+ catch (e) {
76
+ setError(noteOf(e, 'Could not queue those repositories'));
77
+ }
78
+ finally {
79
+ setBusy(false);
80
+ }
81
+ };
82
+ const back = () => {
83
+ host.go(path({ kind: 'screen', screen: 'codebases' }));
84
+ };
85
+ useEffect(() => {
86
+ if (!syncing)
87
+ return;
88
+ let stop = false;
89
+ const tick = async () => {
90
+ try {
91
+ const g = await grants(t);
92
+ const landed = new Set(g.repos.filter((r) => r.imported).map((r) => r.fullName));
93
+ const left = readPending(host.org).filter((p) => !landed.has(p.fullName));
94
+ writePending(host.org, left);
95
+ if (!stop && picked.every((n) => landed.has(n)))
96
+ back();
97
+ }
98
+ catch {
99
+ /* the button stays on Syncing until the next look */
100
+ }
101
+ };
102
+ const id = window.setInterval(() => void tick(), 4000);
103
+ return () => {
104
+ stop = true;
105
+ window.clearInterval(id);
106
+ };
107
+ }, [syncing, t, host.org, host.go, picked]);
108
+ return (_jsxs(YStack, { flex: 1, minH: 0, overflow: "scroll", children: [_jsxs(XStack, { px: "$6", pt: "$5", pb: "$2", gap: "$2", items: "center", children: [_jsx(XStack, { render: "button", "aria-label": "Back to codebases", onPress: back, children: _jsx(SizableText, { size: "$2", color: "$soft", children: "Codebase" }) }), _jsx(SizableText, { size: "$2", color: "$soft", children: "/" }), _jsx(SizableText, { size: "$2", color: "$ink", children: "Sync" })] }), _jsxs(YStack, { flex: 1, items: "center", justify: "center", px: "$6", pb: "$10", gap: "$5", children: [_jsxs(XStack, { justify: "center", gap: "$6", children: [_jsx(Step, { label: "Select repositories", done: step === 'access', current: step === 'pick' }), _jsx(Step, { label: "Repository access", done: false, current: step === 'access' })] }), _jsxs(YStack, { borderWidth: 1, borderColor: "$borderColor", rounded: "$5", bg: "$panel", px: "$6", py: "$6", gap: "$4", width: "100%", maxW: 460, children: [step === 'pick' && !owner ? (_jsxs(_Fragment, { children: [_jsxs(YStack, { gap: "$1", items: "center", children: [_jsx(SizableText, { size: "$5", color: "$ink", children: "Select repositories" }), _jsxs(SizableText, { size: "$2", color: "$soft", style: { textAlign: 'center' }, children: ["Choose repositories to bring into ", dest] })] }), _jsxs(Panel, { children: [_jsx(Input, { value: q, onChangeText: setQ, placeholder: "Search organizations", "aria-label": "Search organizations", disabled: !signed }), signed ? (_jsx(SizableText, { size: "$1", color: "$soft", children: `${orgs.length} ${orgs.length === 1 ? 'organization' : 'organizations'}` })) : null, list.error ? _jsx(Soft, { children: list.error.message }) : null, !signed ? (_jsx(Soft, { children: "Sign in to choose repositories the GitHub connection can read." })) : list.loading && rows.length === 0 && orgs.length === 0 ? (_jsx(Soft, { children: "Reading the connection\u2026" })) : shownOrgs.length === 0 ? (_jsx(Soft, { children: orgs.length === 0 ? 'No organizations on this connection yet.' : 'Nothing matches.' })) : (shownOrgs.map((a) => (_jsxs(XStack, { render: "button", "aria-label": a.blocked ? `${a.name} needs an organization admin` : `Open ${a.name}`, disabled: a.blocked, items: "center", gap: "$2", py: "$2", borderTopWidth: 1, borderColor: "$borderColor", onPress: () => {
109
+ setOwner(a.name);
110
+ setQ('');
111
+ }, children: [_jsx(SizableText, { flex: 1, size: "$2", color: a.blocked ? '$soft' : '$ink', numberOfLines: 1, children: a.name }), a.blocked ? (_jsx(SizableText, { size: "$1", color: "$soft", children: "Needs organization admin" })) : (_jsx(ChevronRight, { size: 14 }))] }, a.name)))), _jsxs(XStack, { render: "button", "aria-label": "Add organization", items: "center", gap: "$2", py: "$2", onPress: () => void addOrg(), disabled: !signed, children: [_jsx(Plus, { size: 14 }), _jsx(SizableText, { size: "$2", color: "$ink", children: "Add organization" })] })] })] })) : null, step === 'pick' && owner ? (_jsxs(_Fragment, { children: [_jsxs(YStack, { gap: "$1", items: "center", children: [_jsx(SizableText, { size: "$5", color: "$ink", children: "Select repositories" }), _jsxs(SizableText, { size: "$2", color: "$soft", style: { textAlign: 'center' }, children: ["Choose repositories to bring into ", dest] })] }), _jsxs(Panel, { children: [_jsxs(XStack, { items: "center", gap: "$2", children: [_jsx(XStack, { render: "button", "aria-label": "All organizations", onPress: () => setOwner(''), children: _jsx(ChevronLeft, { size: 16 }) }), _jsx(SizableText, { flex: 1, size: "$2", color: "$ink", numberOfLines: 1, children: owner })] }), _jsx(Input, { value: q, onChangeText: setQ, placeholder: "Search repositories", "aria-label": "Search repositories" }), _jsxs(XStack, { justify: "space-between", items: "center", children: [_jsxs(SizableText, { size: "$1", color: "$soft", children: [ofOwner.length, " ", ofOwner.length === 1 ? 'repository' : 'repositories'] }), _jsx(XStack, { render: "button", "aria-label": "Select all", onPress: selectShown, disabled: shownRepos.length === 0, children: _jsx(SizableText, { size: "$1", color: "$ink", children: "Select all" }) })] }), shownRepos.length === 0 ? (_jsx(Soft, { children: "Nothing matches." })) : (shownRepos.map((r) => (_jsx(RepoRow, { repo: r, on: picked.includes(r.fullName), onPress: () => toggle(r.fullName) }, r.fullName)))), _jsxs(XStack, { render: "button", "aria-label": "Grant more repositories", items: "center", gap: "$2", py: "$2", onPress: () => void addOrg(), children: [_jsx(Plus, { size: 14 }), _jsx(SizableText, { size: "$2", color: "$ink", children: "Add repositories" })] })] })] })) : null, step === 'access' ? (_jsxs(_Fragment, { children: [_jsxs(YStack, { gap: "$1", items: "center", children: [_jsx(SizableText, { size: "$5", color: "$ink", children: "Repository access" }), _jsx(SizableText, { size: "$2", color: "$soft", style: { textAlign: 'center' }, children: "Review who can read these repositories" })] }), _jsxs(XStack, { gap: "$2", items: "center", children: [_jsxs(XStack, { flex: 1, items: "center", gap: "$2", px: "$3", py: "$2", rounded: "$3", borderWidth: 1, borderColor: "$borderColor", children: [_jsx(XStack, { render: "button", "aria-label": "Clear the selection", onPress: () => { setPicked([]); setStep('pick'); }, children: _jsx(X, { size: 12 }) }), _jsx(SizableText, { flex: 1, size: "$2", color: "$ink", numberOfLines: 1, children: more ? `${lead}, +${more} more` : lead })] }), _jsx(Button, { size: "sm", variant: "outline", onPress: () => setStep('pick'), children: "Edit" })] }), _jsxs(XStack, { justify: "space-between", gap: "$4", children: [_jsxs(YStack, { gap: "$1", children: [_jsx(SizableText, { size: "$1", color: "$soft", children: "Who gets access" }), _jsxs(SizableText, { size: "$2", color: "$ink", children: [host.person?.name || host.person?.email || 'You', " \u00B7 You"] })] }), _jsxs(YStack, { gap: "$1", items: "flex-end", children: [_jsx(SizableText, { size: "$1", color: "$soft", children: "Role" }), _jsx(SizableText, { size: "$2", color: "$ink", children: host.admin ? 'Repository admin' : 'Member' })] })] }), _jsxs(SizableText, { size: "$2", color: "$soft", style: { textAlign: 'center' }, children: ["Anyone with access to ", dest] })] })) : null, error ? _jsx(Soft, { children: error }) : null, step === 'pick' ? (_jsx(Button, { width: "100%", disabled: !owner || picked.length === 0, onPress: () => setStep('access'), children: "Continue" })) : (_jsxs(YStack, { gap: "$3", items: "center", width: "100%", children: [_jsx(Button, { width: "100%", disabled: busy || syncing || picked.length === 0, onPress: () => void send(), children: syncing || busy ? 'Syncing…' : `Sync ${picked.length} ${picked.length === 1 ? 'repository' : 'repositories'} to ${dest}` }), syncing ? null : (_jsx(XStack, { render: "button", "aria-label": "Sync later", onPress: back, children: _jsx(SizableText, { size: "$2", color: "$soft", children: "Sync later" }) }))] })), !signed ? (_jsx(Button, { width: "100%", onPress: () => host.signIn?.(), children: "Sign in" })) : null] })] })] }));
112
+ }
113
+ function Step({ label, done, current }) {
114
+ return (_jsxs(XStack, { items: "center", gap: "$2", children: [_jsx(YStack, { width: 14, height: 14, rounded: 999, bg: done ? '$green10' : 'transparent', borderWidth: 1, borderColor: done || current ? '$ink' : '$borderColor', items: "center", justify: "center", children: done ? _jsx(Check, { size: 10, color: "white" }) : null }), _jsx(SizableText, { size: "$1", color: current || done ? '$ink' : '$soft', children: label })] }));
115
+ }
116
+ function Panel({ children }) {
117
+ return (_jsx(YStack, { borderWidth: 1, borderColor: "$borderColor", rounded: "$3", px: "$3", py: "$2", gap: "$2", maxH: 360, overflow: "scroll", children: children }));
118
+ }
119
+ function Soft({ children }) {
120
+ return (_jsx(SizableText, { size: "$2", color: "$soft", children: children }));
121
+ }
122
+ function RepoRow({ repo, on, onPress }) {
123
+ return (_jsxs(XStack, { render: "button", "aria-label": on ? `Selected ${repo.name}` : `Select ${repo.name}`, "aria-pressed": on, items: "center", gap: "$2", py: "$2", borderTopWidth: 1, borderColor: "$borderColor", onPress: onPress, children: [_jsx(YStack, { width: 14, height: 14, rounded: "$1", borderWidth: 1, borderColor: on ? '$ink' : '$borderColor', bg: on ? '$ink' : 'transparent' }), _jsx(SizableText, { flex: 1, size: "$2", color: "$ink", numberOfLines: 1, children: repo.name }), repo.imported ? (_jsx(SizableText, { size: "$1", color: "$soft", children: "On Forge" })) : null] }));
124
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanzo/build",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0 OR MIT",
6
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).",
@@ -65,6 +65,7 @@
65
65
  "build:lib": "rm -rf lib && tsc -p tsconfig.lib.json",
66
66
  "typecheck": "tsc --noEmit",
67
67
  "preview": "vite preview",
68
- "test": "vitest run"
68
+ "test": "vitest run",
69
+ "e2e": "HANZO_E2E=1 vitest run src/e2e"
69
70
  }
70
71
  }