@hanzo/build 0.2.7 → 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
 
@@ -84,11 +86,13 @@ collide.
84
86
 
85
87
  ## What is on the screen
86
88
 
87
- **The rail** (hanzo.build). New, then Automations, then the forge — Codebase, Projects, Issues —
88
- then MCP, Artifacts, More (Templates, Machines, Docs), then the org's coding
89
- runs newest first with a live status dot, and the account. Collapse is an
90
- explicit toggle kept in this browser. A host with its own rail draws
91
- `DevSection` there instead.
89
+ **The rail** (hanzo.build). At the top, the mark. Signed in to an organization,
90
+ that spot is the organization switcher: the org, and the projects under it.
91
+ New, then Automations, then the forge — Codebase, Projects, Issues — then MCP,
92
+ Artifacts, More (Templates, Machines, Docs), then the org's coding runs newest
93
+ first with a live status dot. The account stays at the foot. Settings opens
94
+ that account on this page. Collapse is an explicit toggle kept in this browser.
95
+ A host with its own rail draws `DevSection` there instead.
92
96
 
93
97
  **MCP.** The native servers `POST /v1/mcp` lists, each with the operations it
94
98
  names. A run starts a server when it calls it, and the server stays up afterwards.
package/lib/account.js CHANGED
@@ -4,7 +4,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
4
4
  * finding a run by name (the search icon).
5
5
  */
6
6
  import { SizableText, XStack, YStack } from '@hanzo/gui';
7
- import { LogOut, Settings } from '@hanzogui/lucide-icons-2';
7
+ import { LogOut } from '@hanzogui/lucide-icons-2';
8
8
  import { Button, Dialog, DialogContent, DialogTitle, Input } from '@hanzo/ui';
9
9
  import { useMemo, useState } from 'react';
10
10
  import { useHost } from './host.js';
@@ -20,21 +20,10 @@ export function onAccount(fn) {
20
20
  export function revealAccount() {
21
21
  reveal?.();
22
22
  }
23
- /** A settings address on another host. This page's own account is the dialog itself. */
24
- function accountSettings(href) {
25
- if (typeof window === 'undefined')
26
- return false;
27
- try {
28
- return new URL(href, window.location.origin).origin !== window.location.origin;
29
- }
30
- catch {
31
- return false;
32
- }
33
- }
34
23
  export function Account({ open, onOpenChange }) {
35
24
  const host = useHost();
36
25
  const who = host.person;
37
- return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { maxW: 360, children: [_jsx(DialogTitle, { children: "Account" }), _jsxs(YStack, { gap: "$1", children: [_jsx(SizableText, { size: "$3", color: "$ink", numberOfLines: 1, children: who?.name || who?.email || 'Signed out' }), who?.email && who.name ? (_jsx(SizableText, { size: "$2", color: "$soft", numberOfLines: 1, children: who.email })) : null, host.org ? (_jsxs(SizableText, { size: "$1", color: "$soft", children: ["Organization: ", host.org] })) : null] }), _jsxs(XStack, { gap: "$2", justify: "flex-end", flexWrap: "wrap", children: [accountSettings(host.links.settings) ? (_jsxs(Button, { variant: "outline", size: "sm", onPress: () => host.open(host.links.settings), children: [_jsx(Settings, { size: 14 }), " Settings"] })) : null, host.signOut ? (_jsxs(Button, { variant: "outline", size: "sm", onPress: () => host.signOut?.(), children: [_jsx(LogOut, { size: 14 }), " Sign out"] })) : null] })] }) }));
26
+ return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { maxW: 360, children: [_jsx(DialogTitle, { children: "Account" }), _jsxs(YStack, { gap: "$1", children: [_jsx(SizableText, { size: "$3", color: "$ink", numberOfLines: 1, children: who?.name || who?.email || 'Signed out' }), who?.email && who.name ? (_jsx(SizableText, { size: "$2", color: "$soft", numberOfLines: 1, children: who.email })) : null, host.org ? (_jsxs(SizableText, { size: "$1", color: "$soft", children: ["Organization: ", host.org] })) : null] }), _jsx(XStack, { gap: "$2", justify: "flex-end", flexWrap: "wrap", children: host.signOut ? (_jsxs(Button, { variant: "outline", size: "sm", onPress: () => host.signOut?.(), children: [_jsx(LogOut, { size: 14 }), " Sign out"] })) : null })] }) }));
38
27
  }
