@hanzo/build 0.2.8 → 0.2.10

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/git.js ADDED
@@ -0,0 +1,70 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * A run's Git tab: what it pushed, read from the forge. Diff is the net change
4
+ * from its base, Review is the pull request it opened and what reviewers said,
5
+ * Commits is its branch's history since the base. While the run works it is
6
+ * read again every little while, because a run pushes when it finishes a step.
7
+ */
8
+ import { SizableText, XStack, YStack } from '@hanzo/gui';
9
+ import { ChevronDown, ChevronRight } from '@hanzogui/lucide-icons-2';
10
+ import { useEffect, useState } from 'react';
11
+ import { read } from './api/changes.js';
12
+ import { useRead } from './data.js';
13
+ import { useHost, useTarget } from './host.js';
14
+ import { Out } from './out.js';
15
+ const NONE = { repo: '', base: '', head: '', commits: [], files: [], pull: null };
16
+ const AGAIN = 15_000;
17
+ const mono = { fontFamily: 'var(--f-mono, ui-monospace, monospace)', whiteSpace: 'pre' };
18
+ export function Git({ session, title, live }) {
19
+ const host = useHost();
20
+ const t = useTarget();
21
+ const signed = Boolean(host.person);
22
+ const [view, setView] = useState('diff');
23
+ const got = useRead(signed ? () => read(t, session) : null, NONE, [t, session, signed, live]);
24
+ const { reload } = got;
25
+ useEffect(() => {
26
+ if (!live)
27
+ return;
28
+ const every = setInterval(reload, AGAIN);
29
+ return () => clearInterval(every);
30
+ }, [live, reload]);
31
+ const c = got.value;
32
+ const added = c.files.reduce((n, f) => n + f.additions, 0);
33
+ const removed = c.files.reduce((n, f) => n + f.deletions, 0);
34
+ return (_jsxs(YStack, { gap: "$3", children: [_jsxs(YStack, { gap: "$1", children: [_jsx(SizableText, { size: "$3", color: "$ink", numberOfLines: 1, children: title }), c.head ? (_jsxs(SizableText, { size: "$1", color: "$soft", numberOfLines: 1, children: [`${c.head} → ${c.base}`, c.files.length ? ` · ${c.files.length} ${c.files.length === 1 ? 'file' : 'files'} +${added} −${removed}` : ''] })) : null] }), _jsx(XStack, { gap: "$1", children: ['diff', 'review', 'commits'].map((v) => (_jsx(XStack, { render: "button", "aria-label": label(v), onPress: () => setView(v), px: "$2.5", py: "$1", rounded: "$2", bg: view === v ? '$hover' : 'transparent', children: _jsx(SizableText, { size: "$2", color: view === v ? '$ink' : '$soft', children: label(v) }) }, v))) }), !signed ? (_jsx(Soft, { children: "Sign in to see what this run pushed." })) : got.error ? (_jsx(Soft, { children: got.error.message })) : got.loading && !c.head ? (_jsx(Soft, { children: "Reading the branch\u2026" })) : view === 'diff' ? (c.files.length ? (_jsx(YStack, { borderWidth: 1, borderColor: "$borderColor", rounded: "$3", overflow: "hidden", children: c.files.map((f, i) => (_jsx(File, { change: f, first: i === 0 }, f.path))) })) : (_jsx(Soft, { children: "No pushed changes" }))) : view === 'review' ? (c.pull ? (_jsxs(YStack, { gap: "$3", children: [_jsxs(YStack, { gap: "$1.5", p: "$3", borderWidth: 1, borderColor: "$borderColor", rounded: "$3", children: [_jsx(SizableText, { size: "$2", color: "$ink", children: `#${c.pull.number} ${c.pull.title}` }), _jsx(SizableText, { size: "$1", color: "$soft", children: c.pull.state === 'open'
35
+ ? c.pull.mergeable === false
36
+ ? 'Open · conflicts with its base'
37
+ : c.pull.mergeable
38
+ ? 'Open · ready to merge'
39
+ : 'Open'
40
+ : c.pull.state === 'merged'
41
+ ? 'Merged'
42
+ : 'Closed' }), c.pull.url.startsWith('https://') ? (_jsx(Out, { href: c.pull.url, label: `Open pull request #${c.pull.number}`, children: _jsx(SizableText, { size: "$1", color: "$ink", textDecorationLine: "underline", children: "Open on the forge" }) })) : null] }), c.pull.reviews.length ? (c.pull.reviews.map((r, i) => (_jsxs(YStack, { gap: "$1", pb: "$2", borderBottomWidth: 1, borderColor: "$borderColor", children: [_jsx(SizableText, { size: "$1", color: "$soft", children: `${r.author || 'Someone'} · ${verdict(r.state)}${r.at ? ` · ${when(r.at)}` : ''}` }), r.body ? (_jsx(SizableText, { size: "$2", color: "$ink", children: r.body })) : null] }, i)))) : (_jsx(Soft, { children: "No reviews yet" }))] })) : (_jsx(Soft, { children: "No pull request yet. The run opens one when it pushes its changes." }))) : c.commits.length ? (_jsx(YStack, { children: c.commits.map((k) => (_jsxs(XStack, { gap: "$3", py: "$2", borderBottomWidth: 1, borderColor: "$borderColor", items: "baseline", children: [_jsx(SizableText, { size: "$1", color: "$soft", style: mono, children: k.sha.slice(0, 7) }), _jsxs(YStack, { flex: 1, minW: 0, gap: "$0.5", children: [_jsx(SizableText, { size: "$2", color: "$ink", numberOfLines: 2, children: k.message }), _jsx(SizableText, { size: "$1", color: "$soft", numberOfLines: 1, children: [k.author, when(k.date)].filter(Boolean).join(' · ') })] })] }, k.sha))) })) : (_jsx(Soft, { children: "No pushed commits" }))] }));
43
+ }
44
+ function File({ change: f, first }) {
45
+ const [open, setOpen] = useState(false);
46
+ const lines = f.patch ? f.patch.split('\n') : [];
47
+ return (_jsxs(YStack, { borderTopWidth: first ? 0 : 1, borderColor: "$borderColor", children: [_jsxs(XStack, { render: "button", "aria-label": `${open ? 'Hide' : 'Show'} ${f.path}`, onPress: () => setOpen(!open), items: "center", gap: "$2", px: "$3", py: "$2", hoverStyle: { bg: '$hover' }, children: [open ? _jsx(ChevronDown, { size: 14 }) : _jsx(ChevronRight, { size: 14 }), _jsx(SizableText, { size: "$1", color: "$soft", width: 12, children: f.status[0].toUpperCase() }), _jsx(SizableText, { flex: 1, minW: 0, size: "$2", color: "$ink", numberOfLines: 1, style: { textAlign: 'left' }, children: f.from ? `${f.from} → ${f.path}` : f.path }), _jsx(SizableText, { size: "$1", color: "$green10", children: `+${f.additions}` }), _jsx(SizableText, { size: "$1", color: "$red10", children: `−${f.deletions}` })] }), open ? (_jsxs(YStack, { px: "$3", pb: "$2", overflow: "scroll", children: [lines.length ? (lines.map((l, i) => (_jsx(SizableText, { size: "$1", style: mono, color: l.startsWith('@@') ? '$soft' : l.startsWith('+') ? '$green10' : l.startsWith('-') ? '$red10' : '$ink', children: l || ' ' }, i)))) : (_jsx(SizableText, { size: "$1", color: "$soft", children: f.truncated ? 'Too large to show here.' : 'Binary file' })), lines.length && f.truncated ? (_jsx(SizableText, { size: "$1", color: "$soft", children: "The rest of this file\u2019s change is too large to show here." })) : null] })) : null] }));
48
+ }
49
+ function label(v) {
50
+ return v === 'diff' ? 'Diff' : v === 'review' ? 'Review' : 'Commits';
51
+ }
52
+ function verdict(state) {
53
+ switch (state.toUpperCase()) {
54
+ case 'APPROVED':
55
+ return 'approved';
56
+ case 'REQUEST_CHANGES':
57
+ return 'asked for changes';
58
+ case 'COMMENT':
59
+ return 'commented';
60
+ default:
61
+ return state.toLowerCase() || 'reviewed';
62
+ }
63
+ }
64
+ function when(iso) {
65
+ const d = new Date(iso);
66
+ return Number.isNaN(d.getTime()) ? '' : d.toLocaleString();
67
+ }
68
+ function Soft({ children }) {
69
+ return (_jsx(YStack, { py: "$6", items: "center", children: _jsx(SizableText, { size: "$2", color: "$soft", style: { textAlign: 'center' }, children: children }) }));
70
+ }
package/lib/landing.js CHANGED
@@ -10,12 +10,15 @@ 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';
13
14
  import { ModeSelect } from '@hanzo/ui/agents';
