@hanzo/build 0.2.8 → 0.2.9

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
@@ -13,6 +13,8 @@ here — and github.com is opened only for the repository grant.
13
13
  pnpm install
14
14
  pnpm dev # http://localhost:3200
15
15
  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)
17
+ pnpm e2e # one live agent turn, projected the way a run draws it
16
18
  pnpm build:lib # lib/ — what npm ships
17
19
  ```
18
20
 
package/lib/builder.js CHANGED
@@ -65,6 +65,10 @@ function Shell() {
65
65
  return list;
66
66
  }, [recents.value, order]);
67
67
  const screen = r.kind === 'screen' ? r.screen : '';
68
+ // The top left: the organization switcher once there is an org to switch, the mark before.
69
+ // Beside the rail from md up, beside the drawer's button below it — one place at a time.
70
+ const switcher = Boolean(host.person && (host.org || (host.memberships?.length ?? 0) > 0));
71
+ const top = switcher ? (_jsx(Where, {})) : (_jsx(XStack, { render: "button", "aria-label": "Hanzo", items: "center", onPress: () => go(''), children: _jsx(HanzoMark, { size: 18 }) }));
68
72
  const links = [
69
73
  { id: 'automations', label: 'Automations', icon: _jsx(Workflow, { size: 16 }), onPress: () => go(path({ kind: 'screen', screen: 'automations' })), active: screen === 'automations' },
70
74
  { id: 'codebases', label: 'Codebase', icon: _jsx(FolderGit2, { size: 16 }), onPress: () => go(path({ kind: 'screen', screen: 'codebases' })), active: screen === 'codebases' },
@@ -94,7 +98,7 @@ function Shell() {
94
98
  ];
95
99
  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.' }), account: host.person
96
100
  ? { name: host.person.email || host.person.name, onPress: () => setAccount(true) }
97
- : { name: 'Sign in', onPress: () => host.signIn?.() }, onSettings: () => setAccount(true), onSearch: () => setFinding(true), collapsed: collapsed, onCollapse: setCollapsed, mark: _jsx(Brand, { org: host.org }), pt: collapsed ? undefined : host.person && (host.org || (host.memberships?.length ?? 0) > 0) ? 80 : 40, open: drawer, onOpenChange: setDrawer, label: "Runs" }), collapsed ? null : (_jsx(XStack, { position: "absolute", t: 8, l: 8, r: 8, z: 2, items: "center", children: host.person && (host.org || (host.memberships?.length ?? 0) > 0) ? (_jsx(Where, {})) : (_jsx(XStack, { render: "button", "aria-label": "Hanzo", items: "center", onPress: () => go(''), children: _jsx(HanzoMark, { size: 18 }) })) }))] }), _jsxs(YStack, { flex: 1, minW: 0, minH: 0, position: "relative", children: [_jsx(XStack, { height: 44, px: "$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 }) }) }), _jsx(Pane, { onStarted: () => recents.reload() })] }), _jsx(Account, { open: account, onOpenChange: setAccount }), _jsx(Find, { open: finding, onOpenChange: setFinding, recents: rows, onOpen: (id) => { setFinding(false); go(id); } })] }));
101
+ : { name: 'Sign in', onPress: () => host.signIn?.() }, onSettings: () => setAccount(true), onSearch: () => setFinding(true), collapsed: collapsed, onCollapse: setCollapsed, mark: _jsx(Brand, { org: host.org }), pt: collapsed ? undefined : switcher ? 80 : 40, open: drawer, onOpenChange: setDrawer, label: "Runs" }), collapsed ? null : (_jsx(XStack, { position: "absolute", t: 8, l: 8, r: 8, z: 2, items: "center", "$max-md": { display: 'none' }, children: top }))] }), _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 }) }), top] }), _jsx(Pane, { onStarted: () => recents.reload() })] }), _jsx(Account, { open: account, onOpenChange: setAccount }), _jsx(Find, { open: finding, onOpenChange: setFinding, recents: rows, onOpen: (id) => { setFinding(false); go(id); } })] }));
98
102
  }
99
103
  /** Whichever pane the address names, beside a rail — the builder's or the host's. */
100
104
  function Pane({ onStarted }) {
package/lib/desk.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { Event } from './api/sessions.ts';
2
- export declare function Desk({ id, repo, branch, base, environment, mode, pr, events, live, refused, onRetry, onHide, }: {
2
+ export declare function Desk({ id, repo, branch, base, environment, mode, pr, events, live, refused, retry, onRetry, onHide, }: {
3
3
  id: string;
4
4
  repo: string;
5
5
  branch: string;
@@ -13,6 +13,8 @@ export declare function Desk({ id, repo, branch, base, environment, mode, pr, ev
13
13
  events: Event[];
14
14
  live: boolean;
15
15
  refused: string;
16
+ /** The refusal's one action: Retry, or Sign in when nobody is. */
17
+ retry?: string;
16
18
  onRetry: () => void;
17
19
  onHide?: () => void;
18
20
  }): import("react").JSX.Element;
package/lib/desk.js CHANGED
@@ -12,7 +12,7 @@ import { shell } from './api/turn.js';
12
12
  import { useRead } from './data.js';
13
13
  import { useTarget } from './host.js';
14
14
  import { Out } from './out.js';
15
- export function Desk({ id, repo, branch, base, environment, mode, pr, events, live, refused, onRetry, onHide, }) {
15
+ export function Desk({ id, repo, branch, base, environment, mode, pr, events, live, refused, retry = 'Retry', onRetry, onHide, }) {
16
16
  const [tab, setTab] = useState('terminal');
17
17
  const [copied, setCopied] = useState(false);
18
18
  const name = repo.split('/').filter(Boolean).pop() || repo;
@@ -30,7 +30,7 @@ export function Desk({ id, repo, branch, base, environment, mode, pr, events, li
30
30
  return (_jsxs(YStack, { flex: 1, minW: 0, minH: 0, borderLeftWidth: 1, borderColor: "$borderColor", display: "none", $md: { display: 'flex' }, children: [_jsxs(XStack, { px: "$2", pt: "$2", gap: "$1", borderBottomWidth: 1, borderColor: "$borderColor", items: "center", children: [_jsx(TabButton, { id: "environment", tab: tab, onPick: setTab, children: "Environment" }), _jsx(TabButton, { id: "git", tab: tab, onPick: setTab, children: "Git" }), _jsx(TabButton, { id: "terminal", tab: tab, onPick: setTab, children: "Terminal" }), _jsx(TabButton, { id: "files", tab: tab, onPick: setTab, children: "Files" }), _jsx(TabButton, { id: "subscriptions", tab: tab, onPick: setTab, children: "Subscriptions" }), _jsx(XStack, { flex: 1 }), onHide ? (_jsx(XStack, { render: "button", "aria-label": "Hide the side pane", px: "$2", py: "$1", rounded: "$2", hoverStyle: { bg: '$hover' }, onPress: onHide, children: _jsx(PanelRightClose, { size: 16 }) })) : null, _jsx(DropdownMenu, { trigger: _jsx(XStack, { render: "button", "aria-label": "Run actions", px: "$2", py: "$1", rounded: "$2", hoverStyle: { bg: '$hover' }, children: _jsx(MoreHorizontal, { size: 16 }) }), items: [
31
31
  { key: 'copy', label: copied ? 'Copied' : 'Copy run id', icon: _jsx(Copy, { size: 16 }), description: id, onSelect: () => void copy() },
32
32
  { key: 'details', label: 'Details', onSelect: () => setTab('environment') },
33
- ] })] }), _jsxs(YStack, { flex: 1, minH: 0, overflow: tab === 'terminal' ? 'hidden' : 'scroll', p: tab === 'terminal' ? 0 : undefined, px: tab === 'terminal' ? 0 : '$3', py: tab === 'terminal' ? 0 : '$3', children: [tab === 'environment' ? (_jsxs(YStack, { gap: "$2", children: [_jsx(Fact, { label: "Run", value: id }), _jsx(Fact, { label: "Repository", value: name || '—' }), _jsx(Fact, { label: "Branch", value: branch || '—' }), _jsx(Fact, { label: "Base", value: base || '—' }), _jsx(Fact, { label: "Runs on", value: environment || 'sandbox' }), _jsx(Fact, { label: "Mode", value: mode || 'build' })] })) : null, tab === 'git' ? (_jsxs(YStack, { gap: "$2", children: [_jsx(Fact, { label: "Writing", value: branch || '—' }), _jsx(Fact, { label: "From", value: base || '—' }), pr.href ? (_jsx(Out, { href: pr.href, label: `Open pull request ${pr.label}`, children: _jsxs(SizableText, { size: "$2", color: "$ink", children: ["Pull request ", pr.label] }) })) : (_jsx(Fact, { label: "Pull request", value: "None yet" }))] })) : null, tab === 'terminal' ? (_jsx(Terminal, { lines: lines, live: live, refused: refused, onRetry: onRetry })) : null, tab === 'files' ? _jsx(Files, { repo: name, refName: ref }) : null, tab === 'subscriptions' ? (_jsx(YStack, { flex: 1, items: "center", justify: "center", px: "$6", children: _jsx(SizableText, { size: "$2", color: "$soft", style: { textAlign: 'center', maxWidth: 320 }, children: "Ask an agent to subscribe to a Slack channel or thread, a pull request, or a timer." }) })) : null] })] }));
33
+ ] })] }), _jsxs(YStack, { flex: 1, minH: 0, overflow: tab === 'terminal' ? 'hidden' : 'scroll', p: tab === 'terminal' ? 0 : undefined, px: tab === 'terminal' ? 0 : '$3', py: tab === 'terminal' ? 0 : '$3', children: [tab === 'environment' ? (_jsxs(YStack, { gap: "$2", children: [_jsx(Fact, { label: "Run", value: id }), _jsx(Fact, { label: "Repository", value: name || '—' }), _jsx(Fact, { label: "Branch", value: branch || '—' }), _jsx(Fact, { label: "Base", value: base || '—' }), _jsx(Fact, { label: "Runs on", value: environment || 'sandbox' }), _jsx(Fact, { label: "Mode", value: mode || 'build' })] })) : null, tab === 'git' ? (_jsxs(YStack, { gap: "$2", children: [_jsx(Fact, { label: "Writing", value: branch || '—' }), _jsx(Fact, { label: "From", value: base || '—' }), pr.href ? (_jsx(Out, { href: pr.href, label: `Open pull request ${pr.label}`, children: _jsxs(SizableText, { size: "$2", color: "$ink", children: ["Pull request ", pr.label] }) })) : (_jsx(Fact, { label: "Pull request", value: "None yet" }))] })) : null, tab === 'terminal' ? (_jsx(Terminal, { lines: lines, live: live, refused: refused, retry: retry, onRetry: onRetry })) : null, tab === 'files' ? _jsx(Files, { repo: name, refName: ref }) : null, tab === 'subscriptions' ? (_jsx(YStack, { flex: 1, items: "center", justify: "center", px: "$6", children: _jsx(SizableText, { size: "$2", color: "$soft", style: { textAlign: 'center', maxWidth: 320 }, children: "Ask an agent to subscribe to a Slack channel or thread, a pull request, or a timer." }) })) : null] })] }));
34
34
  }
35
35
  function TabButton({ id, tab, onPick, children }) {
36
36
  const on = tab === id;
@@ -40,10 +40,10 @@ function Fact({ label, value }) {
40
40
  return (_jsxs(XStack, { gap: "$3", items: "baseline", children: [_jsx(SizableText, { size: "$1", color: "$soft", width: 96, children: label }), _jsx(SizableText, { flex: 1, size: "$2", color: "$ink", numberOfLines: 2, children: value })] }));
41
41
  }
42
42
  const mono = { fontFamily: 'var(--f-mono, ui-monospace, monospace)', whiteSpace: 'pre' };
43
- function Terminal({ lines, live, refused, onRetry, }) {
43
+ function Terminal({ lines, live, refused, retry, onRetry, }) {
44
44
  const prompt = 'workspace';
45
45
  const idle = lines.length === 0 && !live;
46
- return (_jsxs(YStack, { flex: 1, minH: 0, bg: "$background", children: [_jsx(XStack, { px: "$3", py: "$2", borderBottomWidth: 1, borderColor: "$borderColor", children: _jsx(SizableText, { size: "$1", color: "$soft", children: "Terminal 1" }) }), _jsxs(YStack, { flex: 1, minH: 0, overflow: "scroll", px: "$3", py: "$3", gap: "$1", children: [idle ? (_jsxs(YStack, { flex: 1, items: "center", justify: "center", gap: "$3", py: "$8", children: [_jsx(SizableText, { size: "$2", color: "$soft", style: { textAlign: 'center' }, children: refused || 'This run finished without writing a command.' }), refused ? (_jsx(Button, { size: "sm", variant: "outline", onPress: onRetry, children: "Retry" })) : null] })) : (lines.map((line, i) => line.role === 'cmd' ? (_jsxs(XStack, { gap: "$2", children: [_jsx(SizableText, { size: "$1", color: "$soft", style: mono, children: `${prompt} $` }), _jsx(SizableText, { flex: 1, size: "$1", color: "$ink", style: mono, children: line.text })] }, i)) : (_jsx(SizableText, { size: "$1", color: "$ink", style: mono, children: line.text }, i)))), live ? (_jsxs(XStack, { gap: "$2", items: "center", children: [_jsx(SizableText, { size: "$1", color: "$soft", style: mono, children: `${prompt} $` }), _jsx(YStack, { width: 7, height: 14, bg: "$ink" })] })) : null] }), _jsx(XStack, { px: "$3", py: "$2", borderTopWidth: 1, borderColor: "$borderColor", children: _jsx(SizableText, { size: "$1", color: "$soft", children: "Commands appear here as the run writes them." }) })] }));
46
+ return (_jsxs(YStack, { flex: 1, minH: 0, bg: "$background", children: [_jsx(XStack, { px: "$3", py: "$2", borderBottomWidth: 1, borderColor: "$borderColor", children: _jsx(SizableText, { size: "$1", color: "$soft", children: "Terminal 1" }) }), _jsxs(YStack, { flex: 1, minH: 0, overflow: "scroll", px: "$3", py: "$3", gap: "$1", children: [idle ? (_jsxs(YStack, { flex: 1, items: "center", justify: "center", gap: "$3", py: "$8", children: [_jsx(SizableText, { size: "$2", color: "$soft", style: { textAlign: 'center' }, children: refused || 'This run finished without writing a command.' }), refused ? (_jsx(Button, { size: "sm", variant: "outline", onPress: onRetry, children: retry })) : null] })) : (lines.map((line, i) => line.role === 'cmd' ? (_jsxs(XStack, { gap: "$2", children: [_jsx(SizableText, { size: "$1", color: "$soft", style: mono, children: `${prompt} $` }), _jsx(SizableText, { flex: 1, size: "$1", color: "$ink", style: mono, children: line.text })] }, i)) : (_jsx(SizableText, { size: "$1", color: "$ink", style: mono, children: line.text }, i)))), live ? (_jsxs(XStack, { gap: "$2", items: "center", children: [_jsx(SizableText, { size: "$1", color: "$soft", style: mono, children: `${prompt} $` }), _jsx(YStack, { width: 7, height: 14, bg: "$ink" })] })) : null] }), _jsx(XStack, { px: "$3", py: "$2", borderTopWidth: 1, borderColor: "$borderColor", children: _jsx(SizableText, { size: "$1", color: "$soft", children: "Commands appear here as the run writes them." }) })] }));
47
47
  }
48
48
  function Files({ repo, refName }) {
49
49
  const t = useTarget();
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,125 @@
1
+ /**
2
+ * hanzo.build in a browser: every screen draws, stays on this origin, and
3
+ * never asks platform.hanzo.ai for anything. Signed out, so it checks what a
4
+ * visitor sees; the MCP list is public and is read live.
5
+ */
6
+ import { expect, test } from '@playwright/test';
7
+ const SCREENS = [
8
+ ['new', '/', /What.s up next\?/],
9
+ ['automations', '/-/automations', /New automation/],
10
+ ['codebases', '/-/codebases', /Create and browse this organization.s repositories/],
11
+ ['sync', '/-/sync', /Select repositories/],
12
+ ['projects', '/-/projects', /Boards on the forge/],
13
+ ['issues', '/-/issues', /Open work across every board/],
14
+ ['artifacts', '/-/artifacts', /What this organization has built/],
15
+ ['templates', '/-/templates', /Start from a working app/],
16
+ ['mcp', '/-/mcp', /Native servers/],
17
+ ['run', '/sess_0992f90537264a6b154ebff799f38e1c', /Sign in to follow this run/],
18
+ ];
19
+ const RAIL = ['New', 'Automations', 'Codebase', 'Projects', 'Issues', 'Artifacts', 'MCP'];
20
+ /** Everything the page asked for and every error it threw, for the whole test. */
21
+ function watch(page) {
22
+ const hosts = new Set();
23
+ const errors = [];
24
+ page.on('request', (r) => hosts.add(new URL(r.url()).hostname));
25
+ page.on('pageerror', (e) => errors.push(e.message));
26
+ return { hosts, errors };
27
+ }
28
+ for (const [name, path, says] of SCREENS) {
29
+ test(`${name} draws on this origin`, async ({ page, baseURL }, info) => {
30
+ const seen = watch(page);
31
+ await page.goto(path);
32
+ await expect(page.getByText(says).first()).toBeVisible();
33
+ for (const label of RAIL)
34
+ await expect(page.getByText(label, { exact: true }).first()).toBeVisible();
35
+ await page.waitForLoadState('networkidle');
36
+ expect(new URL(page.url()).origin).toBe(new URL(baseURL).origin);
37
+ expect([...seen.hosts]).not.toContain('platform.hanzo.ai');
38
+ expect(seen.errors).toEqual([]);
39
+ await page.screenshot({ path: info.outputPath(`${name}.png`) });
40
+ });
41
+ }
42
+ test('the rail moves between screens without leaving the page', async ({ page, baseURL }) => {
43
+ const seen = watch(page);
44
+ await page.goto('/');
45
+ for (const [label, at] of [
46
+ ['Automations', '/-/automations'],
47
+ ['Codebase', '/-/codebases'],
48
+ ['Projects', '/-/projects'],
49
+ ['Issues', '/-/issues'],
50
+ ['Artifacts', '/-/artifacts'],
51
+ ['MCP', '/-/mcp'],
52
+ ['New', '/'],
53
+ ]) {
54
+ await page.getByText(label, { exact: true }).first().click();
55
+ await expect(page).toHaveURL(new URL(at, baseURL).href);
56
+ }
57
+ expect([...seen.hosts]).not.toContain('platform.hanzo.ai');
58
+ });
59
+ test('one mark at the top left when signed out', async ({ page }) => {
60
+ await page.goto('/');
61
+ await expect(page.getByRole('button', { name: 'Hanzo', exact: true })).toHaveCount(1);
62
+ await expect(page.getByText('Sign in', { exact: true }).first()).toBeVisible();
63
+ });
64
+ test('settings stays on this page', async ({ page, baseURL }) => {
65
+ await page.goto('/-/codebases');
66
+ await page.getByText('Settings', { exact: true }).first().click();
67
+ expect(new URL(page.url()).origin).toBe(new URL(baseURL).origin);
68
+ });
69
+ test('the MCP screen lists the native servers', async ({ page }, info) => {
70
+ await page.goto('/-/mcp');
71
+ const count = page.getByText(/^\d+ servers · \d+ operations$/);
72
+ await expect(count).toBeVisible();
73
+ const servers = Number((await count.innerText()).split(' ')[0]);
74
+ expect(servers).toBeGreaterThan(100);
75
+ await page.getByLabel('Find a server').fill('git');
76
+ await expect(page.getByLabel('git', { exact: true })).toBeVisible();
77
+ await page.screenshot({ path: info.outputPath('mcp-git.png') });
78
+ });
79
+ test('a run hides and shows its side pane', async ({ page }) => {
80
+ await page.goto('/sess_0992f90537264a6b154ebff799f38e1c');
81
+ await page.getByLabel('Hide the side pane').click();
82
+ await expect(page.getByText('Subscriptions', { exact: true })).toHaveCount(0);
83
+ await page.getByLabel('Show the side pane').click();
84
+ await expect(page.getByText('Subscriptions', { exact: true })).toBeVisible();
85
+ });
86
+ /** Controls and text a phone would cut off at its right edge, or that sit on top of another control. */
87
+ async function cramped(page) {
88
+ return page.evaluate(() => {
89
+ const w = window.innerWidth;
90
+ const out = [];
91
+ const leaves = [...document.querySelectorAll('body *')].filter((el) => !el.children.length || ['BUTTON', 'INPUT'].includes(el.tagName) || el.getAttribute('role') === 'button');
92
+ for (const el of leaves) {
93
+ const r = el.getBoundingClientRect();
94
+ const s = getComputedStyle(el);
95
+ if (!r.width || !r.height || s.visibility === 'hidden' || s.opacity === '0')
96
+ continue;
97
+ if (r.right > w + 1 || r.left < -1)
98
+ out.push(`${el.tagName} ${(el.textContent || el.getAttribute('aria-label') || '').trim().slice(0, 40)}`);
99
+ }
100
+ const menu = document.querySelector('[aria-label="Open runs"]')?.getBoundingClientRect();
101
+ const mark = [...document.querySelectorAll('[aria-label="Hanzo"], [aria-label^="Organization"]')]
102
+ .map((el) => el.getBoundingClientRect())
103
+ .find((r) => r.width && r.height);
104
+ if (menu && mark && mark.left < menu.right && menu.left < mark.right && mark.top < menu.bottom && menu.top < mark.bottom)
105
+ out.push('the mark sits on the menu button');
106
+ return out;
107
+ });
108
+ }
109
+ test('a phone gets every screen whole, with the rail as a drawer', async ({ page }, info) => {
110
+ await page.setViewportSize({ width: 390, height: 844 });
111
+ for (const [name, path, says] of SCREENS) {
112
+ await page.goto(path);
113
+ await expect(page.getByText(says).first()).toBeVisible();
114
+ await expect(page.getByLabel('Open runs')).toBeVisible();
115
+ expect(await cramped(page), name).toEqual([]);
116
+ await page.screenshot({ path: info.outputPath(`phone-${name}.png`) });
117
+ }
118
+ await page.getByLabel('Open runs').click();
119
+ await expect(page.getByRole('navigation', { name: 'Runs' }).getByText('Codebase', { exact: true })).toBeVisible();
120
+ });
121
+ test('a run asks a visitor to sign in, not for an API key', async ({ page }) => {
122
+ await page.goto('/sess_0992f90537264a6b154ebff799f38e1c');
123
+ await expect(page.getByText('Sign in to follow this run.').first()).toBeVisible();
124
+ await expect(page.getByText(/Authorization: Bearer/)).toHaveCount(0);
125
+ });
package/lib/forge.js CHANGED
@@ -71,7 +71,7 @@ function Automations() {
71
71
  setProblem(e instanceof Error ? e.message : 'Could not change that automation');
72
72
  }
73
73
  };
74
- return (_jsxs(YStack, { flex: 1, minH: 0, overflow: "scroll", px: "$6", py: "$6", children: [_jsxs(YStack, { width: "100%", maxW: 1040, mx: "auto", gap: "$4", children: [_jsxs(XStack, { justify: "space-between", items: "flex-start", gap: "$4", children: [_jsxs(YStack, { gap: "$1", children: [_jsx(SizableText, { size: "$6", fontWeight: "500", color: "$ink", children: "Automations" }), _jsx(SizableText, { size: "$2", color: "$soft", children: "Repeating work for this organization. A new one starts off, so you can name it before it runs." })] }), _jsxs(Button, { size: "sm", disabled: !signed, onPress: () => setMaking(true), children: [_jsx(Plus, { size: 14 }), "New automation"] })] }), _jsx(XStack, { justify: "flex-end", children: _jsx(YStack, { width: 240, children: _jsx(Input, { value: q, onChangeText: setQ, placeholder: "Search\u2026", "aria-label": "Search automations", disabled: !signed }) }) }), problem ? _jsx(Note, { children: problem }) : null, list.error ? (_jsx(Note, { children: list.error.message })) : !signed ? (_jsx(Empty, { says: "Sign in to see this organization's automations." })) : list.loading && list.value.length === 0 ? (_jsx(Empty, { says: "Reading automations\u2026" })) : shown.length === 0 ? (_jsx(Empty, { says: list.value.length === 0 ? 'No automations yet.' : 'Nothing matches.', children: list.value.length === 0 ? (_jsx(Button, { size: "sm", onPress: () => setMaking(true), children: "New automation" })) : null })) : (_jsxs(YStack, { borderWidth: 1, borderColor: "$borderColor", rounded: "$3", overflow: "hidden", children: [_jsxs(XStack, { px: "$3", py: "$2", gap: "$3", children: [_jsx(SizableText, { flex: 1, size: "$1", color: "$soft", children: "Name" }), _jsx(SizableText, { size: "$1", color: "$soft", width: 80, style: { textAlign: 'right' }, children: "Status" }), _jsx(SizableText, { size: "$1", color: "$soft", width: 120, style: { textAlign: 'right' }, children: "Updated" })] }), shown.map((a) => (_jsxs(XStack, { items: "center", gap: "$3", px: "$3", py: "$2.5", borderTopWidth: 1, borderColor: "$borderColor", children: [_jsx(Workflow, { size: 14 }), _jsx(SizableText, { flex: 1, size: "$2", color: "$ink", numberOfLines: 1, children: a.name }), _jsx(XStack, { render: "button", "aria-label": a.status === 'ENABLED' ? `Turn off ${a.name}` : `Turn on ${a.name}`, onPress: () => void flip(a), width: 80, justify: "flex-end", children: _jsx(SizableText, { size: "$1", color: "$ink", children: a.status === 'ENABLED' ? 'On' : 'Off' }) }), _jsx(SizableText, { size: "$1", color: "$soft", width: 120, style: { textAlign: 'right' }, children: a.updated ? when(new Date(a.updated).toISOString()) : '—' })] }, a.id)))] }))] }), _jsx(NewAutomation, { open: making, onOpenChange: setMaking, onCreate: async (name) => {
74
+ return (_jsxs(YStack, { flex: 1, minH: 0, overflow: "scroll", px: "$6", py: "$6", children: [_jsxs(YStack, { width: "100%", maxW: 1040, mx: "auto", gap: "$4", children: [_jsxs(XStack, { justify: "space-between", items: "flex-start", gap: "$4", children: [_jsxs(YStack, { gap: "$1", flex: 1, minW: 0, children: [_jsx(SizableText, { size: "$6", fontWeight: "500", color: "$ink", children: "Automations" }), _jsx(SizableText, { size: "$2", color: "$soft", children: "Repeating work for this organization. A new one starts off, so you can name it before it runs." })] }), _jsxs(Button, { size: "sm", shrink: 0, disabled: !signed, onPress: () => setMaking(true), children: [_jsx(Plus, { size: 14 }), "New automation"] })] }), _jsx(XStack, { justify: "flex-end", children: _jsx(YStack, { width: 240, maxW: "100%", children: _jsx(Input, { value: q, onChangeText: setQ, placeholder: "Search\u2026", "aria-label": "Search automations", disabled: !signed }) }) }), problem ? _jsx(Note, { children: problem }) : null, list.error ? (_jsx(Note, { children: list.error.message })) : !signed ? (_jsx(Empty, { says: "Sign in to see this organization's automations." })) : list.loading && list.value.length === 0 ? (_jsx(Empty, { says: "Reading automations\u2026" })) : shown.length === 0 ? (_jsx(Empty, { says: list.value.length === 0 ? 'No automations yet.' : 'Nothing matches.', children: list.value.length === 0 ? (_jsx(Button, { size: "sm", onPress: () => setMaking(true), children: "New automation" })) : null })) : (_jsxs(YStack, { borderWidth: 1, borderColor: "$borderColor", rounded: "$3", overflow: "hidden", children: [_jsxs(XStack, { px: "$3", py: "$2", gap: "$3", children: [_jsx(SizableText, { flex: 1, size: "$1", color: "$soft", children: "Name" }), _jsx(SizableText, { size: "$1", color: "$soft", width: 80, style: { textAlign: 'right' }, children: "Status" }), _jsx(SizableText, { size: "$1", color: "$soft", width: 120, style: { textAlign: 'right' }, children: "Updated" })] }), shown.map((a) => (_jsxs(XStack, { items: "center", gap: "$3", px: "$3", py: "$2.5", borderTopWidth: 1, borderColor: "$borderColor", children: [_jsx(Workflow, { size: 14 }), _jsx(SizableText, { flex: 1, size: "$2", color: "$ink", numberOfLines: 1, children: a.name }), _jsx(XStack, { render: "button", "aria-label": a.status === 'ENABLED' ? `Turn off ${a.name}` : `Turn on ${a.name}`, onPress: () => void flip(a), width: 80, justify: "flex-end", children: _jsx(SizableText, { size: "$1", color: "$ink", children: a.status === 'ENABLED' ? 'On' : 'Off' }) }), _jsx(SizableText, { size: "$1", color: "$soft", width: 120, style: { textAlign: 'right' }, children: a.updated ? when(new Date(a.updated).toISOString()) : '—' })] }, a.id)))] }))] }), _jsx(NewAutomation, { open: making, onOpenChange: setMaking, onCreate: async (name) => {
75
75
  await add(t, name);
76
76
  setMaking(false);
77
77
  setTick((n) => n + 1);
@@ -173,7 +173,7 @@ function Codebases() {
173
173
  pinCodebase(host.org, c);
174
174
  host.go('');
175
175
  };
176
- return (_jsxs(YStack, { flex: 1, minH: 0, overflow: "scroll", px: "$6", py: "$6", children: [_jsxs(YStack, { width: "100%", maxW: 1040, mx: "auto", gap: "$4", children: [_jsxs(XStack, { justify: "space-between", items: "flex-start", gap: "$4", children: [_jsxs(YStack, { gap: "$1", children: [_jsx(SizableText, { size: "$6", fontWeight: "500", color: "$ink", children: host.org || 'Forge' }), _jsx(SizableText, { size: "$2", color: "$soft", children: "Create and browse this organization\u2019s repositories." })] }), _jsxs(XStack, { render: "button", "aria-label": "Settings", items: "center", gap: "$1.5", px: "$2", py: "$1", rounded: "$3", hoverStyle: { bg: '$hover' }, onPress: () => revealAccount(), children: [_jsx(Settings, { size: 14 }), _jsx(SizableText, { size: "$2", color: "$soft", children: "Settings" })] })] }), _jsxs(XStack, { gap: "$2", items: "center", children: [_jsx(XStack, { px: "$2.5", py: "$1.5", rounded: "$3", borderWidth: 1, borderColor: "$borderColor", children: _jsx(SizableText, { size: "$2", color: "$ink", children: "All repos" }) }), _jsx(YStack, { flex: 1 }), _jsx(YStack, { width: 240, minW: 0, children: _jsx(Input, { value: q, onChangeText: setQ, placeholder: "Find repo\u2026", "aria-label": "Find a repository", disabled: !signed }) }), _jsx(Button, { size: "sm", variant: "outline", onPress: () => host.go(path({ kind: 'screen', screen: 'sync' })), children: "Sync" }), _jsxs(Button, { size: "sm", disabled: !signed, onPress: () => setMaking(true), children: [_jsx(Plus, { size: 14 }), "New"] })] }), list.error ? (_jsx(Note, { children: list.error.message })) : !signed ? (_jsx(Note, { children: "Sign in to see this organization's repositories." })) : list.loading && list.value.length === 0 ? (_jsx(Note, { children: "Reading the forge\u2026" })) : shown.length === 0 ? (_jsx(Note, { children: list.value.length === 0 ? 'No repositories yet.' : 'Nothing matches.' })) : (_jsxs(YStack, { borderWidth: 1, borderColor: "$borderColor", rounded: "$3", overflow: "hidden", children: [_jsxs(XStack, { px: "$3", py: "$2", gap: "$3", items: "center", children: [_jsxs(XStack, { flex: 1, render: "button", "aria-label": "Sort by name", items: "center", gap: "$1", onPress: () => setSort('name'), children: [_jsx(SizableText, { size: "$1", color: "$soft", children: "Name" }), sort === 'name' ? _jsx(ChevronDown, { size: 12 }) : null] }), _jsxs(XStack, { render: "button", "aria-label": "Sort by last updated", items: "center", justify: "flex-end", gap: "$1", width: 140, onPress: () => setSort('updated'), children: [_jsx(SizableText, { size: "$1", color: "$soft", children: "Last updated" }), sort === 'updated' ? _jsx(ChevronDown, { size: 12 }) : null] })] }), slice.map((c) => (_jsxs(XStack, { render: "button", "aria-label": `Work on ${c.name}`, onPress: () => open(c), items: "center", gap: "$3", px: "$3", py: "$2.5", borderTopWidth: 1, borderColor: "$borderColor", hoverStyle: { bg: '$hover' }, focusVisibleStyle: { outlineWidth: 2, outlineStyle: 'solid', outlineColor: '$outlineColor' }, children: [_jsx(GitBranch, { size: 14 }), _jsx(SizableText, { flex: 1, size: "$2", color: "$ink", numberOfLines: 1, children: c.name }), _jsx(SizableText, { size: "$1", color: "$soft", width: 140, style: { textAlign: 'right' }, children: syncing.has(c.name) ? 'Syncing…' : when(c.updated) })] }, `${c.org}/${c.name}`))), _jsxs(XStack, { px: "$3", py: "$2", justify: "space-between", items: "center", borderTopWidth: 1, borderColor: "$borderColor", children: [_jsx(SizableText, { size: "$1", color: "$soft", children: `Showing ${from}–${to} of ${shown.length}` }), _jsxs(XStack, { gap: "$3", items: "center", children: [_jsx(XStack, { render: "button", "aria-label": "Previous page", disabled: safePage === 0, onPress: () => setPage(safePage - 1), children: _jsx(SizableText, { size: "$1", color: safePage === 0 ? '$soft' : '$ink', children: "Prev" }) }), _jsx(SizableText, { size: "$1", color: "$soft", children: `${safePage + 1} / ${pages}` }), _jsx(XStack, { render: "button", "aria-label": "Next page", disabled: safePage >= pages - 1, onPress: () => setPage(safePage + 1), children: _jsx(SizableText, { size: "$1", color: safePage >= pages - 1 ? '$soft' : '$ink', children: "Next" }) })] })] })] }))] }), _jsx(NewRepo, { open: making, onOpenChange: setMaking, onCreate: async (name, description) => {
176
+ return (_jsxs(YStack, { flex: 1, minH: 0, overflow: "scroll", px: "$6", py: "$6", children: [_jsxs(YStack, { width: "100%", maxW: 1040, mx: "auto", gap: "$4", children: [_jsxs(XStack, { justify: "space-between", items: "flex-start", gap: "$4", children: [_jsxs(YStack, { gap: "$1", flex: 1, minW: 0, children: [_jsx(SizableText, { size: "$6", fontWeight: "500", color: "$ink", children: host.org || 'Forge' }), _jsx(SizableText, { size: "$2", color: "$soft", children: "Create and browse this organization\u2019s repositories." })] }), _jsxs(XStack, { render: "button", "aria-label": "Settings", shrink: 0, items: "center", gap: "$1.5", px: "$2", py: "$1", rounded: "$3", hoverStyle: { bg: '$hover' }, onPress: () => revealAccount(), children: [_jsx(Settings, { size: 14 }), _jsx(SizableText, { size: "$2", color: "$soft", children: "Settings" })] })] }), _jsxs(XStack, { gap: "$2", items: "center", children: [_jsx(XStack, { px: "$2.5", py: "$1.5", rounded: "$3", borderWidth: 1, borderColor: "$borderColor", shrink: 0, "$max-md": { display: 'none' }, children: _jsx(SizableText, { size: "$2", color: "$ink", children: "All repos" }) }), _jsx(YStack, { flex: 1, "$max-md": { display: 'none' } }), _jsx(YStack, { width: 240, minW: 0, shrink: 1, "$max-md": { width: 'auto', flex: 1 }, children: _jsx(Input, { value: q, onChangeText: setQ, placeholder: "Find repo\u2026", "aria-label": "Find a repository", disabled: !signed }) }), _jsx(Button, { size: "sm", variant: "outline", onPress: () => host.go(path({ kind: 'screen', screen: 'sync' })), children: "Sync" }), _jsxs(Button, { size: "sm", disabled: !signed, onPress: () => setMaking(true), children: [_jsx(Plus, { size: 14 }), "New"] })] }), list.error ? (_jsx(Note, { children: list.error.message })) : !signed ? (_jsx(Note, { children: "Sign in to see this organization's repositories." })) : list.loading && list.value.length === 0 ? (_jsx(Note, { children: "Reading the forge\u2026" })) : shown.length === 0 ? (_jsx(Note, { children: list.value.length === 0 ? 'No repositories yet.' : 'Nothing matches.' })) : (_jsxs(YStack, { borderWidth: 1, borderColor: "$borderColor", rounded: "$3", overflow: "hidden", children: [_jsxs(XStack, { px: "$3", py: "$2", gap: "$3", items: "center", children: [_jsxs(XStack, { flex: 1, render: "button", "aria-label": "Sort by name", items: "center", gap: "$1", onPress: () => setSort('name'), children: [_jsx(SizableText, { size: "$1", color: "$soft", children: "Name" }), sort === 'name' ? _jsx(ChevronDown, { size: 12 }) : null] }), _jsxs(XStack, { render: "button", "aria-label": "Sort by last updated", items: "center", justify: "flex-end", gap: "$1", width: 140, onPress: () => setSort('updated'), children: [_jsx(SizableText, { size: "$1", color: "$soft", children: "Last updated" }), sort === 'updated' ? _jsx(ChevronDown, { size: 12 }) : null] })] }), slice.map((c) => (_jsxs(XStack, { render: "button", "aria-label": `Work on ${c.name}`, onPress: () => open(c), items: "center", gap: "$3", px: "$3", py: "$2.5", borderTopWidth: 1, borderColor: "$borderColor", hoverStyle: { bg: '$hover' }, focusVisibleStyle: { outlineWidth: 2, outlineStyle: 'solid', outlineColor: '$outlineColor' }, children: [_jsx(GitBranch, { size: 14 }), _jsx(SizableText, { flex: 1, size: "$2", color: "$ink", numberOfLines: 1, children: c.name }), _jsx(SizableText, { size: "$1", color: "$soft", width: 140, style: { textAlign: 'right' }, children: syncing.has(c.name) ? 'Syncing…' : when(c.updated) })] }, `${c.org}/${c.name}`))), _jsxs(XStack, { px: "$3", py: "$2", justify: "space-between", items: "center", borderTopWidth: 1, borderColor: "$borderColor", children: [_jsx(SizableText, { size: "$1", color: "$soft", children: `Showing ${from}–${to} of ${shown.length}` }), _jsxs(XStack, { gap: "$3", items: "center", children: [_jsx(XStack, { render: "button", "aria-label": "Previous page", disabled: safePage === 0, onPress: () => setPage(safePage - 1), children: _jsx(SizableText, { size: "$1", color: safePage === 0 ? '$soft' : '$ink', children: "Prev" }) }), _jsx(SizableText, { size: "$1", color: "$soft", children: `${safePage + 1} / ${pages}` }), _jsx(XStack, { render: "button", "aria-label": "Next page", disabled: safePage >= pages - 1, onPress: () => setPage(safePage + 1), children: _jsx(SizableText, { size: "$1", color: safePage >= pages - 1 ? '$soft' : '$ink', children: "Next" }) })] })] })] }))] }), _jsx(NewRepo, { open: making, onOpenChange: setMaking, onCreate: async (name, description) => {
177
177
  const row = await create(t, name, description);
178
178
  pinCodebase(host.org, row);
179
179
  setMaking(false);
package/lib/run.js CHANGED
@@ -21,12 +21,15 @@ import { message, stop } from './api/sessions.js';
21
21
  import { outcome, pull, said, steps, who } from './api/turn.js';
22
22
  import { useKept, 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,7 +42,7 @@ 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');
44
47
  useEffect(() => {
45
48
  if (!notify || running)
@@ -90,8 +93,8 @@ export function Run({ id }) {
90
93
  setNote(e instanceof Error ? e.message : 'Could not stop this run');
91
94
  }
92
95
  };
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) => {
96
+ 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 ? detail.error.message : detail.loading ? 'Reading this run…' : running ? 'Setting up environment' : '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
97
  const current = !s.done && plan.findIndex((x) => !x.done) === i;
95
98
  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] }));
99
+ })] })) : 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: !signed ? 'Sign in to follow up' : 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: 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] }));
97
100
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanzo/build",
3
- "version": "0.2.8",
3
+ "version": "0.2.9",
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
  }