@hanzo/build 0.2.56 → 0.2.58
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 +37 -11
- package/lib/activity.d.ts +20 -0
- package/lib/activity.js +38 -0
- package/lib/api/auto.d.ts +77 -2
- package/lib/api/auto.js +137 -4
- package/lib/api/library.d.ts +121 -0
- package/lib/api/library.js +157 -0
- package/lib/automations.d.ts +28 -1
- package/lib/automations.js +49 -61
- package/lib/builder.js +5 -4
- package/lib/forge.js +2 -2
- package/lib/index.d.ts +5 -0
- package/lib/index.js +5 -0
- package/lib/library.d.ts +21 -0
- package/lib/library.js +407 -0
- package/lib/nav.js +3 -3
- package/lib/panel.d.ts +3 -1
- package/lib/panel.js +11 -10
- package/lib/run.js +17 -1
- package/lib/scheduled.d.ts +3 -0
- package/lib/scheduled.js +281 -0
- package/lib/shelf.d.ts +6 -5
- package/lib/shelf.js +9 -8
- package/lib/summary.d.ts +50 -0
- package/lib/summary.js +60 -0
- package/lib/tabs.d.ts +12 -1
- package/lib/tabs.js +37 -4
- package/package.json +1 -1
package/lib/scheduled.js
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Scheduled tasks: everything that runs without you watching, in one place —
|
|
4
|
+
* the prompts you scheduled from Chat and Dev, background runs, Studio renders
|
|
5
|
+
* and workflows.
|
|
6
|
+
*
|
|
7
|
+
* -/automations your tasks, then Activity: every run, across products
|
|
8
|
+
* -/automations/<id> one task's editor and runs (automations.tsx)
|
|
9
|
+
*
|
|
10
|
+
* Tasks are /v1/auto/automations and runs are /v1/auto/jobs (api/auto.ts), both
|
|
11
|
+
* the reader's own; /v1/auto/events keeps them live while the page is open, and
|
|
12
|
+
* a read every half minute keeps them right when the stream is down. A new task
|
|
13
|
+
* is said in plain words — "every Monday at 9am" — and the platform says back
|
|
14
|
+
* what it understood before anything is created.
|
|
15
|
+
*/
|
|
16
|
+
import { SizableText, XStack, YStack } from '@hanzo/gui';
|
|
17
|
+
import { Clock, ExternalLink, MoreHorizontal, Pencil, Play, Plus, ScrollText, Trash2 } from '@hanzogui/lucide-icons-2';
|
|
18
|
+
import { Button, DropdownMenu, Input, Switch, Textarea } from '@hanzo/ui';
|
|
19
|
+
import { StatusDot } from '@hanzo/ui/chat';
|
|
20
|
+
import { ModelPicker } from '@hanzo/ui/models';
|
|
21
|
+
import { useLimits } from '@hanzo/ui/product/useLimits';
|
|
22
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
23
|
+
import { automations, create, cron, events, jobs, remove, 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 { Confirm, Field, Line, Sheet } from './customize/ui.js';
|
|
27
|
+
import { local, PLAN, shaped, tidy, unplanned, unready, When, zone } from './automations.js';
|
|
28
|
+
import { useRead } from './data.js';
|
|
29
|
+
import { useHost, useTarget } from './host.js';
|
|
30
|
+
import { about, dollars, DOTS, lasted, merged, SAID, titled } from './activity.js';
|
|
31
|
+
import { SOURCE } from './api/library.js';
|
|
32
|
+
import { Outputs } from './library.js';
|
|
33
|
+
import { path } from './route.js';
|
|
34
|
+
/** Where a move into Scheduled leaves the prompt a new task should start from. */
|
|
35
|
+
const ASK = 'hanzo.build.scheduled.ask';
|
|
36
|
+
/** Open Scheduled with a new task started from `prompt`: Dev's "Schedule this". */
|
|
37
|
+
export function schedule(prompt, go) {
|
|
38
|
+
try {
|
|
39
|
+
sessionStorage.setItem(ASK, prompt);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// Without storage the sheet opens empty.
|
|
43
|
+
}
|
|
44
|
+
go(path({ kind: 'screen', screen: 'automations' }));
|
|
45
|
+
}
|
|
46
|
+
function asked() {
|
|
47
|
+
try {
|
|
48
|
+
const s = sessionStorage.getItem(ASK) ?? '';
|
|
49
|
+
sessionStorage.removeItem(ASK);
|
|
50
|
+
return s;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return '';
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** /v1/auto/events, followed while signed in: each job and task as it changes, again after a drop. */
|
|
57
|
+
function useLive(on) {
|
|
58
|
+
const host = useHost();
|
|
59
|
+
const t = useTarget();
|
|
60
|
+
const signed = Boolean(host.person);
|
|
61
|
+
const heard = useRef(on);
|
|
62
|
+
heard.current = on;
|
|
63
|
+
const [live, setLive] = useState(false);
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (!signed)
|
|
66
|
+
return;
|
|
67
|
+
const ctl = new AbortController();
|
|
68
|
+
let wait = 1000;
|
|
69
|
+
void (async () => {
|
|
70
|
+
while (!ctl.signal.aborted) {
|
|
71
|
+
const began = Date.now();
|
|
72
|
+
try {
|
|
73
|
+
await events(t, (h) => heard.current(h.kind === 'job' ? { job: h.job } : { automation: h.automation }), ctl.signal, () => setLive(true));
|
|
74
|
+
// A stream that stayed open a while is the platform's; one that closed at once is waited on longer each time.
|
|
75
|
+
if (Date.now() - began > 5000)
|
|
76
|
+
wait = 1000;
|
|
77
|
+
}
|
|
78
|
+
catch (e) {
|
|
79
|
+
// A stream the platform does not serve here is not asked for again; the half-minute read stands in.
|
|
80
|
+
if (e instanceof Refusal && (e.status === 404 || e.status === 401 || e.status === 403))
|
|
81
|
+
return setLive(false);
|
|
82
|
+
}
|
|
83
|
+
setLive(false);
|
|
84
|
+
if (ctl.signal.aborted)
|
|
85
|
+
return;
|
|
86
|
+
await new Promise((r) => setTimeout(r, wait));
|
|
87
|
+
wait = Math.min(wait * 2, 30_000);
|
|
88
|
+
}
|
|
89
|
+
})();
|
|
90
|
+
return () => ctl.abort();
|
|
91
|
+
}, [t, signed]);
|
|
92
|
+
return live;
|
|
93
|
+
}
|
|
94
|
+
export function Scheduled() {
|
|
95
|
+
const host = useHost();
|
|
96
|
+
const t = useTarget();
|
|
97
|
+
const signed = Boolean(host.person);
|
|
98
|
+
const [tick, setTick] = useState(0);
|
|
99
|
+
const tasks = useRead(signed ? () => automations(t) : null, [], [t, signed, tick]);
|
|
100
|
+
const runs = useRead(signed ? () => jobs(t, { limit: 50 }) : null, [], [t, signed, tick]);
|
|
101
|
+
const [shownTasks, setTasks] = useState([]);
|
|
102
|
+
const [shownRuns, setRuns] = useState([]);
|
|
103
|
+
useEffect(() => setTasks(tasks.value), [tasks.value]);
|
|
104
|
+
useEffect(() => setRuns(runs.value), [runs.value]);
|
|
105
|
+
const [problem, setProblem] = useState('');
|
|
106
|
+
const [making, setMaking] = useState(null);
|
|
107
|
+
const [deleting, setDeleting] = useState(null);
|
|
108
|
+
const [looking, setLooking] = useState(null);
|
|
109
|
+
const [now, setNow] = useState(() => Date.now());
|
|
110
|
+
// Dev's "Schedule this" arrives with the prompt the sheet starts from.
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
const prompt = asked();
|
|
113
|
+
if (prompt)
|
|
114
|
+
setMaking(prompt);
|
|
115
|
+
}, []);
|
|
116
|
+
const live = useLive(({ job, automation }) => {
|
|
117
|
+
if (job) {
|
|
118
|
+
setRuns((was) => merged(was, job));
|
|
119
|
+
setLooking((was) => (was && was.id === job.id ? job : was));
|
|
120
|
+
}
|
|
121
|
+
if (automation)
|
|
122
|
+
setTasks((was) => (was.some((a) => a.id === automation.id) ? was.map((a) => (a.id === automation.id ? automation : a)) : [automation, ...was]));
|
|
123
|
+
});
|
|
124
|
+
// Without the stream, a read every half minute; with it, a clock for the running durations.
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
if (!signed)
|
|
127
|
+
return;
|
|
128
|
+
const id = setInterval(() => (live ? setNow(Date.now()) : setTick((n) => n + 1)), live ? 1000 : 30_000);
|
|
129
|
+
return () => clearInterval(id);
|
|
130
|
+
}, [signed, live]);
|
|
131
|
+
const act = async (what, work) => {
|
|
132
|
+
setProblem('');
|
|
133
|
+
try {
|
|
134
|
+
await work();
|
|
135
|
+
}
|
|
136
|
+
catch (e) {
|
|
137
|
+
setProblem(unplanned(e) ? PLAN : `${what}: ${e.message}`);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
const flip = (a, on) => act(a.name, async () => {
|
|
141
|
+
const next = await save(t, a.id, { enabled: on });
|
|
142
|
+
setTasks((was) => was.map((x) => (x.id === next.id ? next : x)));
|
|
143
|
+
});
|
|
144
|
+
const run = (a) => act(a.name, async () => {
|
|
145
|
+
const r = await start(t, a.id);
|
|
146
|
+
if (r.status === 'skipped')
|
|
147
|
+
setProblem(`${a.name}: not started, the previous run is still going.`);
|
|
148
|
+
setTick((n) => n + 1);
|
|
149
|
+
});
|
|
150
|
+
const edit = (id) => host.go(path({ kind: 'automation', id }));
|
|
151
|
+
return (_jsxs(YStack, { flex: 1, minH: 0, overflow: "scroll", px: "$6", py: "$6", "$max-md": { px: '$4', py: '$4' }, children: [_jsxs(YStack, { width: "100%", maxW: 880, mx: "auto", gap: "$5", children: [_jsxs(XStack, { justify: "space-between", items: "flex-start", gap: "$4", children: [_jsxs(YStack, { gap: "$1", flex: 1, minW: 0, children: [_jsx(SizableText, { render: "h1", size: "$7", fontWeight: "600", color: "$ink", children: "Scheduled tasks" }), _jsx(SizableText, { size: "$2", color: "$soft", children: "What runs without you watching: prompts on a schedule, background runs, Studio renders and workflows." })] }), _jsxs(Button, { size: "sm", shrink: 0, disabled: !signed, onPress: () => setMaking(''), children: [_jsx(Plus, { size: 14 }), "New task"] })] }), _jsx(Line, { children: problem }), _jsxs(YStack, { gap: "$2", children: [_jsx(SizableText, { render: "h2", size: "$3", color: "$ink", fontWeight: "500", children: "Tasks" }), !signed ? (_jsx(Said, { says: "Sign in to see your scheduled tasks.", children: _jsx(Button, { size: "sm", onPress: () => host.signIn?.(), children: "Sign in" }) })) : tasks.error && !shownTasks.length ? (_jsx(Said, { says: tasks.error.message })) : tasks.loading && !shownTasks.length ? (_jsx(Said, { says: "Reading your tasks\u2026" })) : !shownTasks.length ? (_jsx(Said, { says: "No scheduled tasks yet. Ask Chat or Dev to do something on a schedule, or start one here.", children: _jsx(Button, { size: "sm", onPress: () => setMaking(''), children: "New task" }) })) : (_jsx(YStack, { role: "list", "aria-label": "Your tasks", borderWidth: 1, borderColor: "$borderColor", rounded: "$4", overflow: "hidden", children: shownTasks.map((a, i) => (_jsx(Task, { a: a, first: i === 0, onOpen: () => edit(a.id), onSwitch: (on) => void flip(a, on), onRun: () => void run(a), onDelete: () => setDeleting(a) }, a.id))) }))] }), signed ? (_jsxs(YStack, { gap: "$2", children: [_jsxs(XStack, { items: "center", gap: "$2", children: [_jsx(SizableText, { render: "h2", size: "$3", color: "$ink", fontWeight: "500", flex: 1, children: "Activity" }), _jsx(SizableText, { size: "$1", color: "$soft", children: live ? 'Live' : '' })] }), runs.error && !shownRuns.length ? (_jsx(Said, { says: runs.error.message })) : runs.loading && !shownRuns.length ? (_jsx(Said, { says: "Reading what ran\u2026" })) : !shownRuns.length ? (_jsx(Said, { says: "Nothing has run yet. A task's runs, Dev's background runs and Studio's renders show here as they go." })) : (_jsx(YStack, { role: "list", "aria-label": "Activity", borderWidth: 1, borderColor: "$borderColor", rounded: "$4", overflow: "hidden", children: shownRuns.map((j, i) => (_jsx(Ran, { j: j, first: i === 0, now: now, onOpen: () => setLooking(j) }, j.id))) }))] })) : null] }), _jsx(Make, { prompt: making, onClose: () => setMaking(null), onMade: (a) => {
|
|
152
|
+
setTasks((was) => [a, ...was.filter((x) => x.id !== a.id)]);
|
|
153
|
+
setProblem(`Scheduled: ${a.name}.`);
|
|
154
|
+
} }), _jsx(Detail, { j: looking, onClose: () => setLooking(null), now: now }), deleting ? (_jsx(Confirm, { what: deleting.name, says: "It stops running, and its schedule and its runs go with it. A run going now is stopped.", open: true, onOpenChange: (o) => !o && setDeleting(null), onYes: async () => {
|
|
155
|
+
await remove(t, deleting.id);
|
|
156
|
+
setTasks((was) => was.filter((x) => x.id !== deleting.id));
|
|
157
|
+
setDeleting(null);
|
|
158
|
+
} })) : null] }));
|
|
159
|
+
}
|
|
160
|
+
/** One task: what it is and when it runs next, its switch, and its menu. */
|
|
161
|
+
function Task({ a, first, onOpen, onSwitch, onRun, onDelete }) {
|
|
162
|
+
const when = a.draft
|
|
163
|
+
? 'Needs instructions'
|
|
164
|
+
: [words(a.schedule), a.enabled && a.next ? `next ${local(a.next)}` : a.schedule.kind !== 'manual' && !a.enabled ? 'paused' : ''].filter(Boolean).join(' · ');
|
|
165
|
+
return (_jsxs(XStack, { role: "listitem", items: "center", gap: "$3", px: "$3", py: "$3", borderTopWidth: first ? 0 : 1, borderColor: "$borderColor", hoverStyle: { bg: '$hover' }, children: [_jsxs(XStack, { render: "button", "aria-label": `Open ${a.name}`, onPress: onOpen, flex: 1, minW: 0, items: "center", gap: "$3", cursor: "pointer", style: { textAlign: 'left' }, children: [_jsx(YStack, { width: 32, height: 32, rounded: "$3", bg: "$raised", items: "center", justify: "center", shrink: 0, "aria-hidden": true, children: _jsx(Clock, { size: 16 }) }), _jsxs(YStack, { flex: 1, minW: 0, gap: "$0.5", children: [_jsxs(XStack, { items: "center", gap: "$2", minW: 0, children: [_jsx(SizableText, { size: "$2", color: "$ink", numberOfLines: 1, shrink: 1, children: a.name }), _jsx(Badge, { children: SOURCE[a.source] }), a.draft ? _jsx(Badge, { children: "Draft" }) : null] }), _jsx(SizableText, { size: "$1", color: "$soft", numberOfLines: 1, children: when })] })] }), _jsx(Switch, { checked: a.enabled, disabled: a.draft, onCheckedChange: onSwitch, "aria-label": `${a.name} on` }), _jsx(DropdownMenu, { trigger: _jsx(XStack, { render: "button", "aria-label": `Actions for ${a.name}`, width: 30, height: 30, items: "center", justify: "center", rounded: "$2", hoverStyle: { bg: '$hover' }, cursor: "pointer", children: _jsx(MoreHorizontal, { size: 16 }) }), items: [
|
|
166
|
+
...(a.draft ? [] : [{ key: 'run', label: 'Run now', icon: _jsx(Play, { size: 16 }), onSelect: onRun }]),
|
|
167
|
+
{ key: 'edit', label: 'Edit', icon: _jsx(Pencil, { size: 16 }), onSelect: onOpen },
|
|
168
|
+
{ key: 'delete', label: 'Delete', icon: _jsx(Trash2, { size: 16 }), onSelect: onDelete },
|
|
169
|
+
] })] }));
|
|
170
|
+
}
|
|
171
|
+
/** One run in Activity. */
|
|
172
|
+
function Ran({ j, first, now, onOpen }) {
|
|
173
|
+
return (_jsx(XStack, { role: "listitem", borderTopWidth: first ? 0 : 1, borderColor: "$borderColor", hoverStyle: { bg: '$hover' }, children: _jsxs(XStack, { render: "button", "aria-label": `${j.title}: ${SAID[j.state]}`, onPress: onOpen, flex: 1, minW: 0, items: "center", gap: "$3", px: "$3", py: "$2.5", cursor: "pointer", style: { textAlign: 'left' }, children: [_jsx(StatusDot, { status: DOTS[j.state] }), _jsxs(YStack, { flex: 1, minW: 0, gap: "$0.5", children: [_jsx(SizableText, { size: "$2", color: "$ink", numberOfLines: 1, children: j.title }), _jsx(SizableText, { size: "$1", color: "$soft", numberOfLines: 1, children: about(j, now) })] }), _jsx(SizableText, { size: "$1", color: "$soft", shrink: 0, children: j.state === 'running' && j.progress !== null ? `${Math.round(j.progress * 100)}%` : SAID[j.state] })] }) }));
|
|
174
|
+
}
|
|
175
|
+
/** One run, opened: how it went, its log, what it made and what it cost. */
|
|
176
|
+
function Detail({ j, onClose, now }) {
|
|
177
|
+
const host = useHost();
|
|
178
|
+
return (_jsx(Sheet, { title: j?.title ?? 'Run', open: Boolean(j), onOpenChange: (o) => !o && onClose(), width: 520, children: j ? (_jsxs(YStack, { gap: "$3", children: [_jsxs(XStack, { items: "center", gap: "$2", children: [_jsx(StatusDot, { status: DOTS[j.state] }), _jsxs(SizableText, { size: "$2", color: "$ink", children: [SAID[j.state], j.status && j.status !== j.state ? ` (${j.status})` : ''] })] }), _jsx(Facts, { rows: [
|
|
179
|
+
['From', SOURCE[j.product]],
|
|
180
|
+
['Started', j.started ? local(j.started) : '—'],
|
|
181
|
+
['Took', lasted(j.started, j.finished, now) || '—'],
|
|
182
|
+
['Cost', j.cost !== null ? dollars(j.cost) : 'Not known yet'],
|
|
183
|
+
] }), j.error ? (_jsx(SizableText, { size: "$2", color: "$soft", style: { whiteSpace: 'pre-wrap' }, children: j.error })) : null, j.summary ? (_jsx(SizableText, { size: "$2", color: "$ink", style: { whiteSpace: 'pre-wrap' }, children: j.summary })) : null, j.outputs.length ? (_jsxs(YStack, { gap: "$1", children: [_jsx(SizableText, { size: "$2", color: "$soft", children: "What it made" }), _jsx(Outputs, { ids: j.outputs })] })) : null, _jsxs(XStack, { gap: "$2", flexWrap: "wrap", rowGap: "$2", children: [j.session ? (_jsxs(Button, { size: "sm", variant: "outline", onPress: () => host.go(j.session), children: [_jsx(ScrollText, { size: 14 }), "Open its log"] })) : null, j.origin ? (_jsxs(Button, { size: "sm", variant: "outline", onPress: () => host.open(j.origin), children: [_jsx(ExternalLink, { size: 14 }), "Open where it runs"] })) : null, j.automation ? (_jsxs(Button, { size: "sm", variant: "ghost", onPress: () => host.go(path({ kind: 'automation', id: j.automation })), children: [_jsx(Pencil, { size: 14 }), "Edit the task"] })) : null] })] })) : null }));
|
|
184
|
+
}
|
|
185
|
+
/** A new task: what it does, when in plain words, and the model it thinks with. */
|
|
186
|
+
function Make({ prompt, onClose, onMade }) {
|
|
187
|
+
const host = useHost();
|
|
188
|
+
const t = useTarget();
|
|
189
|
+
const signed = Boolean(host.person);
|
|
190
|
+
const open = prompt !== null;
|
|
191
|
+
const [name, setName] = useState('');
|
|
192
|
+
const [asks, setAsks] = useState('');
|
|
193
|
+
const [said, setSaid] = useState('');
|
|
194
|
+
const [read, setRead] = useState(null);
|
|
195
|
+
const [reading, setReading] = useState(false);
|
|
196
|
+
const [unread, setUnread] = useState('');
|
|
197
|
+
const [picking, setPicking] = useState(false);
|
|
198
|
+
const [picked, setPicked] = useState(() => shaped('daily', { kind: 'manual' }));
|
|
199
|
+
const [model, setModel] = useState(null);
|
|
200
|
+
const [busy, setBusy] = useState(false);
|
|
201
|
+
const [note, setNote] = useState('');
|
|
202
|
+
const catalog = useRead(signed && open ? () => models(t) : null, [], [t, signed, open]);
|
|
203
|
+
const { limits } = useLimits(signed && open ? (signal) => readLimits(t, signal) : null, t);
|
|
204
|
+
// Each opening starts from what it was handed.
|
|
205
|
+
useEffect(() => {
|
|
206
|
+
if (prompt === null)
|
|
207
|
+
return;
|
|
208
|
+
setAsks(prompt);
|
|
209
|
+
setName(titled(prompt));
|
|
210
|
+
setSaid('');
|
|
211
|
+
setRead(null);
|
|
212
|
+
setUnread('');
|
|
213
|
+
setPicking(false);
|
|
214
|
+
setNote('');
|
|
215
|
+
}, [prompt]);
|
|
216
|
+
// The words are read once the typing settles; the answer is said back before anything is made.
|
|
217
|
+
useEffect(() => {
|
|
218
|
+
const text = said.trim();
|
|
219
|
+
if (!open || !text || picking) {
|
|
220
|
+
setRead(null);
|
|
221
|
+
setUnread('');
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const ctl = new AbortController();
|
|
225
|
+
const id = setTimeout(() => {
|
|
226
|
+
setReading(true);
|
|
227
|
+
cron(t, text, zone(), { signal: ctl.signal })
|
|
228
|
+
.then((p) => {
|
|
229
|
+
setRead(p);
|
|
230
|
+
setUnread('');
|
|
231
|
+
})
|
|
232
|
+
.catch((e) => {
|
|
233
|
+
if (ctl.signal.aborted)
|
|
234
|
+
return;
|
|
235
|
+
setRead(null);
|
|
236
|
+
setUnread(e.message);
|
|
237
|
+
})
|
|
238
|
+
.finally(() => !ctl.signal.aborted && setReading(false));
|
|
239
|
+
}, 450);
|
|
240
|
+
return () => {
|
|
241
|
+
clearTimeout(id);
|
|
242
|
+
ctl.abort();
|
|
243
|
+
};
|
|
244
|
+
}, [said, open, picking, t]);
|
|
245
|
+
const when = picking ? picked : (read?.schedule ?? null);
|
|
246
|
+
const wrong = when ? unready(when) : '';
|
|
247
|
+
const make = async () => {
|
|
248
|
+
if (!name.trim())
|
|
249
|
+
return setNote('Name it first.');
|
|
250
|
+
if (!asks.trim())
|
|
251
|
+
return setNote('Say what it should do.');
|
|
252
|
+
if (!when)
|
|
253
|
+
return setNote(said.trim() ? 'Say when in other words, or choose instead.' : 'Say when it runs.');
|
|
254
|
+
if (wrong)
|
|
255
|
+
return setNote(wrong);
|
|
256
|
+
setBusy(true);
|
|
257
|
+
setNote('');
|
|
258
|
+
try {
|
|
259
|
+
const d = { name: name.trim(), instructions: asks.trim(), model, schedule: when, permissions: 'ask', notify: true, postTo: [], enabled: true, source: 'dev' };
|
|
260
|
+
onMade(await create(t, tidy(d)));
|
|
261
|
+
onClose();
|
|
262
|
+
}
|
|
263
|
+
catch (e) {
|
|
264
|
+
setNote(unplanned(e) ? PLAN : e.message);
|
|
265
|
+
}
|
|
266
|
+
finally {
|
|
267
|
+
setBusy(false);
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
const heard = useMemo(() => (read ? [read.says || words(read.schedule), read.next ? `next ${local(read.next)}` : ''].filter(Boolean).join(' · ') : ''), [read]);
|
|
271
|
+
return (_jsx(Sheet, { title: "New task", open: open, onOpenChange: (o) => !o && !busy && onClose(), width: 560, children: _jsxs(YStack, { gap: "$4", children: [_jsx(Field, { label: "Name", children: _jsx(Input, { value: name, onChangeText: setName, placeholder: "Monday summary", "aria-label": "Task name" }) }), _jsx(Field, { label: "What it does", hint: "Each run is one Dev run in this organization, as you. It reads and proposes; it changes nothing on its own.", children: _jsx(Textarea, { value: asks, onChangeText: setAsks, placeholder: "Summarize what changed in my repositories this week, and what needs me.", "aria-label": "What it does", rows: 5 }) }), _jsx(Field, { label: "When", hint: picking ? undefined : 'In your own words: every weekday at 8am, every 5 minutes, first of the month.', children: picking ? (_jsxs(YStack, { gap: "$2", children: [_jsx(When, { s: picked, onChange: setPicked }), _jsx(XStack, { children: _jsx(Button, { size: "sm", variant: "ghost", onPress: () => setPicking(false), children: "Say it instead" }) })] })) : (_jsxs(YStack, { gap: "$2", children: [_jsx(Input, { value: said, onChangeText: setSaid, placeholder: "Every Monday at 9am", "aria-label": "When it runs" }), _jsx(SizableText, { size: "$1", color: unread ? '$soft' : '$ink', role: "status", minH: 18, children: reading ? 'Reading…' : unread ? `${unread} Choose instead, or say it another way.` : heard }), _jsx(XStack, { children: _jsx(Button, { size: "sm", variant: "ghost", onPress: () => setPicking(true), children: "Choose instead" }) })] })) }), _jsx(Field, { label: "Model", children: _jsx(XStack, { children: _jsx(ModelPicker, { size: "sm", name: "Model", models: catalog.value, scope: "chat", limits: limits, value: model ?? ENSO, onChange: (id) => setModel(id === ENSO ? null : id), loading: catalog.loading, error: catalog.error?.message ?? null }) }) }), _jsx(Line, { children: note }), _jsxs(XStack, { gap: "$2", justify: "flex-end", children: [_jsx(Button, { size: "sm", variant: "ghost", disabled: busy, onPress: onClose, children: "Cancel" }), _jsx(Button, { size: "sm", disabled: busy || !signed, onPress: () => void make(), children: busy ? 'Creating…' : 'Create' })] })] }) }));
|
|
272
|
+
}
|
|
273
|
+
function Badge({ children }) {
|
|
274
|
+
return (_jsx(SizableText, { size: "$1", color: "$soft", px: "$1.5", rounded: "$2", borderWidth: 1, borderColor: "$borderColor", shrink: 0, children: children }));
|
|
275
|
+
}
|
|
276
|
+
function Facts({ rows }) {
|
|
277
|
+
return (_jsx(YStack, { gap: "$1", children: rows.map(([k, v]) => (_jsxs(XStack, { gap: "$3", children: [_jsx(SizableText, { size: "$1", color: "$soft", width: 72, children: k }), _jsx(SizableText, { size: "$1", color: "$ink", flex: 1, children: v })] }, k))) }));
|
|
278
|
+
}
|
|
279
|
+
function Said({ says, children }) {
|
|
280
|
+
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] }));
|
|
281
|
+
}
|
package/lib/shelf.d.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* What this org has
|
|
3
|
-
* every
|
|
4
|
-
* private, and its rename, visibility and delete. A
|
|
5
|
-
*
|
|
2
|
+
* What this org has deployed: a few starters to make something new from, then
|
|
3
|
+
* every site under the month it was last edited, whether it is public or
|
|
4
|
+
* private, and its rename, visibility and delete. A site belongs to the org and
|
|
5
|
+
* names no author, so the list does not split yours from a colleague's — which
|
|
6
|
+
* is why it is a section of its own under the Library's items, which are yours.
|
|
6
7
|
*/
|
|
7
|
-
export declare function
|
|
8
|
+
export declare function Sites(): import("react").JSX.Element;
|
|
8
9
|
export declare function Templates(): import("react").JSX.Element;
|
package/lib/shelf.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
* public starters (Templates).
|
|
3
|
+
* Two shelves: the sites this org has deployed (Sites, drawn at the foot of the
|
|
4
|
+
* Library — library.tsx), and the public starters (Templates).
|
|
5
5
|
*
|
|
6
6
|
* A project is a real row — `/v1/projects`, the same one a deployed site is
|
|
7
7
|
* served from — so opening one opens its workspace at its own address. A
|
|
@@ -70,12 +70,13 @@ function edited(s) {
|
|
|
70
70
|
return `Edited ${d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year })}`;
|
|
71
71
|
}
|
|
72
72
|
/**
|
|
73
|
-
* What this org has
|
|
74
|
-
* every
|
|
75
|
-
* private, and its rename, visibility and delete. A
|
|
76
|
-
*
|
|
73
|
+
* What this org has deployed: a few starters to make something new from, then
|
|
74
|
+
* every site under the month it was last edited, whether it is public or
|
|
75
|
+
* private, and its rename, visibility and delete. A site belongs to the org and
|
|
76
|
+
* names no author, so the list does not split yours from a colleague's — which
|
|
77
|
+
* is why it is a section of its own under the Library's items, which are yours.
|
|
77
78
|
*/
|
|
78
|
-
export function
|
|
79
|
+
export function Sites() {
|
|
79
80
|
const t = useTarget();
|
|
80
81
|
const host = useHost();
|
|
81
82
|
const list = useProjects(t, Boolean(host.person));
|
|
@@ -97,7 +98,7 @@ export function Artifacts() {
|
|
|
97
98
|
}
|
|
98
99
|
};
|
|
99
100
|
const said = failed || note;
|
|
100
|
-
return (_jsxs(YStack, {
|
|
101
|
+
return (_jsxs(YStack, { width: "100%", role: "region", "aria-label": "Sites", children: [_jsxs(YStack, { width: "100%", children: [_jsxs(YStack, { gap: "$1", pb: "$4", children: [_jsx(SizableText, { render: "h2", size: "$5", color: "$ink", children: "Sites" }), _jsx(SizableText, { size: "$2", color: "$soft", children: "What this organization has built. Open one to keep building it." })] }), said ? (_jsx(SizableText, { size: "$2", color: "$soft", role: "status", pb: "$3", children: said })) : null, starters.value.length ? (_jsxs(YStack, { gap: "$3", pb: "$6", children: [_jsx(SizableText, { size: "$3", color: "$ink", children: "Make something new" }), _jsx(XStack, { flexWrap: "wrap", gap: "$3", children: starters.value.slice(0, 4).map((x) => (_jsxs(Card, { small: true, label: `Start from ${x.title}`, onPress: () => void take(x.slug), children: [_jsx(Shot, { slug: x.slug }), _jsx(YStack, { px: "$3", py: "$2.5", children: _jsx(SizableText, { size: "$2", color: "$ink", numberOfLines: 1, children: busy === x.slug ? 'Copying…' : x.title }) })] }, x.slug))) })] })) : null, list.error ? (_jsx(SizableText, { size: "$2", color: "$soft", children: list.error.message })) : !host.person ? (_jsx(SizableText, { size: "$2", color: "$soft", children: "Sign in to see what your organization has built." })) : list.loading && list.value.length === 0 ? (_jsx(SizableText, { size: "$2", color: "$soft", children: "Reading your projects\u2026" })) : list.value.length === 0 ? (_jsxs(YStack, { gap: "$3", items: "flex-start", children: [_jsx(SizableText, { size: "$2", color: "$soft", children: "Nothing built yet. Describe something on the New page and it lands here." }), _jsx(Button, { size: "sm", onPress: () => host.go(''), children: "New run" })] })) : (_jsx(YStack, { gap: "$5", children: months(list.value).map((g) => (_jsxs(YStack, { gap: "$2", children: [_jsx(SizableText, { size: "$2", color: "$soft", children: g.label }), _jsx(YStack, { borderWidth: 1, borderColor: "$borderColor", rounded: "$3", overflow: "hidden", children: g.rows.map((p, i) => (_jsxs(XStack, { items: "center", pr: "$2", borderTopWidth: i ? 1 : 0, borderColor: "$borderColor", hoverStyle: { bg: '$hover' }, children: [_jsxs(XStack, { flex: 1, minW: 0, render: "button", "aria-label": `Open ${p.name}`, onPress: () => host.go(p.slug), items: "center", gap: "$3", px: "$3", py: "$2.5", cursor: "pointer", style: { textAlign: 'left' }, children: [p.visibility === 'private' ? _jsx(Lock, { size: 16, opacity: 0.7 }) : _jsx(Globe, { size: 16, opacity: 0.7 }), _jsxs(YStack, { flex: 1, minW: 0, gap: "$0.5", children: [_jsx(SizableText, { size: "$3", color: "$ink", numberOfLines: 1, children: p.name }), _jsx(SizableText, { size: "$1", color: "$soft", numberOfLines: 1, children: [p.visibility === 'private' ? 'Private' : 'Public', edited(when(p)), p.live ? new URL(p.live).host : 'Not deployed yet']
|
|
101
102
|
.filter(Boolean)
|
|
102
103
|
.join(' · ') })] })] }), _jsx(DropdownMenu, { trigger: _jsx(XStack, { render: "button", "aria-label": `Actions for ${p.name}`, px: "$1.5", py: "$1", rounded: "$2", hoverStyle: { bg: '$raised' }, children: _jsx(MoreHorizontal, { size: 16 }) }), items: [
|
|
103
104
|
{ key: 'rename', label: 'Rename', onSelect: () => setRenaming(p) },
|
package/lib/summary.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A surface's summary: what it made and what went into it, as sections of
|
|
3
|
+
* rows, each one press from a tab beside it. Chat draws its Outputs and Sources
|
|
4
|
+
* here; Studio its artifacts. The rows are the caller's: this draws them and
|
|
5
|
+
* knows nothing of a conversation.
|
|
6
|
+
*
|
|
7
|
+
* `Summary` is the popover off the header's outline mark: each section's
|
|
8
|
+
* heading with what it adds at its end, its leading action (Chat's Create file
|
|
9
|
+
* or site), its rows, and one quiet line when it has none. `useKinds` makes
|
|
10
|
+
* each section a kind of tab for `Panel`, the same rows in the panel.
|
|
11
|
+
*/
|
|
12
|
+
import { type ReactNode } from 'react';
|
|
13
|
+
import type { Kind } from './panel.tsx';
|
|
14
|
+
/** One row: what it is called, its mark, and what pressing it opens. */
|
|
15
|
+
export interface Entry {
|
|
16
|
+
id: string;
|
|
17
|
+
title: string;
|
|
18
|
+
icon?: ReactNode;
|
|
19
|
+
/** A quieter second line: what a lookup asked, what came back. */
|
|
20
|
+
detail?: string;
|
|
21
|
+
open?: () => void;
|
|
22
|
+
}
|
|
23
|
+
/** A section: its heading, its rows, and what it says with none. */
|
|
24
|
+
export interface Section {
|
|
25
|
+
id: string;
|
|
26
|
+
title: string;
|
|
27
|
+
entries: readonly Entry[];
|
|
28
|
+
/** The line drawn when it has no rows; none, and it draws only its heading. */
|
|
29
|
+
empty?: string;
|
|
30
|
+
/** At the heading's end: what adds to it (Chat's `+` on Sources). */
|
|
31
|
+
action?: ReactNode;
|
|
32
|
+
/** A row before the others that does something rather than opens something. */
|
|
33
|
+
lead?: {
|
|
34
|
+
title: string;
|
|
35
|
+
run: () => void;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The summary, from the outline mark in a header. `onOpen` runs after a row is
|
|
40
|
+
* pressed, for the host to show the panel the row opened a tab in.
|
|
41
|
+
*/
|
|
42
|
+
export declare function Summary({ sections, onOpen, label }: {
|
|
43
|
+
sections: readonly Section[];
|
|
44
|
+
onOpen?: () => void;
|
|
45
|
+
label?: string;
|
|
46
|
+
}): import("react").JSX.Element;
|
|
47
|
+
/** Each section as a kind of tab for `Panel`: the same rows, with their second lines. */
|
|
48
|
+
export declare function kinds(sections: readonly Section[], icons?: Record<string, ReactNode>): Kind[];
|
|
49
|
+
/** `kinds`, kept while the sections are the same. */
|
|
50
|
+
export declare function useKinds(sections: readonly Section[], icons?: Record<string, ReactNode>): Kind[];
|
package/lib/summary.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* A surface's summary: what it made and what went into it, as sections of
|
|
4
|
+
* rows, each one press from a tab beside it. Chat draws its Outputs and Sources
|
|
5
|
+
* here; Studio its artifacts. The rows are the caller's: this draws them and
|
|
6
|
+
* knows nothing of a conversation.
|
|
7
|
+
*
|
|
8
|
+
* `Summary` is the popover off the header's outline mark: each section's
|
|
9
|
+
* heading with what it adds at its end, its leading action (Chat's Create file
|
|
10
|
+
* or site), its rows, and one quiet line when it has none. `useKinds` makes
|
|
11
|
+
* each section a kind of tab for `Panel`, the same rows in the panel.
|
|
12
|
+
*/
|
|
13
|
+
import { useMemo, useState } from 'react';
|
|
14
|
+
import { SizableText, XStack, YStack } from '@hanzo/gui';
|
|
15
|
+
import { ListTodo } from '@hanzogui/lucide-icons-2';
|
|
16
|
+
import { Popover, PopoverContent, PopoverTrigger } from '@hanzo/ui';
|
|
17
|
+
/** A row of the summary: its mark and its name, one press from a tab. */
|
|
18
|
+
function Line({ icon, children, detail, onPress, quiet = false }) {
|
|
19
|
+
return (_jsxs(XStack, { render: onPress ? 'button' : undefined, role: onPress ? 'button' : undefined, onPress: onPress, items: "center", gap: "$2.5", px: "$2", minH: 30, width: "100%", rounded: "$3", cursor: onPress ? 'pointer' : undefined, hoverStyle: onPress ? { bg: '$hover' } : undefined, focusVisibleStyle: { outlineWidth: 2, outlineStyle: 'solid', outlineColor: '$outlineColor' }, children: [icon ? (_jsx(XStack, { width: 16, justify: "center", shrink: 0, opacity: 0.75, children: icon })) : null, _jsxs(YStack, { flex: 1, minW: 0, py: detail ? '$1' : 0, children: [_jsx(SizableText, { fontSize: 13, color: quiet ? '$soft' : '$ink', numberOfLines: 1, children: children }), detail ? (_jsx(SizableText, { fontSize: 12, color: "$soft", numberOfLines: 2, children: detail })) : null] })] }));
|
|
20
|
+
}
|
|
21
|
+
/** A section's heading, with what it adds at its end. */
|
|
22
|
+
const Head = ({ children, action }) => (_jsxs(XStack, { items: "center", justify: "space-between", px: "$2", pt: "$1.5", pb: "$1", minH: 28, children: [_jsx(SizableText, { fontSize: 13, color: "$soft", children: children }), action] }));
|
|
23
|
+
/** One section's rows; `done` runs after a row is pressed (the popover puts itself away). */
|
|
24
|
+
function Rows({ section, done, details = false }) {
|
|
25
|
+
const { entries, empty, lead } = section;
|
|
26
|
+
return (_jsxs(_Fragment, { children: [lead ? (_jsx(Line, { quiet: true, onPress: () => {
|
|
27
|
+
done?.();
|
|
28
|
+
lead.run();
|
|
29
|
+
}, children: lead.title })) : null, entries.map((e) => (_jsx(Line, { icon: e.icon, detail: details ? e.detail : undefined, onPress: e.open
|
|
30
|
+
? () => {
|
|
31
|
+
done?.();
|
|
32
|
+
e.open?.();
|
|
33
|
+
}
|
|
34
|
+
: undefined, children: e.title }, e.id))), !entries.length && empty ? _jsx(Line, { quiet: true, children: empty }) : null] }));
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The summary, from the outline mark in a header. `onOpen` runs after a row is
|
|
38
|
+
* pressed, for the host to show the panel the row opened a tab in.
|
|
39
|
+
*/
|
|
40
|
+
export function Summary({ sections, onOpen, label = 'Summary' }) {
|
|
41
|
+
const [open, setOpen] = useState(false);
|
|
42
|
+
const done = () => {
|
|
43
|
+
setOpen(false);
|
|
44
|
+
onOpen?.();
|
|
45
|
+
};
|
|
46
|
+
return (_jsxs(Popover, { open: open, onOpenChange: setOpen, allowFlip: true, children: [_jsx(PopoverTrigger, { children: _jsx(XStack, { render: "button", "data-slot": "summary-toggle", "aria-label": label, "aria-haspopup": "dialog", "aria-expanded": open, ...{ title: label }, width: 32, height: 32, rounded: "$3", items: "center", justify: "center", cursor: "pointer", bg: open ? '$hover' : 'transparent', hoverStyle: { bg: '$hover' }, focusVisibleStyle: { outlineWidth: 2, outlineStyle: 'solid', outlineColor: '$outlineColor' }, children: _jsx(ListTodo, { size: 16, "aria-hidden": true }) }) }), _jsx(PopoverContent, { solid: true, align: "end", p: 6, width: 300, children: _jsx(YStack, { "data-slot": "summary", role: "dialog", "aria-label": label, gap: 1, self: "stretch", width: "100%", maxH: "min(70dvh, 520px)", overflowY: "auto", overflowX: "hidden", children: sections.map((s, i) => (_jsxs(YStack, { gap: 1, children: [i ? _jsx(XStack, { height: 1, bg: "$borderColor", my: "$1.5", mx: "$1" }) : null, _jsx(Head, { action: s.action, children: s.title }), _jsx(Rows, { section: s, done: done })] }, s.id))) }) })] }));
|
|
47
|
+
}
|
|
48
|
+
/** Each section as a kind of tab for `Panel`: the same rows, with their second lines. */
|
|
49
|
+
export function kinds(sections, icons = {}) {
|
|
50
|
+
return sections.map((s) => ({
|
|
51
|
+
id: s.id,
|
|
52
|
+
label: s.title,
|
|
53
|
+
icon: icons[s.id],
|
|
54
|
+
render: () => (_jsxs(YStack, { gap: 1, children: [_jsx(Head, { action: s.action, children: s.title }), _jsx(Rows, { section: s, details: true })] })),
|
|
55
|
+
}));
|
|
56
|
+
}
|
|
57
|
+
/** `kinds`, kept while the sections are the same. */
|
|
58
|
+
export function useKinds(sections, icons) {
|
|
59
|
+
return useMemo(() => kinds(sections, icons), [sections, icons]);
|
|
60
|
+
}
|
package/lib/tabs.d.ts
CHANGED
|
@@ -11,6 +11,8 @@ export interface Tab {
|
|
|
11
11
|
/** The addresses a page came from, oldest first, and those Back stepped over, nearest first. */
|
|
12
12
|
back?: string[];
|
|
13
13
|
ahead?: string[];
|
|
14
|
+
/** Leads the strip, and closes only on purpose. */
|
|
15
|
+
pinned?: boolean;
|
|
14
16
|
}
|
|
15
17
|
/** The tabs in a scope, in order, and the chosen one. */
|
|
16
18
|
export interface Tabs {
|
|
@@ -26,8 +28,12 @@ export declare function opened(was: Tabs, tab: Omit<Tab, 'id'> & {
|
|
|
26
28
|
}): Tabs;
|
|
27
29
|
/** A tab shut: the one to its right is chosen, else the one to its left, as a browser does. */
|
|
28
30
|
export declare function shut(was: Tabs, id: string): Tabs;
|
|
29
|
-
/** A tab moved to `to`, the others closing up around it. */
|
|
31
|
+
/** A tab moved to `to`, the others closing up around it, and kept in its group: pinned or not. */
|
|
30
32
|
export declare function moved(was: Tabs, id: string, to: number): Tabs;
|
|
33
|
+
/** A tab pinned, or let go: it joins the end of the pinned tabs, or leads the rest. */
|
|
34
|
+
export declare function pinned(was: Tabs, id: string, on: boolean): Tabs;
|
|
35
|
+
/** Every tab closed but the pinned ones and those `keep` names; the chosen one stays if it is left. */
|
|
36
|
+
export declare function cleared(was: Tabs, keep?: (t: Tab) => boolean): Tabs;
|
|
31
37
|
export interface Deck extends Tabs {
|
|
32
38
|
open: (tab: Omit<Tab, 'id'> & {
|
|
33
39
|
id?: string;
|
|
@@ -35,6 +41,11 @@ export interface Deck extends Tabs {
|
|
|
35
41
|
pick: (id: string) => void;
|
|
36
42
|
shut: (id: string) => void;
|
|
37
43
|
move: (id: string, to: number) => void;
|
|
44
|
+
/** Pin a tab: it leads the strip and closes only on purpose. */
|
|
45
|
+
pin: (id: string) => void;
|
|
46
|
+
unpin: (id: string) => void;
|
|
47
|
+
/** Close every tab but the pinned ones and those `keep` names. */
|
|
48
|
+
clear: (keep?: (t: Tab) => boolean) => void;
|
|
38
49
|
/** Point a page somewhere else: where it was goes on its Back, and Forward is forgotten. */
|
|
39
50
|
go: (id: string, url: string) => void;
|
|
40
51
|
/** A page's Back and Forward. */
|
package/lib/tabs.js
CHANGED
|
@@ -15,6 +15,11 @@
|
|
|
15
15
|
* A page keeps the addresses it went through (`back`, `ahead`), so its bar's
|
|
16
16
|
* Back and Forward walk them as a browser's do.
|
|
17
17
|
*
|
|
18
|
+
* A PINNED tab leads the strip and closes only on purpose: its own close (`shut`)
|
|
19
|
+
* takes it, and nothing that closes tabs in bulk (`clear`) does. Pinned tabs
|
|
20
|
+
* keep their order among themselves, the others theirs; a tab moved stays in
|
|
21
|
+
* its group.
|
|
22
|
+
*
|
|
18
23
|
*/
|
|
19
24
|
import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
|
|
20
25
|
export const PAGE = 'page';
|
|
@@ -53,7 +58,7 @@ export function read(scope) {
|
|
|
53
58
|
if (raw) {
|
|
54
59
|
const v = JSON.parse(raw);
|
|
55
60
|
// An object URL dies with the document that minted it: a page holding only one is not kept.
|
|
56
|
-
const tabs = (Array.isArray(v.tabs) ? v.tabs.filter(isTab) : []).filter((t) => t.kind !== PAGE || t.body !== undefined || !t.url?.startsWith('blob:'));
|
|
61
|
+
const tabs = ranked((Array.isArray(v.tabs) ? v.tabs.filter(isTab) : []).filter((t) => t.kind !== PAGE || t.body !== undefined || !t.url?.startsWith('blob:')));
|
|
57
62
|
held = { tabs, at: tabs.some((t) => t.id === v.at) ? v.at : (tabs[0]?.id ?? null) };
|
|
58
63
|
}
|
|
59
64
|
}
|
|
@@ -74,6 +79,10 @@ function write(scope, next) {
|
|
|
74
79
|
}
|
|
75
80
|
announce();
|
|
76
81
|
}
|
|
82
|
+
/** Pinned tabs first, each group in the order it had. */
|
|
83
|
+
function ranked(tabs) {
|
|
84
|
+
return [...tabs.filter((t) => t.pinned), ...tabs.filter((t) => !t.pinned)];
|
|
85
|
+
}
|
|
77
86
|
let minted = 0;
|
|
78
87
|
const mint = () => `p${Date.now().toString(36)}${(minted++).toString(36)}`;
|
|
79
88
|
/** A tab opened: a kind already open is chosen, a page already open on the same thing is chosen. */
|
|
@@ -82,7 +91,7 @@ export function opened(was, tab) {
|
|
|
82
91
|
if (same)
|
|
83
92
|
return { ...was, at: same.id };
|
|
84
93
|
const id = tab.id ?? (tab.kind === PAGE ? mint() : tab.kind);
|
|
85
|
-
return { tabs: [...was.tabs, { ...tab, id }], at: id };
|
|
94
|
+
return { tabs: ranked([...was.tabs, { ...tab, id }]), at: id };
|
|
86
95
|
}
|
|
87
96
|
/** A tab shut: the one to its right is chosen, else the one to its left, as a browser does. */
|
|
88
97
|
export function shut(was, id) {
|
|
@@ -94,16 +103,37 @@ export function shut(was, id) {
|
|
|
94
103
|
return { ...was, tabs };
|
|
95
104
|
return { tabs, at: (tabs[gone] ?? tabs[gone - 1])?.id ?? null };
|
|
96
105
|
}
|
|
97
|
-
/** A tab moved to `to`, the others closing up around it. */
|
|
106
|
+
/** A tab moved to `to`, the others closing up around it, and kept in its group: pinned or not. */
|
|
98
107
|
export function moved(was, id, to) {
|
|
99
108
|
const from = was.tabs.findIndex((t) => t.id === id);
|
|
100
109
|
if (from < 0)
|
|
101
110
|
return was;
|
|
102
111
|
const tabs = was.tabs.slice();
|
|
103
112
|
const [tab] = tabs.splice(from, 1);
|
|
104
|
-
tabs.
|
|
113
|
+
const pins = tabs.filter((t) => t.pinned).length;
|
|
114
|
+
const [low, high] = tab.pinned ? [0, pins] : [pins, tabs.length];
|
|
115
|
+
tabs.splice(Math.max(low, Math.min(high, to)), 0, tab);
|
|
105
116
|
return { ...was, tabs };
|
|
106
117
|
}
|
|
118
|
+
/** A tab pinned, or let go: it joins the end of the pinned tabs, or leads the rest. */
|
|
119
|
+
export function pinned(was, id, on) {
|
|
120
|
+
const tab = was.tabs.find((t) => t.id === id);
|
|
121
|
+
if (!tab || Boolean(tab.pinned) === on)
|
|
122
|
+
return was;
|
|
123
|
+
const rest = was.tabs.filter((t) => t.id !== id);
|
|
124
|
+
const pins = rest.filter((t) => t.pinned);
|
|
125
|
+
const others = rest.filter((t) => !t.pinned);
|
|
126
|
+
const plain = { ...tab };
|
|
127
|
+
delete plain.pinned;
|
|
128
|
+
return { ...was, tabs: on ? [...pins, { ...tab, pinned: true }, ...others] : [...pins, plain, ...others] };
|
|
129
|
+
}
|
|
130
|
+
/** Every tab closed but the pinned ones and those `keep` names; the chosen one stays if it is left. */
|
|
131
|
+
export function cleared(was, keep = () => false) {
|
|
132
|
+
const tabs = was.tabs.filter((t) => t.pinned || keep(t));
|
|
133
|
+
if (tabs.length === was.tabs.length)
|
|
134
|
+
return was;
|
|
135
|
+
return { tabs, at: tabs.some((t) => t.id === was.at) ? was.at : (tabs[0]?.id ?? null) };
|
|
136
|
+
}
|
|
107
137
|
/** How many addresses a page's Back keeps. */
|
|
108
138
|
const DEPTH = 50;
|
|
109
139
|
/** A page pointed at `url`, where it was on its Back. */
|
|
@@ -136,6 +166,9 @@ export function useDeck(scope, seed) {
|
|
|
136
166
|
pick: (id) => change((was) => ({ ...was, at: id })),
|
|
137
167
|
shut: (id) => change((was) => shut(was, id)),
|
|
138
168
|
move: (id, to) => change((was) => moved(was, id, to)),
|
|
169
|
+
pin: (id) => change((was) => pinned(was, id, true)),
|
|
170
|
+
unpin: (id) => change((was) => pinned(was, id, false)),
|
|
171
|
+
clear: (keep) => change((was) => cleared(was, keep)),
|
|
139
172
|
go: (id, url) => change((was) => ({ ...was, tabs: was.tabs.map((t) => (t.id === id ? went(t, url) : t)) })),
|
|
140
173
|
back: (id) => change((was) => ({ ...was, tabs: was.tabs.map((t) => (t.id === id ? stepped(t, 'back') : t)) })),
|
|
141
174
|
forward: (id) => change((was) => ({ ...was, tabs: was.tabs.map((t) => (t.id === id ? stepped(t, 'forward') : t)) })),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hanzo/build",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.58",
|
|
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 component, mounted by the Hanzo app at hanzo.ai.",
|