39
28
  export function Find({ open, onOpenChange, recents, onOpen, }) {
40
29
  const [q, setQ] = useState('');
package/lib/builder.js CHANGED
@@ -42,6 +42,7 @@ import { Run } from './run.js';
42
42
  import { DOTS } from './section.js';
43
43
  import { Artifacts, Templates } from './shelf.js';
44
44
  import { Account, Find, onAccount } from './account.js';
45
+ import { Where } from './switch.js';
45
46
  function Shell() {
46
47
  const host = useHost();
47
48
  const t = useTarget();
@@ -64,6 +65,10 @@ function Shell() {
64
65
  return list;
65
66
  }, [recents.value, order]);
66
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 }) }));
67
72
  const links = [
68
73
  { id: 'automations', label: 'Automations', icon: _jsx(Workflow, { size: 16 }), onPress: () => go(path({ kind: 'screen', screen: 'automations' })), active: screen === 'automations' },
69
74
  { id: 'codebases', label: 'Codebase', icon: _jsx(FolderGit2, { size: 16 }), onPress: () => go(path({ kind: 'screen', screen: 'codebases' })), active: screen === 'codebases' },
@@ -87,37 +92,13 @@ function Shell() {
87
92
  id: 'machines',
88
93
  label: 'Machines',
89
94
  icon: _jsx(Cpu, { size: 16 }),
90
- onPress: () => {
91
- const home = host.links.home.replace(/\/+$/, '');
92
- const computers = `${home}/platform/computers`;
93
- try {
94
- if (new URL(computers, window.location.origin).origin === window.location.origin) {
95
- host.go('');
96
- return;
97
- }
98
- }
99
- catch {
100
- return;
101
- }
102
- host.open(computers);
103
- },
95
+ onPress: () => host.go(''),
104
96
  },
105
97
  { id: 'docs', label: 'Docs', icon: _jsx(BookOpen, { size: 16 }), onPress: () => window.open('https://docs.hanzo.ai/docs/dev', '_blank', 'noopener,noreferrer') },
106
98
  ];
107
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
108
100
  ? { name: host.person.email || host.person.name, onPress: () => setAccount(true) }
109
- : { name: 'Sign in', onPress: () => host.signIn?.() }, onSettings: () => {
110
- try {
111
- if (new URL(host.links.settings, window.location.origin).origin === window.location.origin) {
112
- setAccount(true);
113
- return;
114
- }
115
- }
116
- catch {
117
- return;
118
- }
119
- host.open(host.links.settings);
120
- }, onSearch: () => setFinding(true), collapsed: collapsed, onCollapse: setCollapsed, mark: _jsx(Brand, { org: host.org }), pt: collapsed ? undefined : 44, open: drawer, onOpenChange: setDrawer, label: "Runs" }), collapsed ? null : (_jsxs(XStack, { position: "absolute", t: 8, l: 8, r: 8, z: 2, items: "center", gap: "$2", render: "button", "aria-label": host.org ? `Organization ${host.org}` : 'Hanzo', onPress: () => go(''), children: [_jsx(Brand, { org: host.org }), _jsx(SizableText, { size: "$2", color: "$ink", numberOfLines: 1, children: host.org || 'Hanzo' })] }))] }), _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); } })] }));
121
102
  }
122
103
  /** Whichever pane the address names, beside a rail — the builder's or the host's. */