14
15
  import { Composer, EmptyPrompt } from '@hanzo/ui/chat';
15
16
  import { BranchSelect, ChipSelect, HanzoMark, RepoSelect } from '@hanzo/ui/product';
16
17
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
17
18
  import { start, unhonoured } from './api/coding.js';
18
19
  import { asRepo, chosen, codebases, one } from './api/codebases.js';
20
+ import { read, SETUP } from './api/environment.js';
21
+ import { SetupDialog } from './environment.js';
19
22
  import { ENSO, models } from './api/models.js';
20
23
  import { ready, SANDBOX } from './api/places.js';
21
24
  import { isForge } from './choice.js';
@@ -47,6 +50,7 @@ export function Landing({ onStarted }) {
47
50
  const [busy, setBusy] = useState(false);
48
51
  const [note, setNote] = useState('');
49
52
  const [adding, setAdding] = useState(null);
53
+ const [offering, setOffering] = useState(false);
50
54
  // A codebase or an issue hands its words over once. Left in the kept choice,
51
55
  // every later visit to New would put them back.
52
56
  useEffect(() => {
@@ -57,6 +61,9 @@ export function Landing({ onStarted }) {
57
61
  }, []);
58
62
  const places = usePlaces(t, signed);
59
63
  const catalog = useRead(signed ? () => models(t) : null, [], [t, signed]);
64
+ // The chosen codebase's environment, so New can say when it has none yet.
65
+ const codebase = isForge(kept.repo) ? kept.repo.name : '';
66
+ const env = useRead(signed && codebase ? () => read(t, codebase) : null, null, [t, signed, codebase]);
60
67
  const place = places.value.find((p) => p.id === kept.place) ?? SANDBOX;
61
68
  const known = useRef(new Map());
62
69
  const loadRepos = useCallback(async (q) => {
@@ -110,6 +117,11 @@ export function Landing({ onStarted }) {
110
117
  setBusy(false);
111
118
  }
112
119
  };
120
+ /** Start the agent that finds this codebase's environment, and open it. A refusal is the dialog's to say. */
121
+ const setup = async () => {
122
+ const run = await start(t, { prompt: SETUP, repo: codebase, mode: 'setup' });
123
+ onStarted(run.session);
124
+ };
113
125
  const placeItems = useMemo(() => (places.value.length ? places.value : [SANDBOX]).map((p) => ({
114
126
  id: p.id || 'sandbox',
115
127
  label: p.label,
@@ -137,5 +149,10 @@ export function Landing({ onStarted }) {
137
149
  },
138
150
  }, 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 })] }));
