@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 +9 -5
- package/lib/account.js +2 -13
- package/lib/builder.js +7 -26
- package/lib/desk.d.ts +3 -1
- package/lib/desk.js +4 -4
- package/lib/e2e/site.spec.d.ts +1 -0
- package/lib/e2e/site.spec.js +125 -0
- package/lib/forge.js +2 -13
- package/lib/host.d.ts +4 -0
- package/lib/run.js +8 -5
- package/lib/switch.d.ts +1 -0
- package/lib/switch.js +40 -0
- package/package.json +4 -2
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).
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
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] }),
|
|
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:
|
|
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
|
-
|
|
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/lib/switch.d.ts
ADDED
|
@@ -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.
|
|
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
|
}
|