123
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,18 +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: () => {
177
- try {
178
- if (new URL(host.links.settings, window.location.origin).origin === window.location.origin) {
179
- revealAccount();
180
- return;
181
- }
182
- }
183
- catch {
184
- return;
185
- }
186
- host.open(host.links.settings);
187
- }, 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) => {
188
177
  const row = await create(t, name, description);
189
178
  pinCodebase(host.org, row);
190
179
  setMaking(false);
package/lib/host.d.ts CHANGED
@@ -22,6 +22,10 @@ export interface Host {
22
22
  token: () => string | null;
23
23
  /** The org every call is scoped to. */
24
24
  org: string | null;
25
+ /** The organizations this person belongs to. The switcher lists these and no others. */
26
+ memberships?: readonly string[];
27
+ /** Stay on this page and scope every read to `org`. */
28
+ chooseOrg?: (org: string) => void;
25
29
  /** Who is signed in, or null. */
26
30
  person: Person | null;
27
31
  /** Whether the person administers `org`: an admin publishes to main, anyone else opens a review. */
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
  }
@@ -0,0 +1 @@
1
+ export declare function Where(): import("react").JSX.Element;
package/lib/switch.js ADDED
@@ -0,0 +1,40 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * The rail's top left: the organization, and the projects under it.
4
+ *
5
+ * The account stays at the foot of the rail. Settings opens that account on
6
+ * this page. Choosing an organization reloads this origin so every read is
7
+ * scoped to it.
8
+ */
9
+ import { YStack } from '@hanzo/gui';
10
+ import { MenuLabel, MenuRow, MenuRule, OrgSwitcher } from '@hanzo/ui/product';
11
+ import { revealAccount } from './account.js';
12
+ import { projects } from './api/projects.js';
13
+ import { useRead } from './data.js';
14
+ import { useHost, useTarget } from './host.js';
15
+ import { route } from './route.js';
16
+ export function Where() {
17
+ const host = useHost();
18
+ const t = useTarget();
19
+ const signed = Boolean(host.person);
20
+ const list = useRead(signed ? () => projects(t) : null, [], [t, signed]);
21
+ const here = route(host.path);
22
+ const current = host.org || '';
23
+ const scope = {
24
+ currentOrg: () => current,
25
+ setCurrentOrg: (next) => host.chooseOrg?.(next),
26
+ isScopedAway: () => false,
27
+ hasSelectedOrg: () => Boolean(current),
28
+ enterOrg: (next) => host.chooseOrg?.(next),
29
+ leaveOrg: () => undefined,
30
+ switchOrg: (next) => host.chooseOrg?.(next),
31
+ };
32
+ const rows = () => Promise.resolve((host.memberships ?? (current ? [current] : [])).map((name) => ({ name, displayName: name })));
33
+ return (_jsx(OrgSwitcher, { scope: scope, orgs: (page) => (page === 0 ? rows() : Promise.resolve([])), pageSize: 50, search: false, heading: "Organization", lead: true, sub: here.kind === 'project' ? here.slug : undefined, current: current ? { name: current, displayName: current } : undefined, aria: current ? `Organization ${current}` : 'Organization', footer: (close) => (_jsxs(YStack, { gap: "$1", children: [list.value.length > 0 ? (_jsxs(_Fragment, { children: [_jsx(MenuRule, {}), _jsx(MenuLabel, { children: "Project" }), _jsx(YStack, { role: "radiogroup", "aria-label": "Projects", gap: "$1", children: list.value.slice(0, 12).map((p) => (_jsx(MenuRow, { label: p.name || p.slug, active: here.kind === 'project' && here.slug === p.slug, onPress: () => {
34
+ close();
35
+ host.go(p.slug);
36
+ } }, p.slug))) })] })) : null, _jsx(MenuRule, {}), _jsx(MenuRow, { label: "Settings", onPress: () => {
37
+ close();
38
+ revealAccount();
39
+ } })] })) }));
40
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanzo/build",
3
- "version": "0.2.7",
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
  }