139
151
  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 })] }));
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) })] }));
152
+ 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: [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'
153
+ ? `A proposed environment for ${codebase} is waiting for review.`
154
+ : `${codebase} has no environment yet, so every run starts it bare.` }), env.value.state === 'proposed' && env.value.session ? (_jsx(Button, { size: "sm", variant: "outline", onPress: () => host.go(env.value.session), children: "Review" })) : (_jsx(Button, { size: "sm", onPress: () => setOffering(true), children: "Set up environment" }))] })) : null, 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) }), codebase ? (_jsx(SetupDialog, { repo: codebase, open: offering, onOpenChange: setOffering, onStart: setup, onSaved: () => {
155
+ setOffering(false);
156
+ env.reload();
157
+ } })) : null] }));
141
158
  }
package/lib/run.js CHANGED
@@ -17,16 +17,19 @@ import { Button } from '@hanzo/ui';
17
17
  import { Transcript, fold } from '@hanzo/ui/agents';
18
18
  import { Composer } from '@hanzo/ui/chat';
19
19
  import { useEffect, useMemo, useState } from 'react';
20
- import { message, stop } from './api/sessions.js';
20
+ import { list, message, stop, took } from './api/sessions.js';
21
21
  import { outcome, pull, said, steps, who } from './api/turn.js';
22
- import { useKept, useRun } from './data.js';
22
+ import { useKept, useRead, useRun } from './data.js';
23
23
  import { Desk } from './desk.js';
24
- import { useTarget } from './host.js';
24
+ import { useHost, useTarget } from './host.js';
25
25
  import { Out } from './out.js';
26
26
  const LIVE = new Set(['running', 'paused', '']);
