@hanzo/build 0.2.40 → 0.2.42
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/ago.d.ts +2 -0
- package/lib/ago.js +18 -0
- package/lib/api/auto.d.ts +78 -19
- package/lib/api/auto.js +110 -50
- package/lib/api/models.d.ts +15 -12
- package/lib/api/models.js +12 -34
- package/lib/automations.d.ts +5 -0
- package/lib/automations.js +294 -0
- package/lib/builder.js +5 -2
- package/lib/customize/agents.js +3 -12
- package/lib/forge.js +4 -70
- package/lib/landing.js +8 -4
- package/lib/pick.d.ts +2 -0
- package/lib/pick.js +21 -0
- package/lib/route.d.ts +6 -0
- package/lib/route.js +8 -0
- package/lib/run.js +8 -5
- package/lib/section.js +1 -1
- package/lib/settings/code.js +4 -3
- package/package.json +4 -4
- package/lib/access.d.ts +0 -4
- package/lib/access.js +0 -16
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Automations: work that runs itself in this organization, on a schedule or
|
|
4
|
+
* when someone presses Run. Each run is one Dev run.
|
|
5
|
+
*
|
|
6
|
+
* -/automations the org's automations; a row opens its editor
|
|
7
|
+
* -/automations/new a new one: its name, what it does, when it runs
|
|
8
|
+
* -/automations/<id> its editor, its switch, Run now and its runs
|
|
9
|
+
*
|
|
10
|
+
* Every read and write is /v1/auto/automations (api/auto.ts), scoped to the
|
|
11
|
+
* org the builder is in. A save sends only what changed; the switch sends
|
|
12
|
+
* `enabled` alone, so turning one on or off never changes who it runs as.
|
|
13
|
+
*/
|
|
14
|
+
import { SizableText, XStack, YStack } from '@hanzo/gui';
|
|
15
|
+
import { ChevronLeft, Plus, Workflow } from '@hanzogui/lucide-icons-2';
|
|
16
|
+
import { Button, Input, Switch, Textarea } from '@hanzo/ui';
|
|
17
|
+
import { StatusDot } from '@hanzo/ui/chat';
|
|
18
|
+
import { ModelPicker } from '@hanzo/ui/models';
|
|
19
|
+
import { ChipSelect } from '@hanzo/ui/product';
|
|
20
|
+
import { useLimits } from '@hanzo/ui/product/useLimits';
|
|
21
|
+
import { useEffect, useId, useMemo, useRef, useState } from 'react';
|
|
22
|
+
import { ago } from './ago.js';
|
|
23
|
+
import { automation, automations, changes, create, remove, runs, save, start, words } from './api/auto.js';
|
|
24
|
+
import { Refusal } from './api/call.js';
|
|
25
|
+
import { ENSO, limits as readLimits, models } from './api/models.js';
|
|
26
|
+
import { Choice, Confirm, Field, Line } from './customize/ui.js';
|
|
27
|
+
import { useRead } from './data.js';
|
|
28
|
+
import { useHost, useTarget } from './host.js';
|
|
29
|
+
import { path } from './route.js';
|
|
30
|
+
const zone = () => {
|
|
31
|
+
try {
|
|
32
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return 'UTC';
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
const KINDS = [
|
|
39
|
+
{ id: 'manual', label: 'When I run it' },
|
|
40
|
+
{ id: 'hourly', label: 'Every hour' },
|
|
41
|
+
{ id: 'daily', label: 'Every day' },
|
|
42
|
+
{ id: 'weekdays', label: 'Weekdays' },
|
|
43
|
+
{ id: 'weekly', label: 'Every week' },
|
|
44
|
+
{ id: 'cron', label: 'Cron' },
|
|
45
|
+
];
|
|
46
|
+
const DAYS = [
|
|
47
|
+
{ id: 'mon', label: 'Monday' },
|
|
48
|
+
{ id: 'tue', label: 'Tuesday' },
|
|
49
|
+
{ id: 'wed', label: 'Wednesday' },
|
|
50
|
+
{ id: 'thu', label: 'Thursday' },
|
|
51
|
+
{ id: 'fri', label: 'Friday' },
|
|
52
|
+
{ id: 'sat', label: 'Saturday' },
|
|
53
|
+
{ id: 'sun', label: 'Sunday' },
|
|
54
|
+
];
|
|
55
|
+
const PERMISSIONS = [
|
|
56
|
+
{ id: 'ask', label: 'Ask first' },
|
|
57
|
+
{ id: 'auto', label: 'Act on its own' },
|
|
58
|
+
];
|
|
59
|
+
/** A run's status as the rail's dot draws it. */
|
|
60
|
+
const DOT = { succeeded: 'done', failed: 'error', running: 'running', queued: 'running', skipped: 'stopped', refused: 'stopped' };
|
|
61
|
+
const WORD = { succeeded: 'Succeeded', failed: 'Failed', running: 'Running', queued: 'Queued', skipped: 'Skipped', refused: 'Refused' };
|
|
62
|
+
const live = (s) => s === 'running' || s === 'queued';
|
|
63
|
+
/** The schedule a kind starts from, keeping whatever of `was` still applies. */
|
|
64
|
+
function shaped(kind, was) {
|
|
65
|
+
// A schedule that runs only when asked keeps no zone worth keeping: leaving it takes the person's own.
|
|
66
|
+
const tz = was.kind === 'manual' || !was.tz ? zone() : was.tz;
|
|
67
|
+
switch (kind) {
|
|
68
|
+
case 'manual':
|
|
69
|
+
return { kind };
|
|
70
|
+
case 'hourly':
|
|
71
|
+
return { kind, at: `00:${(was.at ?? '00:00').slice(3) || '00'}`, tz };
|
|
72
|
+
case 'daily':
|
|
73
|
+
case 'weekdays':
|
|
74
|
+
return { kind, at: was.kind === 'hourly' || !was.at ? '09:00' : was.at, tz };
|
|
75
|
+
case 'weekly':
|
|
76
|
+
return { kind, at: was.kind === 'hourly' || !was.at ? '09:00' : was.at, day: was.day || 'mon', tz };
|
|
77
|
+
case 'cron':
|
|
78
|
+
return { kind, cron: was.cron || '0 9 * * 1-5', tz };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** What is wrong with a schedule before it is sent, or ''. */
|
|
82
|
+
function unready(s) {
|
|
83
|
+
if ((s.kind === 'daily' || s.kind === 'weekdays' || s.kind === 'weekly') && !/^([01]?\d|2[0-3]):[0-5]\d$/.test(s.at ?? ''))
|
|
84
|
+
return 'Time of day is HH:MM on a 24-hour clock.';
|
|
85
|
+
if (s.kind === 'cron' && (s.cron ?? '').trim().split(/\s+/).length !== 5)
|
|
86
|
+
return 'Cron is five fields: minute, hour, day of month, month, day of week.';
|
|
87
|
+
return '';
|
|
88
|
+
}
|
|
89
|
+
/** A schedule as the platform reads it: an hourly minute in two digits. */
|
|
90
|
+
function tidy(d) {
|
|
91
|
+
const s = d.schedule;
|
|
92
|
+
if (s.kind !== 'hourly')
|
|
93
|
+
return d;
|
|
94
|
+
const m = Math.min(59, Number((s.at ?? '').slice(3)) || 0);
|
|
95
|
+
return { ...d, schedule: { ...s, at: `00:${String(m).padStart(2, '0')}` } };
|
|
96
|
+
}
|
|
97
|
+
/** A local time and date for an RFC 3339 instant. */
|
|
98
|
+
const local = (iso) => {
|
|
99
|
+
const d = new Date(iso);
|
|
100
|
+
return Number.isNaN(d.getTime()) ? '' : d.toLocaleString(undefined, { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
|
|
101
|
+
};
|
|
102
|
+
/** The editor's copy of an automation: what a save compares against. */
|
|
103
|
+
const drafted = (a) => ({
|
|
104
|
+
name: a.name,
|
|
105
|
+
instructions: a.instructions,
|
|
106
|
+
model: a.model,
|
|
107
|
+
schedule: a.schedule,
|
|
108
|
+
permissions: a.permissions,
|
|
109
|
+
notify: a.notify,
|
|
110
|
+
enabled: a.draft ? true : a.enabled,
|
|
111
|
+
});
|
|
112
|
+
/** `next`, keeping each field of `cur` the person changed from `was` since. */
|
|
113
|
+
function rebase(cur, was, next) {
|
|
114
|
+
const out = { ...next };
|
|
115
|
+
for (const k of Object.keys(next)) {
|
|
116
|
+
if (JSON.stringify(cur[k]) !== JSON.stringify(was[k]))
|
|
117
|
+
out[k] = cur[k];
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
/** A refusal in the platform's words; out of credit names where to add some. */
|
|
122
|
+
function said(e) {
|
|
123
|
+
if (e instanceof Refusal && e.status === 402)
|
|
124
|
+
return `${e.message || 'This organization is out of credit.'} Add credit in Settings → Billing.`;
|
|
125
|
+
return e.message;
|
|
126
|
+
}
|
|
127
|
+
export function Automations() {
|
|
128
|
+
const host = useHost();
|
|
129
|
+
const t = useTarget();
|
|
130
|
+
const signed = Boolean(host.person);
|
|
131
|
+
const [q, setQ] = useState('');
|
|
132
|
+
const [problem, setProblem] = useState('');
|
|
133
|
+
const list = useRead(signed ? () => automations(t) : null, [], [t, signed]);
|
|
134
|
+
const needle = q.trim().toLowerCase();
|
|
135
|
+
const shown = list.value.filter((a) => !needle || a.name.toLowerCase().includes(needle) || a.instructions.toLowerCase().includes(needle));
|
|
136
|
+
const open = (id) => host.go(path({ kind: 'automation', id }));
|
|
137
|
+
const flip = async (a, on) => {
|
|
138
|
+
setProblem('');
|
|
139
|
+
try {
|
|
140
|
+
await save(t, a.id, { enabled: on });
|
|
141
|
+
list.reload();
|
|
142
|
+
}
|
|
143
|
+
catch (e) {
|
|
144
|
+
setProblem(`${a.name}: ${said(e)}`);
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
return (_jsx(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: "Work that runs itself in this organization, on a schedule or when you run it. Each run is a Dev run." })] }), _jsxs(Button, { size: "sm", shrink: 0, disabled: !signed, onPress: () => open('new'), 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 }) }) }), _jsx(Line, { children: problem }), list.error ? (_jsx(Empty, { says: 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: () => open('new'), 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: 220, children: "When" }), _jsx(SizableText, { size: "$1", color: "$soft", width: 150, children: "Last run" }), _jsx(SizableText, { size: "$1", color: "$soft", width: 56, style: { textAlign: 'right' }, children: "On" })] }), shown.map((a) => (_jsx(Item, { a: a, onOpen: () => open(a.id), onSwitch: (on) => void flip(a, on) }, a.id)))] }))] }) }));
|
|
148
|
+
}
|
|
149
|
+
/** One automation in the list: its name opens it, and its switch arms it. */
|
|
150
|
+
function Item({ a, onOpen, onSwitch }) {
|
|
151
|
+
const about = useId();
|
|
152
|
+
return (_jsxs(XStack, { items: "center", gap: "$3", px: "$3", py: "$2.5", borderTopWidth: 1, borderColor: "$borderColor", hoverStyle: { bg: '$hover' }, children: [_jsxs(XStack, { render: "button", "aria-label": `Open ${a.name}`, "aria-describedby": about, onPress: onOpen, flex: 1, minW: 0, items: "center", gap: "$3", children: [_jsx(Workflow, { size: 14 }), _jsx(SizableText, { size: "$2", color: "$ink", numberOfLines: 1, flex: 1, minW: 0, style: { textAlign: 'left' }, children: a.name }), _jsxs(XStack, { id: about, items: "center", gap: "$3", children: [a.draft ? (_jsx(SizableText, { size: "$1", color: "$soft", px: "$2", rounded: "$2", borderWidth: 1, borderColor: "$borderColor", children: "Draft" })) : null, _jsx(SizableText, { size: "$1", color: "$soft", width: 220, numberOfLines: 1, style: { textAlign: 'left' }, children: a.draft ? 'Needs instructions' : words(a.schedule) }), _jsxs(XStack, { width: 150, items: "center", gap: "$2", children: [a.last ? _jsx(StatusDot, { status: DOT[a.last.status] ?? 'idle' }) : null, _jsx(SizableText, { size: "$1", color: "$soft", numberOfLines: 1, children: a.last ? `${WORD[a.last.status] ?? a.last.status} · ${ago(a.last.at)}` : 'Never run' })] })] })] }), _jsx(XStack, { width: 56, justify: "flex-end", children: _jsx(Switch, { checked: a.enabled, disabled: a.draft, onCheckedChange: onSwitch, "aria-label": `${a.name} on` }) })] }));
|
|
153
|
+
}
|
|
154
|
+
function Empty({ says, children }) {
|
|
155
|
+
return (_jsxs(YStack, { borderWidth: 1, borderColor: "$borderColor", rounded: "$4", px: "$4", py: "$8", gap: "$3", items: "center", children: [_jsx(SizableText, { size: "$2", color: "$soft", style: { textAlign: 'center' }, children: says }), children] }));
|
|
156
|
+
}
|
|
157
|
+
const BLANK = () => ({ name: '', instructions: '', model: null, schedule: { kind: 'manual' }, permissions: 'ask', notify: false, enabled: true });
|
|
158
|
+
/** One automation: what it does and when, its switch, Run now, and its runs. `new` writes one. */
|
|
159
|
+
export function Editor({ id }) {
|
|
160
|
+
const host = useHost();
|
|
161
|
+
const t = useTarget();
|
|
162
|
+
const signed = Boolean(host.person);
|
|
163
|
+
const fresh = id === 'new';
|
|
164
|
+
// `tick` reads the automation again; `beat` reads its runs again.
|
|
165
|
+
const [tick, setTick] = useState(0);
|
|
166
|
+
const [beat, setBeat] = useState(0);
|
|
167
|
+
const saved = useRead(signed && !fresh ? () => automation(t, id) : null, null, [t, signed, id, tick]);
|
|
168
|
+
const history = useRead(signed && !fresh ? () => runs(t, id) : null, [], [t, signed, id, beat]);
|
|
169
|
+
const catalog = useRead(signed ? () => models(t) : null, [], [t, signed]);
|
|
170
|
+
const { limits } = useLimits(signed ? (signal) => readLimits(t, signal) : null, t);
|
|
171
|
+
// `base` is what the form started from; a save sends what changed since, and only that.
|
|
172
|
+
const [base, setBase] = useState(BLANK);
|
|
173
|
+
const [d, setD] = useState(BLANK);
|
|
174
|
+
const [ready, setReady] = useState(fresh);
|
|
175
|
+
const [busy, setBusy] = useState('');
|
|
176
|
+
const [note, setNote] = useState('');
|
|
177
|
+
const [asking, setAsking] = useState(false);
|
|
178
|
+
const here = useRef(true);
|
|
179
|
+
useEffect(() => {
|
|
180
|
+
here.current = true;
|
|
181
|
+
return () => {
|
|
182
|
+
here.current = false;
|
|
183
|
+
};
|
|
184
|
+
}, []);
|
|
185
|
+
const a = saved.value;
|
|
186
|
+
// The switch of a saved automation is saved as it moves; of a new one or a draft, with the rest.
|
|
187
|
+
const switchesAlone = Boolean(a && !a.draft);
|
|
188
|
+
const change = changes(base, d);
|
|
189
|
+
if (switchesAlone)
|
|
190
|
+
delete change.enabled;
|
|
191
|
+
const dirty = fresh ? Boolean(d.name.trim() || d.instructions.trim()) : Object.keys(change).length > 0;
|
|
192
|
+
// What is saved now becomes the form's start, unless the person is mid-edit: then
|
|
193
|
+
// their edit stands, and what they did not touch is not sent back over it.
|
|
194
|
+
const clean = useRef(true);
|
|
195
|
+
clean.current = !dirty;
|
|
196
|
+
useEffect(() => {
|
|
197
|
+
if (!a)
|
|
198
|
+
return;
|
|
199
|
+
if (clean.current) {
|
|
200
|
+
setBase(drafted(a));
|
|
201
|
+
setD(drafted(a));
|
|
202
|
+
}
|
|
203
|
+
setReady(true);
|
|
204
|
+
}, [a]);
|
|
205
|
+
// A run going is read every 4s; a scheduled automation's runs every 30s, so a tick's run appears.
|
|
206
|
+
const going = history.value.some((r) => live(r.status));
|
|
207
|
+
const every = going ? 4000 : a && a.enabled && a.schedule.kind !== 'manual' ? 30_000 : 0;
|
|
208
|
+
useEffect(() => {
|
|
209
|
+
if (!every)
|
|
210
|
+
return;
|
|
211
|
+
const timer = setInterval(() => setBeat((n) => n + 1), every);
|
|
212
|
+
return () => clearInterval(timer);
|
|
213
|
+
}, [every]);
|
|
214
|
+
const set = (p) => setD((x) => ({ ...x, ...p }));
|
|
215
|
+
const setWhen = (p) => setD((x) => ({ ...x, schedule: { ...x.schedule, ...p } }));
|
|
216
|
+
const wrong = unready(d.schedule);
|
|
217
|
+
const zones = useMemo(() => {
|
|
218
|
+
try {
|
|
219
|
+
return ['UTC', ...Intl.supportedValuesOf('timeZone').filter((z) => z !== 'UTC')].map((z) => ({ id: z, label: z }));
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
return [{ id: 'UTC', label: 'UTC' }];
|
|
223
|
+
}
|
|
224
|
+
}, []);
|
|
225
|
+
const act = async (what, work) => {
|
|
226
|
+
setBusy(what);
|
|
227
|
+
setNote('');
|
|
228
|
+
try {
|
|
229
|
+
await work();
|
|
230
|
+
}
|
|
231
|
+
catch (e) {
|
|
232
|
+
setNote(said(e));
|
|
233
|
+
}
|
|
234
|
+
finally {
|
|
235
|
+
setBusy('');
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
const keep = () => act('save', async () => {
|
|
239
|
+
if (fresh) {
|
|
240
|
+
const made = await create(t, tidy(d));
|
|
241
|
+
// Only while still here: a person who left during the create stays where they went.
|
|
242
|
+
if (here.current)
|
|
243
|
+
host.go(path({ kind: 'automation', id: made.id }), { replace: true });
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const sent = tidy(d);
|
|
247
|
+
const send = changes(base, sent);
|
|
248
|
+
if (switchesAlone)
|
|
249
|
+
delete send.enabled;
|
|
250
|
+
const now = Object.keys(send).length ? await save(t, id, send) : a;
|
|
251
|
+
// What was typed while the save was in flight stands.
|
|
252
|
+
setBase(drafted(now));
|
|
253
|
+
setD((cur) => rebase(cur, sent, drafted(now)));
|
|
254
|
+
setTick((n) => n + 1);
|
|
255
|
+
setNote('Saved.');
|
|
256
|
+
});
|
|
257
|
+
const flip = (on) => {
|
|
258
|
+
if (!switchesAlone)
|
|
259
|
+
return set({ enabled: on });
|
|
260
|
+
void act('switch', async () => {
|
|
261
|
+
await save(t, id, { enabled: on });
|
|
262
|
+
setTick((n) => n + 1);
|
|
263
|
+
});
|
|
264
|
+
};
|
|
265
|
+
const go = () => act('run', async () => {
|
|
266
|
+
const run = await start(t, id);
|
|
267
|
+
setBeat((n) => n + 1);
|
|
268
|
+
if (run.status === 'skipped')
|
|
269
|
+
setNote('Not started: the previous run is still going.');
|
|
270
|
+
});
|
|
271
|
+
const back = (how) => host.go(path({ kind: 'screen', screen: 'automations' }), how);
|
|
272
|
+
if (!signed)
|
|
273
|
+
return _jsx(Page, { title: "Automation", onBack: () => back(), says: "Sign in to see this organization's automations." });
|
|
274
|
+
if (!fresh && saved.error && !a)
|
|
275
|
+
return _jsx(Page, { title: "Automation", onBack: () => back(), says: saved.error.message });
|
|
276
|
+
if (!ready)
|
|
277
|
+
return _jsx(Page, { title: "Automation", onBack: () => back(), says: "Reading the automation\u2026" });
|
|
278
|
+
const s = d.schedule;
|
|
279
|
+
const on = switchesAlone ? Boolean(a?.enabled) : d.enabled;
|
|
280
|
+
return (_jsxs(YStack, { flex: 1, minH: 0, overflow: "scroll", px: "$6", py: "$6", children: [_jsxs(YStack, { width: "100%", maxW: 760, mx: "auto", gap: "$5", children: [_jsxs(XStack, { items: "center", gap: "$3", children: [_jsxs(Button, { size: "sm", variant: "ghost", onPress: () => back(), "aria-label": "All automations", children: [_jsx(ChevronLeft, { size: 14 }), "Automations"] }), _jsx(XStack, { flex: 1 }), _jsxs(XStack, { items: "center", gap: "$2", children: [_jsx(SizableText, { size: "$2", color: "$soft", children: switchesAlone ? (on ? 'On' : 'Off') : on ? 'On when saved' : 'Off when saved' }), _jsx(Switch, { checked: on, disabled: busy === 'switch', onCheckedChange: flip, "aria-label": switchesAlone ? `${a.name} on` : 'On when saved' })] }), a && !a.draft ? (_jsx(Button, { size: "sm", variant: "outline", disabled: Boolean(busy) || dirty, onPress: () => void go(), children: busy === 'run' ? 'Starting…' : 'Run now' })) : null] }), _jsxs(YStack, { gap: "$1", children: [_jsx(SizableText, { render: "h1", size: "$6", color: "$ink", numberOfLines: 1, children: fresh ? 'New automation' : a?.name }), _jsx(SizableText, { size: "$2", color: "$soft", children: fresh
|
|
281
|
+
? 'Name it, say what it should do, and when. It runs as you, in this organization.'
|
|
282
|
+
: a?.draft
|
|
283
|
+
? 'A draft: say what it should do and save it, and it runs.'
|
|
284
|
+
: [words(a.schedule), a.next && a.enabled ? `next ${local(a.next)}` : a.schedule.kind === 'manual' ? '' : 'off'].filter(Boolean).join(' · ') })] }), _jsx(Field, { label: "Name", children: _jsx(Input, { value: d.name, onChangeText: (v) => set({ name: v }), placeholder: "Morning briefing", "aria-label": "Automation name", autoFocus: fresh }) }), _jsx(Field, { label: "Instructions", hint: "What the agent does each run. It works in a Dev sandbox with this organization's connectors, MCP servers and skills.", children: _jsx(Textarea, { value: d.instructions, onChangeText: (v) => set({ instructions: v }), placeholder: "Summarize what needs my attention today across calendar, email and messages.", "aria-label": "Instructions", rows: 7 }) }), _jsx(Field, { label: "When it runs", hint: s.kind === 'cron' ? 'Five fields: minute, hour, day of month, month, day of week.' : undefined, children: _jsxs(XStack, { gap: "$2", flexWrap: "wrap", rowGap: "$2", items: "center", children: [_jsx(ChipSelect, { name: "When it runs", label: KINDS.find((k) => k.id === s.kind).label, chosen: KINDS.find((k) => k.id === s.kind), items: KINDS, onChange: (k) => set({ schedule: shaped(k.id, s) }), placement: "bottom-start", width: 200 }), s.kind === 'weekly' ? _jsx(ChipSelect, { name: "Day", label: DAYS.find((x) => x.id === s.day)?.label ?? 'Monday', chosen: DAYS.find((x) => x.id === s.day), items: DAYS, onChange: (x) => setWhen({ day: x.id }), placement: "bottom-start", width: 180 }) : null, s.kind === 'daily' || s.kind === 'weekdays' || s.kind === 'weekly' ? (_jsx(YStack, { width: 110, children: _jsx(Input, { value: s.at ?? '', onChangeText: (v) => setWhen({ at: v.trim() }), placeholder: "09:00", "aria-label": "Time of day", inputMode: "text" }) })) : null, s.kind === 'hourly' ? (_jsxs(XStack, { items: "center", gap: "$2", children: [_jsx(SizableText, { size: "$2", color: "$soft", children: "at minute" }), _jsx(YStack, { width: 70, children: _jsx(Input, { value: (s.at ?? '00:00').slice(3), onChangeText: (v) => setWhen({ at: `00:${v.replace(/\D/g, '').slice(0, 2)}` }), placeholder: "00", "aria-label": "Minute", inputMode: "numeric" }) })] })) : null, s.kind === 'cron' ? (_jsx(YStack, { width: 200, children: _jsx(Input, { value: s.cron ?? '', onChangeText: (v) => setWhen({ cron: v }), placeholder: "0 9 * * 1-5", "aria-label": "Cron", autoCapitalize: "none" }) })) : null, s.kind !== 'manual' ? _jsx(ChipSelect, { name: "Time zone", label: s.tz || 'UTC', chosen: { id: s.tz || 'UTC', label: s.tz || 'UTC' }, items: zones, onChange: (z) => setWhen({ tz: z.id }), placeholder: "Search zones\u2026", placement: "bottom-start", width: 260 }) : null] }) }), _jsx(Field, { label: "Model", children: _jsx(XStack, { children: _jsx(ModelPicker, { size: "sm", name: "Model", models: catalog.value, scope: "chat", limits: limits, value: d.model ?? ENSO, onChange: (id) => set({ model: id === ENSO ? null : id }), loading: catalog.loading, error: catalog.error?.message ?? null }) }) }), _jsx(Field, { label: "Permissions", hint: d.permissions === 'auto' ? 'Works and uses connectors without stopping.' : 'Reads and researches, changes nothing, and ends with the actions it would take for you to approve.', children: _jsx(Choice, { label: "Permissions", value: d.permissions, options: PERMISSIONS, onChange: (p) => set({ permissions: p }) }) }), _jsxs(XStack, { items: "center", gap: "$3", children: [_jsxs(YStack, { flex: 1, minW: 0, children: [_jsx(SizableText, { size: "$2", color: "$ink", children: "Email me when a run ends" }), _jsx(SizableText, { size: "$1", color: "$soft", children: "One line on how it went, with a link to the run." })] }), _jsx(Switch, { checked: d.notify, onCheckedChange: (v) => set({ notify: v }), "aria-label": "Email me when a run ends" })] }), _jsx(Line, { children: note || (dirty ? wrong : '') }), _jsxs(XStack, { gap: "$2", items: "center", children: [a ? (_jsx(Button, { size: "sm", variant: "ghost", disabled: Boolean(busy), onPress: () => setAsking(true), children: "Delete" })) : null, _jsx(XStack, { flex: 1 }), a && dirty ? (_jsx(Button, { size: "sm", variant: "ghost", disabled: Boolean(busy), onPress: () => {
|
|
285
|
+
setBase(drafted(a));
|
|
286
|
+
setD(drafted(a));
|
|
287
|
+
}, children: "Discard changes" })) : null, _jsx(Button, { size: "sm", disabled: Boolean(busy) || !dirty || !d.name.trim() || !d.instructions.trim() || Boolean(wrong), onPress: () => void keep(), children: busy === 'save' ? 'Saving…' : fresh ? 'Create' : 'Save' })] }), a ? (_jsxs(YStack, { gap: "$2", children: [_jsx(SizableText, { size: "$3", color: "$ink", children: "Runs" }), history.error && !history.value.length ? (_jsx(Line, { children: history.error.message })) : !history.value.length ? (_jsx(SizableText, { size: "$2", color: "$soft", children: history.loading ? 'Reading its runs…' : a.draft ? 'It runs once its instructions are saved.' : 'No runs yet. Run now starts one.' })) : (_jsx(YStack, { role: "list", borderWidth: 1, borderColor: "$borderColor", rounded: "$3", overflow: "hidden", "aria-label": "Run history", children: history.value.map((r, i) => (_jsxs(XStack, { role: "listitem", items: "flex-start", gap: "$3", px: "$3", py: "$2.5", borderTopWidth: i ? 1 : 0, borderColor: "$borderColor", children: [_jsx(YStack, { pt: "$1", children: _jsx(StatusDot, { status: DOT[r.status] ?? 'idle' }) }), _jsxs(YStack, { flex: 1, minW: 0, gap: "$0.5", children: [_jsx(SizableText, { size: "$2", color: "$ink", children: `${WORD[r.status] ?? r.status} · ${local(r.at)}` }), _jsx(SizableText, { size: "$1", color: "$soft", style: { whiteSpace: 'pre-wrap' }, children: r.summary || (live(r.status) ? 'Working…' : '') })] }), r.session ? (_jsx(Button, { size: "sm", variant: "ghost", onPress: () => host.go(r.session), "aria-label": `Open the Dev run of ${local(r.at)}`, children: "Open run" })) : null] }, r.id))) }))] })) : null] }), a ? (_jsx(Confirm, { what: a.name, says: "It stops running, and its schedule and its runs go with it. A run going now is stopped.", open: asking, onOpenChange: setAsking, onYes: async () => {
|
|
288
|
+
await remove(t, a.id);
|
|
289
|
+
back({ replace: true });
|
|
290
|
+
} })) : null] }));
|
|
291
|
+
}
|
|
292
|
+
function Page({ title, says, onBack }) {
|
|
293
|
+
return (_jsx(YStack, { flex: 1, minH: 0, overflow: "scroll", px: "$6", py: "$6", children: _jsxs(YStack, { width: "100%", maxW: 760, mx: "auto", gap: "$4", children: [_jsx(XStack, { children: _jsxs(Button, { size: "sm", variant: "ghost", onPress: onBack, "aria-label": "All automations", children: [_jsx(ChevronLeft, { size: 14 }), "Automations"] }) }), _jsx(SizableText, { render: "h1", size: "$6", color: "$ink", children: title }), _jsx(SizableText, { size: "$2", color: "$soft", children: says })] }) }));
|
|
294
|
+
}
|
package/lib/builder.js
CHANGED
|
@@ -4,7 +4,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
4
4
|
*
|
|
5
5
|
* / New — the empty state, a composer over the codebase it works on
|
|
6
6
|
* /sess_… one run, live
|
|
7
|
-
* /-/automations
|
|
7
|
+
* /-/automations work that runs itself; /-/automations/<id> one, to edit and run
|
|
8
8
|
/-/codebases the forge's repositories
|
|
9
9
|
* /-/sync bring granted repositories onto the forge
|
|
10
10
|
* /-/projects the forge's boards
|
|
@@ -36,6 +36,7 @@ import { useMemo, useState } from 'react';
|
|
|
36
36
|
import { safe } from './api/projects.js';
|
|
37
37
|
import { pinBoard } from './choice.js';
|
|
38
38
|
import { Customize } from './customize/index.js';
|
|
39
|
+
import { Editor } from './automations.js';
|
|
39
40
|
import { useKept, useRecents } from './data.js';
|
|
40
41
|
import { Forge } from './forge.js';
|
|
41
42
|
import { HostProvider, useHost, useTarget } from './host.js';
|
|
@@ -100,7 +101,7 @@ export function nav(host, go) {
|
|
|
100
101
|
{ id: 'projects', label: 'Projects', icon: _jsx(Kanban, { size: 16 }), onPress: () => go(path({ kind: 'screen', screen: 'projects' })), active: screen === 'projects' },
|
|
101
102
|
{ id: 'artifacts', label: 'Artifacts', icon: _jsx(Blocks, { size: 16 }), onPress: () => go(path({ kind: 'screen', screen: 'artifacts' })), active: screen === 'artifacts' },
|
|
102
103
|
{ id: 'customize', label: 'Customize', icon: _jsx(SlidersHorizontal, { size: 16 }), onPress: () => go(path({ kind: 'customize', tab: 'skills' })), active: r.kind === 'customize' || screen === 'mcp' },
|
|
103
|
-
{ id: 'automations', label: 'Automations', icon: _jsx(Workflow, { size: 16 }), onPress: () => go(path({ kind: 'screen', screen: 'automations' })), active: screen === 'automations' },
|
|
104
|
+
{ id: 'automations', label: 'Automations', icon: _jsx(Workflow, { size: 16 }), onPress: () => go(path({ kind: 'screen', screen: 'automations' })), active: screen === 'automations' || r.kind === 'automation' },
|
|
104
105
|
];
|
|
105
106
|
const more = [
|
|
106
107
|
{ id: 'codebases', label: 'Codebase', icon: _jsx(FolderGit2, { size: 16 }), onPress: () => go(path({ kind: 'screen', screen: 'codebases' })), active: screen === 'codebases' },
|
|
@@ -141,6 +142,8 @@ function Pane({ onStarted }) {
|
|
|
141
142
|
return _jsx(Settings, { section: r.section });
|
|
142
143
|
if (r.kind === 'customize')
|
|
143
144
|
return _jsx(Customize, { tab: r.tab });
|
|
145
|
+
if (r.kind === 'automation')
|
|
146
|
+
return _jsx(Editor, { id: r.id }, r.id);
|
|
144
147
|
if (r.kind === 'screen') {
|
|
145
148
|
if (r.screen === 'artifacts')
|
|
146
149
|
return _jsx(Artifacts, {});
|
package/lib/customize/agents.js
CHANGED
|
@@ -13,17 +13,15 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
|
|
|
13
13
|
import { SizableText, XStack, YStack } from '@hanzo/gui';
|
|
14
14
|
import { Bot } from '@hanzogui/lucide-icons-2';
|
|
15
15
|
import { Button, Input, Switch, Textarea } from '@hanzo/ui';
|
|
16
|
-
import {
|
|
16
|
+
import { ModelPicker } from '@hanzo/ui/models';
|
|
17
17
|
import { useEffect, useMemo, useState } from 'react';
|
|
18
18
|
import { agents, ALL, create, draft, EMPTY, fromPreset, one, PERIODS, presets, remove, update } from '../api/agents.js';
|
|
19
|
-
import {
|
|
20
|
-
import { label as named, models } from '../api/models.js';
|
|
19
|
+
import { models } from '../api/models.js';
|
|
21
20
|
import { tools } from '../api/tools.js';
|
|
22
21
|
import { useRead } from '../data.js';
|
|
23
22
|
import { useHost, useTarget } from '../host.js';
|
|
24
23
|
import { Add, Choice, Confirm, Field, Grid, Line, Mark, matches, mono, Sheet, Soft, Tile, Visitor } from './ui.js';
|
|
25
24
|
/** The model a new agent runs on when none is chosen: the deployment's own default. */
|
|
26
|
-
const DEFAULT = 'default';
|
|
27
25
|
const money = (m) => `$${(m / 1_000_000).toFixed(m % 10_000 ? 4 : 2).replace(/\.?0+$/, '')}`;
|
|
28
26
|
export function Agents({ view, q, adding, onAdding, onView }) {
|
|
29
27
|
const host = useHost();
|
|
@@ -96,13 +94,6 @@ function Editor({ agent, start, onClose, onSaved, onDeleted, }) {
|
|
|
96
94
|
return [...kept, ...listed];
|
|
97
95
|
}, [on.value, d.tools]);
|
|
98
96
|
const shownTools = choices.filter((x) => matches(find, x.name, x.source, x.description));
|
|
99
|
-
const items = useMemo(() => {
|
|
100
|
-
const list = [{ id: DEFAULT, label: 'Default', hint: 'the deployment’s' }, ...catalog.value];
|
|
101
|
-
if (d.model && !list.some((m) => m.id === d.model))
|
|
102
|
-
list.splice(1, 0, { id: d.model, label: named(d.model) });
|
|
103
|
-
return agent ? list.filter((m) => m.id !== DEFAULT) : list;
|
|
104
|
-
}, [catalog.value, d.model, agent]);
|
|
105
|
-
const chosen = items.find((m) => m.id === (d.model || DEFAULT)) ?? null;
|
|
106
97
|
const save = async () => {
|
|
107
98
|
setWorking(true);
|
|
108
99
|
setNote('');
|
|
@@ -119,7 +110,7 @@ function Editor({ agent, start, onClose, onSaved, onDeleted, }) {
|
|
|
119
110
|
}
|
|
120
111
|
};
|
|
121
112
|
const pick = (name, yes) => set({ tools: yes ? [...d.tools, name] : d.tools.filter((n) => n !== name) });
|
|
122
|
-
return (_jsx(Sheet, { title: agent ? agent.name : 'New agent', open: true, onOpenChange: (o) => !o && onClose(), width: 680, children: agent && full.error && !full.value ? (_jsx(Soft, { children: full.error.message })) : !ready ? (_jsx(Soft, { children: "Reading the agent\u2026" })) : (_jsxs(_Fragment, { children: [_jsx(Field, { label: "Name", hint: agent ? 'An agent keeps its name.' : 'Letters, digits, . _ or -. Other agents call it as agent_<name>.', children: _jsx(Input, { value: d.name, onChangeText: (v) => set({ name: v }), placeholder: "helper", "aria-label": "Name", disabled: Boolean(agent), autoCapitalize: "none" }) }), _jsx(Field, { label: "Description", hint: "The line another agent reads to decide whether to call this one.", children: _jsx(Input, { value: d.description, onChangeText: (v) => set({ description: v }), placeholder: "Answers questions about our codebase", "aria-label": "Description" }) }), _jsx(Field, { label: "Model", children:
|
|
113
|
+
return (_jsx(Sheet, { title: agent ? agent.name : 'New agent', open: true, onOpenChange: (o) => !o && onClose(), width: 680, children: agent && full.error && !full.value ? (_jsx(Soft, { children: full.error.message })) : !ready ? (_jsx(Soft, { children: "Reading the agent\u2026" })) : (_jsxs(_Fragment, { children: [_jsx(Field, { label: "Name", hint: agent ? 'An agent keeps its name.' : 'Letters, digits, . _ or -. Other agents call it as agent_<name>.', children: _jsx(Input, { value: d.name, onChangeText: (v) => set({ name: v }), placeholder: "helper", "aria-label": "Name", disabled: Boolean(agent), autoCapitalize: "none" }) }), _jsx(Field, { label: "Description", hint: "The line another agent reads to decide whether to call this one.", children: _jsx(Input, { value: d.description, onChangeText: (v) => set({ description: v }), placeholder: "Answers questions about our codebase", "aria-label": "Description" }) }), _jsx(Field, { label: "Model", children: _jsxs(XStack, { items: "center", gap: "$2", flexWrap: "wrap", children: [_jsx(ModelPicker, { size: "sm", name: "Model", placeholder: "The deployment\u2019s default", models: catalog.value, scope: "chat", value: d.model || undefined, onChange: (id) => set({ model: id }), loading: catalog.loading, error: catalog.error?.message ?? null }), d.model && !agent ? (_jsx(Button, { size: "sm", variant: "ghost", onPress: () => set({ model: '' }), children: "Use the default" })) : null] }) }), _jsx(Field, { label: "Instructions", hint: "The system prompt: what the model reads before every run.", children: _jsx(Textarea, { value: d.instructions, onChangeText: (v) => set({ instructions: v }), placeholder: "Be terse, and cite the file you read.", "aria-label": "Instructions", rows: 8 }) }), _jsxs(YStack, { gap: "$2", children: [_jsxs(XStack, { items: "center", gap: "$3", children: [_jsxs(YStack, { flex: 1, minW: 0, children: [_jsx(SizableText, { size: "$2", color: "$ink", children: "Tools" }), _jsx(SizableText, { size: "$1", color: "$soft", children: every ? 'Every tool the fleet’s MCP server serves when it runs.' : d.tools.length ? `${d.tools.length} chosen. It can call only these.` : 'None chosen: it can call no tool.' })] }), _jsx(SizableText, { size: "$1", color: "$soft", children: "Every tool" }), _jsx(Switch, { checked: every, onCheckedChange: (v) => set({ tools: v ? [ALL] : [] }), "aria-label": "Every tool" })] }), every ? null : (_jsxs(_Fragment, { children: [_jsx(Input, { value: find, onChangeText: setFind, placeholder: "Find a tool\u2026", "aria-label": "Find a tool" }), _jsx(YStack, { borderWidth: 1, borderColor: "$borderColor", rounded: "$3", maxH: 240, overflow: "scroll", children: on.loading && !on.value.length ? (_jsx(Soft, { children: "Reading the tools that are on\u2026" })) : on.error && !on.value.length ? (_jsx(Soft, { children: on.error.message })) : !shownTools.length ? (_jsx(Soft, { children: choices.length ? 'No tool matches.' : 'No tool is on for your organization. Switch skills and connectors on first.' })) : (shownTools.map((x, i) => (_jsxs(XStack, { items: "center", gap: "$3", px: "$3", py: "$2", borderTopWidth: i ? 1 : 0, borderColor: "$borderColor", children: [_jsxs(YStack, { flex: 1, minW: 0, children: [_jsx(SizableText, { size: "$2", color: "$ink", style: mono, numberOfLines: 1, children: x.name }), _jsx(SizableText, { size: "$1", color: "$soft", numberOfLines: 1, children: [x.source, x.description].filter(Boolean).join(' · ') })] }), _jsx(Switch, { checked: d.tools.includes(x.name), onCheckedChange: (v) => pick(x.name, v), "aria-label": `Use ${x.name}` })] }, x.name)))) })] }))] }), _jsxs(YStack, { gap: "$2", children: [_jsx(SizableText, { size: "$2", color: "$ink", children: "Budget" }), _jsxs(XStack, { gap: "$3", flexWrap: "wrap", rowGap: "$2", items: "flex-end", children: [_jsxs(YStack, { gap: "$1", width: 140, children: [_jsxs(SizableText, { size: "$1", color: "$soft", children: ["Each ", d.period, ", USD"] }), _jsx(Input, { value: d.cap, onChangeText: (v) => set({ cap: v }), placeholder: "10", "aria-label": "Budget each period", inputMode: "decimal" })] }), _jsxs(YStack, { gap: "$1", width: 140, children: [_jsx(SizableText, { size: "$1", color: "$soft", children: "One run, USD" }), _jsx(Input, { value: d.task, onChangeText: (v) => set({ task: v }), placeholder: "1", "aria-label": "Budget for one run", inputMode: "decimal" })] }), _jsx(Choice, { label: "Period", value: d.period, options: PERIODS.map((p) => ({ id: p, label: p[0].toUpperCase() + p.slice(1) })), onChange: (p) => set({ period: p }) })] }), _jsxs(SizableText, { size: "$1", color: "$soft", children: [full.value && full.value.cap ? `Spent ${money(full.value.spent)} this ${full.value.period}. ` : '', "A run stops at either limit."] })] }), _jsx(Line, { children: note }), _jsxs(XStack, { gap: "$2", items: "center", children: [agent && onDeleted ? (_jsx(Button, { size: "sm", variant: "ghost", onPress: () => setAsking(true), children: "Delete" })) : null, _jsx(XStack, { flex: 1 }), _jsx(Button, { size: "sm", variant: "ghost", onPress: onClose, children: "Cancel" }), _jsx(Button, { size: "sm", disabled: working, onPress: () => void save(), children: agent ? 'Save' : 'Create' })] }), agent && onDeleted ? (_jsx(Confirm, { what: agent.name, says: "The agent and every run recorded against it are removed.", open: asking, onOpenChange: setAsking, onYes: async () => {
|
|
123
114
|
await remove(t, agent.id || agent.name);
|
|
124
115
|
onDeleted();
|
|
125
116
|
} })) : null] })) }));
|
package/lib/forge.js
CHANGED
|
@@ -7,11 +7,12 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
7
7
|
* issue opens New with that codebase and the issue as the ask.
|
|
8
8
|
*/
|
|
9
9
|
import { SizableText, XStack, YStack } from '@hanzo/gui';
|
|
10
|
-
import { ChevronDown, GitBranch, Plus, Settings
|
|
10
|
+
import { ChevronDown, GitBranch, Plus, Settings } from '@hanzogui/lucide-icons-2';
|
|
11
11
|
import { Button, Dialog, DialogContent, DialogTitle, Input } from '@hanzo/ui';
|
|
12
12
|
import { useEffect, useState } from 'react';
|
|
13
|
-
import {
|
|
13
|
+
import { ago } from './ago.js';
|
|
14
14
|
import { codebases, create } from './api/codebases.js';
|
|
15
|
+
import { Automations } from './automations.js';
|
|
15
16
|
import { environments } from './api/environment.js';
|
|
16
17
|
import { boards, issues } from './api/work.js';
|
|
17
18
|
import { boardKey, pinBoard, pinCodebase, readPending, writePending } from './choice.js';
|
|
@@ -51,56 +52,6 @@ export function Forge({ screen }) {
|
|
|
51
52
|
return _jsx(Projects, {});
|
|
52
53
|
return _jsx(Issues, {});
|
|
53
54
|
}
|
|
54
|
-
function Automations() {
|
|
55
|
-
const host = useHost();
|
|
56
|
-
const t = useTarget();
|
|
57
|
-
const signed = Boolean(host.person);
|
|
58
|
-
const [q, setQ] = useState('');
|
|
59
|
-
const [making, setMaking] = useState(false);
|
|
60
|
-
const [tick, setTick] = useState(0);
|
|
61
|
-
const [problem, setProblem] = useState('');
|
|
62
|
-
const list = useRead(signed ? () => flows(t) : null, [], [t, signed, tick]);
|
|
63
|
-
const needle = q.trim().toLowerCase();
|
|
64
|
-
const shown = list.value.filter((a) => !needle || a.name.toLowerCase().includes(needle));
|
|
65
|
-
const flip = async (a) => {
|
|
66
|
-
setProblem('');
|
|
67
|
-
try {
|
|
68
|
-
await arm(t, a.id, a.status !== 'ENABLED');
|
|
69
|
-
setTick((n) => n + 1);
|
|
70
|
-
}
|
|
71
|
-
catch (e) {
|
|
72
|
-
setProblem(e.message);
|
|
73
|
-
}
|
|
74
|
-
};
|
|
75
|
-
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) => {
|
|
76
|
-
await add(t, name);
|
|
77
|
-
setMaking(false);
|
|
78
|
-
setTick((n) => n + 1);
|
|
79
|
-
} })] }));
|
|
80
|
-
}
|
|
81
|
-
function Empty({ says, children }) {
|
|
82
|
-
return (_jsxs(YStack, { borderWidth: 1, borderColor: "$borderColor", rounded: "$4", px: "$4", py: "$8", gap: "$3", items: "center", children: [_jsx(SizableText, { size: "$2", color: "$soft", style: { textAlign: 'center' }, children: says }), children] }));
|
|
83
|
-
}
|
|
84
|
-
function NewAutomation({ open, onOpenChange, onCreate, }) {
|
|
85
|
-
const [name, setName] = useState('');
|
|
86
|
-
const [busy, setBusy] = useState(false);
|
|
87
|
-
const [error, setError] = useState('');
|
|
88
|
-
const submit = async () => {
|
|
89
|
-
setBusy(true);
|
|
90
|
-
setError('');
|
|
91
|
-
try {
|
|
92
|
-
await onCreate(name);
|
|
93
|
-
setName('');
|
|
94
|
-
}
|
|
95
|
-
catch (e) {
|
|
96
|
-
setError(e.message);
|
|
97
|
-
}
|
|
98
|
-
finally {
|
|
99
|
-
setBusy(false);
|
|
100
|
-
}
|
|
101
|
-
};
|
|
102
|
-
return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { children: [_jsx(DialogTitle, { children: "New automation" }), _jsxs(YStack, { gap: "$3", pt: "$3", children: [_jsx(Input, { value: name, onChangeText: setName, placeholder: "Name", "aria-label": "Automation name" }), error ? _jsx(Note, { children: error }) : null, _jsxs(XStack, { gap: "$2", justify: "flex-end", children: [_jsx(Button, { size: "sm", variant: "outline", disabled: busy, onPress: () => onOpenChange(false), children: "Cancel" }), _jsx(Button, { size: "sm", disabled: busy || !name.trim(), onPress: () => void submit(), children: busy ? 'Creating…' : 'Create' })] })] })] }) }));
|
|
103
|
-
}
|
|
104
55
|
const PAGE = 25;
|
|
105
56
|
function Codebases() {
|
|
106
57
|
const host = useHost();
|
|
@@ -177,7 +128,7 @@ function Codebases() {
|
|
|
177
128
|
pinCodebase(host.org, c);
|
|
178
129
|
host.go('');
|
|
179
130
|
};
|
|
180
|
-
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: () => host.go('-/settings/environments'), 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] }), _jsx(SizableText, { size: "$1", color: "$soft", width: 96, "$max-md": { display: 'none' }, children: "Environment" }), _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: 96, "$max-md": { display: 'none' }, children: envOf.get(c.name) === 'ready' ? 'Ready' : envOf.get(c.name) === 'proposed' ? 'To review' : '—' }), _jsx(SizableText, { size: "$1", color: "$soft", width: 140, style: { textAlign: 'right' }, children: syncing.has(c.name) ? 'Syncing…' :
|
|
131
|
+
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: () => host.go('-/settings/environments'), 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] }), _jsx(SizableText, { size: "$1", color: "$soft", width: 96, "$max-md": { display: 'none' }, children: "Environment" }), _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: 96, "$max-md": { display: 'none' }, children: envOf.get(c.name) === 'ready' ? 'Ready' : envOf.get(c.name) === 'proposed' ? 'To review' : '—' }), _jsx(SizableText, { size: "$1", color: "$soft", width: 140, style: { textAlign: 'right' }, children: syncing.has(c.name) ? 'Syncing…' : ago(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) => {
|
|
181
132
|
const row = await create(t, name, description);
|
|
182
133
|
pinCodebase(host.org, row);
|
|
183
134
|
setMaking(false);
|
|
@@ -206,23 +157,6 @@ function NewRepo({ open, onOpenChange, onCreate, }) {
|
|
|
206
157
|
};
|
|
207
158
|
return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { children: [_jsx(DialogTitle, { children: "New repository" }), _jsxs(YStack, { gap: "$3", pt: "$3", children: [_jsx(Input, { value: name, onChangeText: setName, placeholder: "Name", "aria-label": "Repository name" }), _jsx(Input, { value: description, onChangeText: setDescription, placeholder: "Description", "aria-label": "Description" }), error ? _jsx(Note, { children: error }) : null, _jsxs(XStack, { gap: "$2", justify: "flex-end", children: [_jsx(Button, { size: "sm", variant: "outline", disabled: busy, onPress: () => onOpenChange(false), children: "Cancel" }), _jsx(Button, { size: "sm", disabled: busy || !name.trim(), onPress: () => void submit(), children: busy ? 'Creating…' : 'Create' })] })] })] }) }));
|
|
208
159
|
}
|
|
209
|
-
function when(iso) {
|
|
210
|
-
const t = Date.parse(iso);
|
|
211
|
-
if (!Number.isFinite(t))
|
|
212
|
-
return '—';
|
|
213
|
-
const minutes = Math.round((Date.now() - t) / 60000);
|
|
214
|
-
if (minutes < 1)
|
|
215
|
-
return 'just now';
|
|
216
|
-
if (minutes < 60)
|
|
217
|
-
return `${minutes}m ago`;
|
|
218
|
-
const hours = Math.round(minutes / 60);
|
|
219
|
-
if (hours < 24)
|
|
220
|
-
return `${hours}h ago`;
|
|
221
|
-
const days = Math.round(hours / 24);
|
|
222
|
-
if (days < 30)
|
|
223
|
-
return `${days}d ago`;
|
|
224
|
-
return new Date(t).toLocaleDateString();
|
|
225
|
-
}
|
|
226
160
|
function Projects() {
|
|
227
161
|
const host = useHost();
|
|
228
162
|
const t = useTarget();
|
package/lib/landing.js
CHANGED
|
@@ -18,13 +18,15 @@ import { Button } from '@hanzo/ui';
|
|
|
18
18
|
import { ModeSelect } from '@hanzo/ui/agents';
|
|
19
19
|
import { Composer } from '@hanzo/ui/chat';
|
|
20
20
|
import { BranchSelect, ChipSelect, FooterLink, HanzoMark, RepoSelect } from '@hanzo/ui/product';
|
|
21
|
+
import { ModelPicker } from '@hanzo/ui/models';
|
|
22
|
+
import { useLimits } from '@hanzo/ui/product/useLimits';
|
|
21
23
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
22
24
|
import { start, unhonoured } from './api/coding.js';
|
|
23
25
|
import { asRepo, chosen, codebases, one } from './api/codebases.js';
|
|
24
26
|
import { read, SETUP } from './api/environment.js';
|
|
25
27
|
import { SetupDialog } from './environment.js';
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
28
|
+
import { ENSO, limits as readLimits, models } from './api/models.js';
|
|
29
|
+
import { usePick } from './pick.js';
|
|
28
30
|
import { ready, SANDBOX } from './api/places.js';
|
|
29
31
|
import { isForge } from './choice.js';
|
|
30
32
|
import { useKept, usePlaces, useRead } from './data.js';
|
|
@@ -102,6 +104,8 @@ export function Landing({ onStarted }) {
|
|
|
102
104
|
}, []);
|
|
103
105
|
const places = usePlaces(t, signed);
|
|
104
106
|
const catalog = useRead(signed ? () => models(t) : null, [], [t, signed]);
|
|
107
|
+
const { limits } = useLimits(signed ? (signal) => readLimits(t, signal) : null, t);
|
|
108
|
+
const [model, pickModel] = usePick(kept.model, (id) => set({ model: id }), catalog.value);
|
|
105
109
|
// The chosen codebase's environment, so New can say when it has none yet.
|
|
106
110
|
const codebase = isForge(kept.repo) ? kept.repo.name : '';
|
|
107
111
|
const env = useRead(signed && codebase ? () => read(t, codebase) : null, null, [t, signed, codebase]);
|
|
@@ -146,7 +150,7 @@ export function Landing({ onStarted }) {
|
|
|
146
150
|
base: repo ? kept.branch : undefined,
|
|
147
151
|
targetId: place.id,
|
|
148
152
|
mode: kept.mode,
|
|
149
|
-
model:
|
|
153
|
+
model: model === ENSO ? undefined : model,
|
|
150
154
|
effort: kept.effort,
|
|
151
155
|
});
|
|
152
156
|
setDraft('');
|
|
@@ -209,7 +213,7 @@ export function Landing({ onStarted }) {
|
|
|
209
213
|
});
|
|
210
214
|
},
|
|
211
215
|
}, placeholder: "New project", disabled: !signed }, round), isForge(kept.repo) ? _jsx(BranchSelect, { value: branch, onChange: (b) => set({ branch: b }), load: loadBranches }) : null, _jsx(Files, { files: files, onFiles: setFiles })] }));
|
|
212
|
-
const foot = (_jsxs(XStack, { flex: 1, items: "center", gap: "$2", flexWrap: "wrap", rowGap: "$1", children: [_jsx(Attach, { files: files, onFiles: setFiles, onNote: setNote }), _jsx(Dictate, { onText: (said) => setDraft((d) => (d ? `${d} ${said}` : said)), onNote: setNote }), _jsx(ModeSelect, { modes: MODES, value: kept.mode, onChange: (m) => set({ mode: m }), bg: "transparent", minH: 24, px: "$1.5", self: "center" }), _jsx(XStack, { flex: 1 }), _jsx(
|
|
216
|
+
const foot = (_jsxs(XStack, { flex: 1, items: "center", gap: "$2", flexWrap: "wrap", rowGap: "$1", children: [_jsx(Attach, { files: files, onFiles: setFiles, onNote: setNote }), _jsx(Dictate, { onText: (said) => setDraft((d) => (d ? `${d} ${said}` : said)), onNote: setNote }), _jsx(ModeSelect, { modes: MODES, value: kept.mode, onChange: (m) => set({ mode: m }), bg: "transparent", minH: 24, px: "$1.5", self: "center" }), _jsx(XStack, { flex: 1 }), _jsx(ModelPicker, { quiet: true, size: "sm", name: "Model", models: catalog.value, scope: "chat", limits: limits, value: model, onChange: pickModel, loading: catalog.loading, error: catalog.error?.message ?? null }), _jsx(ChipSelect, { quiet: true, name: "Effort", label: EFFORTS.find((e) => e.id === kept.effort)?.label ?? 'Medium', chosen: EFFORTS.find((e) => e.id === kept.effort) ?? null, items: EFFORTS, onChange: (e) => set({ effort: e.id }), placement: "top-end", width: 180 })] }));
|
|
213
217
|
return (_jsxs(YStack, { flex: 1, minH: 0, minW: 0, items: "center", justify: "center", px: "$4", pb: "$10", overflow: "scroll", children: [_jsxs(YStack, { width: "100%", maxW: COLUMN, gap: "$5", children: [_jsxs(XStack, { role: "heading", "aria-level": 1, justify: "center", items: "center", gap: "$3", flexWrap: "wrap", children: [_jsx(HanzoMark, { size: 26 }), _jsx(SizableText, { size: "$8", color: "$ink", style: { textAlign: 'center' }, children: prefs.callName ? `What’s up next, ${prefs.callName}?` : 'What’s up next?' })] }), _jsxs(YStack, { gap: "$2", children: [env.value && env.value.state !== 'ready' && !place.id ? (_jsxs(XStack, { items: "center", justify: "space-between", gap: "$3", px: "$3", py: "$2", rounded: "$10", borderWidth: 1, borderColor: "$borderColor", children: [_jsx(SizableText, { size: "$2", color: "$ink", flex: 1, minW: 0, children: env.value.state === 'proposed'
|
|
214
218
|
? `A proposed environment for ${codebase} is waiting for review.`
|
|
215
219
|
: `${codebase} has no environment yet, so every run starts it bare.` }), env.value.state === 'proposed' && env.value.session ? (_jsx(Button, { size: "sm", variant: "outline", onPress: () => host.go(env.value.session), children: "Review" })) : (_jsx(Button, { size: "sm", onPress: () => setOffering(true), children: "Set up environment" }))] })) : null, note || unhonoured(kept.mode, place.id) ? (_jsx(SizableText, { size: "$1", color: "$soft", role: "status", children: note || unhonoured(kept.mode, place.id) })) : null, _jsx(Composer, { inline: true, ...pane, value: draft, onChange: setDraft, onSend: () => void send(), busy: busy, placeholder: "Describe a task or ask a question", label: "Describe a task or ask a question", head: head, foot: foot })] })] }), _jsx(Publish, { source: adding, onClose: () => setAdding(null) }), codebase ? (_jsx(SetupDialog, { repo: codebase, open: offering, onOpenChange: setOffering, onStart: setup, onSaved: () => {
|
package/lib/pick.d.ts
ADDED