27
27
  export function Run({ id }) {
28
+ const host = useHost();
28
29
  const t = useTarget();
29
- const { detail, record, events, status, refused } = useRun(t, id);
30
+ // Signed out, there is nothing to read: the gateway answers a bare run with a refusal.
31
+ const signed = Boolean(host.person);
32
+ const { detail, record, events, status, refused } = useRun(t, signed ? id : null);
30
33
  const [draft, setDraft] = useState('');
31
34
  const [note, setNote] = useState('');
32
35
  const [busy, setBusy] = useState(false);
@@ -39,8 +42,14 @@ export function Run({ id }) {
39
42
  const plan = useMemo(() => steps(events), [events]);
40
43
  const state = status || end.status;
41
44
  const pr = pull(record?.pr ?? '', record?.repo ?? '');
42
- const running = LIVE.has(state) && !detail.error;
45
+ const running = signed && LIVE.has(state) && !detail.error;
43
46
  const title = detail.value?.title || (detail.loading ? '' : 'Untitled run');
47
+ // A setup run explores and installs before it answers, which takes minutes.
48
+ const setup = record?.mode === 'setup';
49
+ const [seen, setSeen] = useKept('hanzo.build.setup.seen', false);
50
+ // How long setup runs have taken here: the ones this person can see, finished.
51
+ const past = useRead(setup && running ? () => list(t, { kind: 'coding', status: 'done', limit: 500 }) : null, [], [t, setup, running]);
52
+ const span = took(past.value, 'setup');
44
53
  useEffect(() => {
45
54
  if (!notify || running)
46
55
  return;
@@ -90,8 +99,29 @@ export function Run({ id }) {
90
99
  setNote(e instanceof Error ? e.message : 'Could not stop this run');
91
100
  }
92
101
  };
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) => {
102
+ 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: signed ? (_jsx(SizableText, { size: "$2", color: "$soft", children: detail.error
103
+ ? detail.error.message
104
+ : detail.loading
105
+ ? 'Reading this run…'
106
+ : running
107
+ ? setup
108
+ ? 'Setting up environment'
109
+ : 'Starting…'
110
+ : 'Waiting for the run’s first step…' })) : (_jsxs(YStack, { gap: "$3", items: "flex-start", children: [_jsx(SizableText, { size: "$2", color: "$soft", children: "Sign in to follow this run." }), _jsx(Button, { size: "sm", onPress: () => host.signIn?.(), children: "Sign in" })] })) }), _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 || (signed && 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
111
  const current = !s.done && plan.findIndex((x) => !x.done) === i;
95
112
  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] }));
113
+ })] })) : null, setup && running && !seen ? _jsx(Onboarding, { onDone: () => setSeen(true) }) : 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: setup
114
+ ? span
115
+ ? `Environment setup takes ~${span[0] === span[1] ? span[0] : `${span[0]}–${span[1]}`} minutes.`
116
+ : 'Environment setup takes several minutes.'
117
+ : 'This run is still working.' }), _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: !signed ? 'Sign in to follow up' : running ? (setup ? 'Add a follow up for the setup agent' : '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, title: title, project: record?.project ?? '', sandbox: record?.sandbox ?? '', events: events, live: running, refused: signed ? refused || (detail.error ? detail.error.message : '') : 'Sign in to follow this run.', retry: signed ? 'Retry' : 'Sign in', onRetry: signed ? detail.reload : () => host.signIn?.(), onHide: () => setDesk(false) }) : null] }));
118
+ }
119
+ /** What a setup run does, said once per browser the first time one is watched. */
120
+ function Onboarding({ onDone }) {
121
+ const steps = [
122
+ 'Explore, install and check the codebase',
123
+ 'Name the secrets it needs — you set their values',
124
+ 'Propose the environment for you to review and save',
125
+ ];
126
+ return (_jsxs(YStack, { gap: "$3", p: "$4", rounded: "$4", borderWidth: 1, borderColor: "$borderColor", bg: "$panel", children: [_jsx(SizableText, { size: "$4", color: "$ink", children: "Set up a cloud environment" }), _jsx(SizableText, { size: "$2", color: "$soft", children: "An environment lets every later run install, start, test and check its changes the way an engineer does. Setup is agent-led and takes several minutes. The agent will:" }), _jsx(YStack, { gap: "$1.5", children: steps.map((s, i) => (_jsxs(XStack, { gap: "$3", children: [_jsx(SizableText, { size: "$2", color: "$soft", width: 12, children: String(i + 1) }), _jsx(SizableText, { size: "$2", color: "$ink", children: s })] }, s))) }), _jsx(SizableText, { size: "$2", color: "$soft", children: "Interrupt anytime to steer the agent or ask it questions." }), _jsx(Button, { size: "sm", onPress: onDone, children: "Got it" })] }));
97
127
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanzo/build",
3
- "version": "0.2.8",
3
+ "version": "0.2.10",
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).",
@@ -16,6 +16,7 @@
16
16
  "@hanzo/voice": "0.1.8",
17
17
  "@hanzogui/lucide-icons-2": "8.3.5",
18
18
  "@hanzogui/next-theme": "8.3.5",
19
+ "@playwright/test": "1.63.0",
19
20
  "@types/node": "26.5.0",
20
21
  "@types/react": "19.2.18",
21
22
  "@types/react-dom": "19.2.7",
@@ -66,6 +67,7 @@
66
67
  "typecheck": "tsc --noEmit",
67
68
  "preview": "vite preview",
68
69
  "test": "vitest run",
69
- "e2e": "HANZO_E2E=1 vitest run src/e2e"
70
+ "e2e": "HANZO_E2E=1 vitest run src/e2e",
71
+ "site": "playwright test"
70
72
  }
71
73
  }