@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/lib/library.js ADDED
@@ -0,0 +1,407 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * The Library: everything you made — in Chat, in Dev, in Studio and in
4
+ * workflows — in one place, newest first (api/library.ts).
5
+ *
6
+ * -/artifacts the Library; `?item=<id>` opens one item's preview
7
+ *
8
+ * A tile opens its item in the side panel (panel.tsx), one tab per item, so a
9
+ * few can be read side by side; on a phone the panel is a sheet. Each item
10
+ * carries what can be done with it: open where it was made, download, share
11
+ * with the org, copy its link, rename, move to a project, use in Studio or in
12
+ * Chat, and delete. What a colleague shared is drawn with their name and offers
13
+ * only what a reader may do with it.
14
+ */
15
+ import { SizableText, useMedia, XStack, YStack } from '@hanzo/gui';
16
+ import { AudioLines, Box, Code2, Copy, Download, ExternalLink, FileText, FolderInput, Image as Picture, LayoutGrid, List, MessageSquare, MoreHorizontal, Pencil, Presentation, Search, Share2, Sparkles, Trash2, Type, Video, } from '@hanzogui/lucide-icons-2';
17
+ import { Button, Dialog, DialogContent, DialogTitle, DropdownMenu, Input } from '@hanzo/ui';
18
+ import { ChipSelect } from '@hanzo/ui/product';
19
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
20
+ import { ago } from './ago.js';
21
+ import { change, days, get, items as list, remove, size, SOURCE } from './api/library.js';
22
+ import { Confirm, Rename } from './ask.js';
23
+ import { useKept, useProjects, useRead } from './data.js';
24
+ import { useHost, useTarget } from './host.js';
25
+ import { Panel } from './panel.js';
26
+ import { gap, pane } from './pane.js';
27
+ import { Sites } from './shelf.js';
28
+ import { useDeck } from './tabs.js';
29
+ /** The kinds a reader narrows to, in the order the chips draw them. */
30
+ const KINDS = [
31
+ { id: '', label: 'All' },
32
+ { id: 'image', label: 'Images' },
33
+ { id: 'video', label: 'Videos' },
34
+ { id: 'audio', label: 'Audio' },
35
+ { id: 'document', label: 'Documents' },
36
+ { id: 'slides', label: 'Slides' },
37
+ { id: 'model', label: '3D' },
38
+ { id: 'code', label: 'Code' },
39
+ { id: 'file', label: 'Files' },
40
+ ];
41
+ const FROM = [
42
+ { id: '', label: 'Anywhere' },
43
+ { id: 'chat', label: 'Chat' },
44
+ { id: 'dev', label: 'Dev' },
45
+ { id: 'studio', label: 'Studio' },
46
+ { id: 'workflow', label: 'Workflows' },
47
+ ];
48
+ const WHOSE = [
49
+ { id: 'all', label: 'Mine and shared' },
50
+ { id: 'mine', label: 'Mine' },
51
+ { id: 'shared', label: 'Shared with me' },
52
+ ];
53
+ /** How many items a page reads. */
54
+ const PAGE = 60;
55
+ /** The side panel's scope: the items a reader opened, kept as they left them. */
56
+ const DECK = 'library';
57
+ const TAB = 'item:';
58
+ /** A kind's mark. */
59
+ function glyph(kind, n = 18) {
60
+ switch (kind) {
61
+ case 'image':
62
+ return _jsx(Picture, { size: n });
63
+ case 'video':
64
+ return _jsx(Video, { size: n });
65
+ case 'audio':
66
+ return _jsx(AudioLines, { size: n });
67
+ case 'document':
68
+ return _jsx(FileText, { size: n });
69
+ case 'slides':
70
+ return _jsx(Presentation, { size: n });
71
+ case 'type':
72
+ return _jsx(Type, { size: n });
73
+ case 'model':
74
+ return _jsx(Box, { size: n });
75
+ case 'code':
76
+ return _jsx(Code2, { size: n });
77
+ default:
78
+ return _jsx(FileText, { size: n });
79
+ }
80
+ }
81
+ /** The still a tile shows: the item's preview, or an image's own bytes. */
82
+ const still = (x) => x.preview || (x.kind === 'image' && x.mime !== 'image/svg+xml' ? x.url : '');
83
+ /** One square: the still, or the kind's mark on a quiet ground. */
84
+ export function Thumb({ x, size: n }) {
85
+ const [broken, setBroken] = useState(false);
86
+ const src = still(x);
87
+ return (_jsx(YStack, { width: n ?? '100%', height: n, style: n ? undefined : { aspectRatio: '1 / 1' }, rounded: "$3", overflow: "hidden", bg: "$raised", items: "center", justify: "center", shrink: 0, "aria-hidden": true, children: src && !broken ? (_jsx("img", { src: src, alt: "", loading: "lazy", onError: () => setBroken(true), style: { width: '100%', height: '100%', objectFit: 'cover', display: 'block' } })) : (_jsx(YStack, { opacity: 0.7, children: glyph(x.kind, n ? Math.max(14, Math.round(n / 2.6)) : 28) })) }));
88
+ }
89
+ /** What a tile says under its name: where it came from, whose, and when. */
90
+ function meta(x) {
91
+ return [SOURCE[x.source], x.mine ? '' : `shared by ${x.owner}`, ago(x.created)].filter(Boolean).join(' · ');
92
+ }
93
+ /** An item's link, to paste: this page with the item named. */
94
+ function linkTo(x) {
95
+ const u = new URL(window.location.href);
96
+ u.search = '';
97
+ u.hash = '';
98
+ u.searchParams.set('item', x.id);
99
+ return u.toString();
100
+ }
101
+ const ID = /^lib_[0-9a-f]{32}$/;
102
+ /** Where a move into the Library leaves the item it should open. */
103
+ const WANT = 'hanzo.build.library.item';
104
+ /** Open the Library at one item: the move leaves its id for the screen to take. */
105
+ export function want(id) {
106
+ try {
107
+ sessionStorage.setItem(WANT, id);
108
+ }
109
+ catch {
110
+ // Without storage the Library opens without it.
111
+ }
112
+ }
113
+ /** The item to open on arrival: one a move left, or the one `?item=` names. */
114
+ function named() {
115
+ if (typeof window === 'undefined')
116
+ return '';
117
+ let left = '';
118
+ try {
119
+ left = sessionStorage.getItem(WANT) ?? '';
120
+ sessionStorage.removeItem(WANT);
121
+ }
122
+ catch {
123
+ left = '';
124
+ }
125
+ const id = left || new URLSearchParams(window.location.search).get('item') || '';
126
+ return ID.test(id) ? id : '';
127
+ }
128
+ /** What can be done with one item, as menu rows. */
129
+ function useActs(onChanged, onGone) {
130
+ const host = useHost();
131
+ const t = useTarget();
132
+ const [renaming, setRenaming] = useState(null);
133
+ const [deleting, setDeleting] = useState(null);
134
+ const [moving, setMoving] = useState(null);
135
+ const [note, setNote] = useState('');
136
+ const act = async (work) => {
137
+ setNote('');
138
+ try {
139
+ await work();
140
+ }
141
+ catch (e) {
142
+ setNote(e.message);
143
+ }
144
+ };
145
+ const copy = (text, said) => act(async () => {
146
+ await navigator.clipboard.writeText(text);
147
+ setNote(said);
148
+ });
149
+ const rows = (x) => [
150
+ ...(x.origin ? [{ key: 'open', label: 'Open where it was made', icon: _jsx(ExternalLink, { size: 16 }), onSelect: () => host.open(x.origin) }] : []),
151
+ ...(x.download ? [{ key: 'download', label: 'Download', icon: _jsx(Download, { size: 16 }), onSelect: () => window.open(x.download, '_blank', 'noopener,noreferrer') }] : []),
152
+ ...(x.mine
153
+ ? [
154
+ {
155
+ key: 'share',
156
+ label: x.shared ? 'Stop sharing' : 'Share with organization',
157
+ icon: _jsx(Share2, { size: 16 }),
158
+ description: x.shared ? 'Only you see it' : 'Everyone in it can open it',
159
+ onSelect: () => void act(async () => {
160
+ const next = await change(t, x.id, { shared: !x.shared });
161
+ onChanged(next);
162
+ setNote(next.shared ? `${next.name} is shared with the organization` : `${next.name} is yours alone`);
163
+ }),
164
+ },
165
+ ]
166
+ : []),
167
+ { key: 'link', label: 'Copy link', icon: _jsx(Copy, { size: 16 }), onSelect: () => void copy(linkTo(x), 'Link copied') },
168
+ ...(x.mine
169
+ ? [
170
+ { key: 'rename', label: 'Rename', icon: _jsx(Pencil, { size: 16 }), onSelect: () => setRenaming(x) },
171
+ { key: 'move', label: 'Move to project', icon: _jsx(FolderInput, { size: 16 }), onSelect: () => setMoving(x) },
172
+ ]
173
+ : []),
174
+ { key: 'studio', label: 'Use in Studio', icon: _jsx(Sparkles, { size: 16 }), onSelect: () => host.open(`/studio?asset=${encodeURIComponent(x.id)}`) },
175
+ { key: 'chat', label: 'Use in Chat', icon: _jsx(MessageSquare, { size: 16 }), onSelect: () => host.open(`/chat?attach=${encodeURIComponent(x.id)}`) },
176
+ ...(x.mine ? [{ key: 'delete', label: 'Delete', icon: _jsx(Trash2, { size: 16 }), onSelect: () => setDeleting(x) }] : []),
177
+ ];
178
+ const dialogs = (_jsxs(_Fragment, { children: [_jsx(Rename, { open: Boolean(renaming), onOpenChange: (o) => !o && setRenaming(null), title: "Rename", name: renaming?.name ?? '', run: async (name) => {
179
+ const next = await change(t, renaming.id, { name });
180
+ onChanged(next);
181
+ } }), _jsx(Confirm, { open: Boolean(deleting), onOpenChange: (o) => !o && setDeleting(null), title: `Delete ${deleting?.name ?? ''}?`, says: "It leaves the Library and its file is deleted. Anyone it was shared with loses it too.", act: "Delete", run: async () => {
182
+ await remove(t, deleting.id);
183
+ onGone(deleting.id);
184
+ } }), _jsx(Move, { item: moving, onClose: () => setMoving(null), onMoved: (next) => {
185
+ onChanged(next);
186
+ setNote(next.project ? `${next.name} is in ${next.project}` : `${next.name} is in no project`);
187
+ } })] }));
188
+ return { rows, dialogs, note };
189
+ }
190
+ /** Choosing the project an item belongs to, or none. */
191
+ function Move({ item: x, onClose, onMoved }) {
192
+ const host = useHost();
193
+ const t = useTarget();
194
+ const projects = useProjects(t, Boolean(host.person) && Boolean(x));
195
+ const [busy, setBusy] = useState(false);
196
+ const [note, setNote] = useState('');
197
+ const pick = async (project) => {
198
+ if (!x)
199
+ return;
200
+ setBusy(true);
201
+ setNote('');
202
+ try {
203
+ onMoved(await change(t, x.id, { project }));
204
+ onClose();
205
+ }
206
+ catch (e) {
207
+ setNote(e.message);
208
+ }
209
+ finally {
210
+ setBusy(false);
211
+ }
212
+ };
213
+ return (_jsx(Dialog, { open: Boolean(x), onOpenChange: (o) => !o && !busy && onClose(), children: _jsxs(DialogContent, { maxW: 420, children: [_jsx(DialogTitle, { children: "Move to project" }), _jsx(YStack, { gap: "$1", maxH: 320, overflow: "scroll", children: projects.loading && !projects.value.length ? (_jsx(SizableText, { size: "$2", color: "$soft", children: "Reading projects\u2026" })) : projects.error ? (_jsx(SizableText, { size: "$2", color: "$soft", children: projects.error.message })) : ([{ slug: '', name: 'No project' }, ...projects.value].map((p) => (_jsx(XStack, { render: "button", "aria-label": p.slug ? `Move to ${p.name || p.slug}` : 'Move to no project', "aria-current": x?.project === p.slug ? 'true' : undefined, disabled: busy, onPress: () => void pick(p.slug), px: "$3", py: "$2", rounded: "$3", bg: x?.project === p.slug ? '$hover' : 'transparent', hoverStyle: { bg: '$hover' }, cursor: "pointer", children: _jsx(SizableText, { size: "$2", color: "$ink", children: p.name || p.slug }) }, p.slug || 'none')))) }), note ? (_jsx(SizableText, { size: "$1", color: "$soft", role: "status", children: note })) : null] }) }));
214
+ }
215
+ /** One item's menu, as a quiet control. */
216
+ function Acts({ x, rows }) {
217
+ return (_jsx(DropdownMenu, { trigger: _jsx(XStack, { render: "button", "aria-label": `Actions for ${x.name}`, width: 30, height: 30, items: "center", justify: "center", rounded: "$2", hoverStyle: { bg: '$hover' }, cursor: "pointer", shrink: 0, children: _jsx(MoreHorizontal, { size: 16 }) }), items: rows }));
218
+ }
219
+ /** One tile of the grid: the still, then its name and where it came from; its menu beside the name. */
220
+ function Tile({ x, onOpen, rows }) {
221
+ return (_jsxs(YStack, { role: "listitem", gap: "$2", minW: 0, children: [_jsx(YStack, { render: "button", "aria-label": `Open ${x.name}`, onPress: onOpen, rounded: "$4", overflow: "hidden", cursor: "pointer", hoverStyle: { opacity: 0.88 }, focusVisibleStyle: { outlineWidth: 2, outlineStyle: 'solid', outlineColor: '$outlineColor' }, children: _jsx(Thumb, { x: x }) }), _jsxs(XStack, { items: "flex-start", gap: "$1", minW: 0, children: [_jsxs(YStack, { flex: 1, minW: 0, gap: "$0.5", children: [_jsx(SizableText, { size: "$2", color: "$ink", numberOfLines: 1, children: x.name }), _jsx(SizableText, { size: "$1", color: "$soft", numberOfLines: 1, children: meta(x) })] }), _jsx(Acts, { x: x, rows: rows })] })] }));
222
+ }
223
+ /** One row of the list: a small still, its name, where it came from, how large, and its menu. */
224
+ function Row({ x, onOpen, rows }) {
225
+ return (_jsxs(XStack, { role: "listitem", items: "center", gap: "$3", px: "$2", py: "$2", rounded: "$3", hoverStyle: { bg: '$hover' }, minW: 0, children: [_jsxs(XStack, { render: "button", "aria-label": `Open ${x.name}`, onPress: onOpen, flex: 1, minW: 0, items: "center", gap: "$3", cursor: "pointer", style: { textAlign: 'left' }, children: [_jsx(Thumb, { x: x, size: 40 }), _jsxs(YStack, { flex: 1, minW: 0, gap: "$0.5", children: [_jsx(SizableText, { size: "$2", color: "$ink", numberOfLines: 1, children: x.name }), _jsx(SizableText, { size: "$1", color: "$soft", numberOfLines: 1, children: meta(x) })] }), _jsx(SizableText, { size: "$1", color: "$soft", width: 72, style: { textAlign: 'right' }, "$max-md": { display: 'none' }, children: size(x.bytes) })] }), _jsx(Acts, { x: x, rows: rows })] }));
226
+ }
227
+ /** A chip: one choice among a few, lit when chosen. */
228
+ function Chip({ label, on, onPress }) {
229
+ return (_jsx(XStack, { render: "button", role: "radio", "aria-checked": on, "aria-label": label, onPress: onPress, height: 32, px: "$3", items: "center", rounded: "$10", shrink: 0, borderWidth: 1, borderColor: on ? '$ink' : '$borderColor', bg: on ? '$ink' : 'transparent', hoverStyle: { bg: on ? '$ink' : '$hover' }, cursor: "pointer", focusVisibleStyle: { outlineWidth: 2, outlineStyle: 'solid', outlineColor: '$outlineColor' }, children: _jsx(SizableText, { size: "$2", color: on ? '$background' : '$ink', children: label }) }));
230
+ }
231
+ /** One item, read fresh so its signed URLs are live: the thing itself, then what it is. */
232
+ export function Preview({ id, rows }) {
233
+ const t = useTarget();
234
+ const host = useHost();
235
+ const one = useRead(host.person ? () => get(t, id) : null, null, [t, id, host.person]);
236
+ const x = one.value;
237
+ if (!x) {
238
+ return (_jsx(SizableText, { size: "$2", color: "$soft", children: !host.person ? 'Sign in to open it.' : one.error ? one.error.message : 'Reading…' }));
239
+ }
240
+ return (_jsxs(YStack, { gap: "$3", minH: 0, children: [_jsx(Shown, { x: x }), _jsxs(XStack, { items: "flex-start", gap: "$2", children: [_jsxs(YStack, { flex: 1, minW: 0, gap: "$1", children: [_jsx(SizableText, { size: "$3", color: "$ink", children: x.name }), _jsx(SizableText, { size: "$1", color: "$soft", children: [meta(x), size(x.bytes), x.width && x.height ? `${x.width}×${x.height}` : '', x.project ? `in ${x.project}` : '', x.shared ? 'shared with the organization' : '']
241
+ .filter(Boolean)
242
+ .join(' · ') })] }), rows ? _jsx(Acts, { x: x, rows: rows(x) }) : null] }), _jsxs(XStack, { gap: "$2", flexWrap: "wrap", rowGap: "$2", children: [x.origin ? (_jsxs(Button, { size: "sm", variant: "outline", onPress: () => host.open(x.origin), children: [_jsx(ExternalLink, { size: 14 }), "Open where it was made"] })) : null, x.download ? (_jsxs(Button, { size: "sm", variant: "outline", onPress: () => window.open(x.download, '_blank', 'noopener,noreferrer'), children: [_jsx(Download, { size: 14 }), "Download"] })) : null] })] }));
243
+ }
244
+ /** The thing itself, drawn the way its kind is read. */
245
+ function Shown({ x }) {
246
+ const box = { width: '100%', maxHeight: '60vh', borderRadius: 12, display: 'block' };
247
+ if (!x.url)
248
+ return _jsx(Thumb, { x: x });
249
+ if (x.kind === 'image' && x.mime !== 'image/svg+xml')
250
+ return _jsx("img", { src: x.url, alt: x.name, style: { ...box, objectFit: 'contain' } });
251
+ if (x.kind === 'video')
252
+ return _jsx("video", { src: x.url, poster: x.preview || undefined, controls: true, preload: "metadata", style: box, "aria-label": x.name });
253
+ if (x.kind === 'audio')
254
+ return _jsx("audio", { src: x.url, controls: true, preload: "metadata", style: { width: '100%' }, "aria-label": x.name });
255
+ if (x.mime === 'application/pdf')
256
+ return _jsx("iframe", { src: x.url, title: x.name, style: { ...box, height: '60vh', border: 0 } });
257
+ if (x.mime.startsWith('text/') || x.kind === 'code' || x.mime === 'application/json')
258
+ return _jsx(Text, { x: x });
259
+ return _jsx(Thumb, { x: x });
260
+ }
261
+ /** A text item's first 200 KB, as written; in a frame where the store does not answer this page's read. */
262
+ function Text({ x }) {
263
+ const read = useRead(async () => {
264
+ const res = await fetch(x.url, { cache: 'no-store' });
265
+ if (!res.ok)
266
+ throw new Error(`The file answered ${res.status}`);
267
+ return (await res.text()).slice(0, 200_000);
268
+ }, '', [x.url]);
269
+ if (read.error)
270
+ return _jsx("iframe", { src: x.url, title: x.name, style: { width: '100%', height: '60vh', border: 0, borderRadius: 12, background: 'white' } });
271
+ return (_jsx(YStack, { rounded: "$3", bg: "$raised", p: "$3", maxH: "60vh", overflow: "scroll", children: _jsx(SizableText, { size: "$1", color: "$ink", style: { whiteSpace: 'pre-wrap', fontFamily: 'var(--f-mono, ui-monospace, monospace)' }, children: read.loading && !read.value ? 'Reading…' : read.value }) }));
272
+ }
273
+ /** A run's outputs as small tiles: each opens in the Library. */
274
+ export function Outputs({ ids }) {
275
+ const host = useHost();
276
+ const t = useTarget();
277
+ const key = ids.join(',');
278
+ const got = useRead(host.person && ids.length ? () => Promise.all(ids.slice(0, 12).map((id) => get(t, id).catch(() => null))) : null, [], [t, key, host.person]);
279
+ const shown = got.value.filter((x) => x !== null);
280
+ if (!ids.length)
281
+ return null;
282
+ return (_jsxs(XStack, { gap: "$2", flexWrap: "wrap", rowGap: "$2", pt: "$1.5", role: "list", "aria-label": "What it made", children: [shown.map((x) => (_jsx(XStack, { role: "listitem", children: _jsxs(XStack, { render: "button", "aria-label": `Open ${x.name} in the Library`, onPress: () => {
283
+ want(x.id);
284
+ host.go('-/artifacts');
285
+ }, items: "center", gap: "$2", pr: "$2.5", rounded: "$3", borderWidth: 1, borderColor: "$borderColor", overflow: "hidden", hoverStyle: { bg: '$hover' }, cursor: "pointer", maxW: 240, children: [_jsx(Thumb, { x: x, size: 36 }), _jsx(SizableText, { size: "$1", color: "$ink", numberOfLines: 1, children: x.name })] }) }, x.id))), got.loading && !shown.length ? (_jsx(SizableText, { size: "$1", color: "$soft", children: `Reading ${ids.length === 1 ? 'what it made' : `the ${ids.length} things it made`}…` })) : null] }));
286
+ }
287
+ /** A list read page by page: the first on every change of filter, the next on reaching its end. */
288
+ function usePages(load, key) {
289
+ const [rows, setRows] = useState([]);
290
+ const [next, setNext] = useState('');
291
+ const [error, setError] = useState(null);
292
+ const [loading, setLoading] = useState(Boolean(load));
293
+ const busy = useRef(null);
294
+ const [tick, setTick] = useState(0);
295
+ const read = useCallback((cursor) => {
296
+ if (!load)
297
+ return;
298
+ busy.current?.abort();
299
+ const ctl = new AbortController();
300
+ busy.current = ctl;
301
+ setLoading(true);
302
+ load(cursor, ctl.signal)
303
+ .then((page) => {
304
+ if (ctl.signal.aborted)
305
+ return;
306
+ setRows((was) => (cursor ? [...was, ...page.data.filter((x) => !was.some((w) => w.id === x.id))] : page.data));
307
+ setNext(page.next);
308
+ setError(null);
309
+ })
310
+ .catch((e) => {
311
+ if (!ctl.signal.aborted)
312
+ setError(e);
313
+ })
314
+ .finally(() => {
315
+ if (!ctl.signal.aborted)
316
+ setLoading(false);
317
+ });
318
+ }, [load]);
319
+ useEffect(() => {
320
+ setRows([]);
321
+ setNext('');
322
+ read('');
323
+ return () => busy.current?.abort();
324
+ // `key` names the filter; `read` follows it.
325
+ // eslint-disable-next-line react-hooks/exhaustive-deps
326
+ }, [key, tick, Boolean(load)]);
327
+ const more = useCallback(() => {
328
+ if (next && !loading)
329
+ read(next);
330
+ }, [next, loading, read]);
331
+ const patch = (x) => setRows((was) => was.map((w) => (w.id === x.id ? x : w)));
332
+ const drop = (id) => setRows((was) => was.filter((w) => w.id !== id));
333
+ return { rows, next, error, loading, more, patch, drop, reload: () => setTick((n) => n + 1) };
334
+ }
335
+ /** Calls `on` when the element it is set on comes into view. */
336
+ function useEnd(on) {
337
+ const seen = useRef(on);
338
+ seen.current = on;
339
+ const [el, setEl] = useState(null);
340
+ useEffect(() => {
341
+ if (!el || typeof IntersectionObserver === 'undefined')
342
+ return;
343
+ const io = new IntersectionObserver((entries) => entries.some((e) => e.isIntersecting) && seen.current(), { rootMargin: '400px' });
344
+ io.observe(el);
345
+ return () => io.disconnect();
346
+ }, [el]);
347
+ return (node) => setEl(node instanceof Element ? node : null);
348
+ }
349
+ export function Library() {
350
+ const host = useHost();
351
+ const t = useTarget();
352
+ const signed = Boolean(host.person);
353
+ const wide = useMedia().md;
354
+ const [kind, setKind] = useState('');
355
+ const [source, setSource] = useState('');
356
+ const [scope, setScope] = useState('all');
357
+ const [typed, setTyped] = useState('');
358
+ const [q, setQ] = useState('');
359
+ const [view, setView] = useKept('hanzo.build.library.view', 'grid');
360
+ // One org's items are not another's: each org's open tabs are kept apart.
361
+ const side = useDeck(`${DECK}.${host.org ?? ''}`, { tabs: [], at: null });
362
+ const [shown, setShown] = useState(false);
363
+ // A search is sent once the typing settles.
364
+ useEffect(() => {
365
+ const id = setTimeout(() => setQ(typed.trim()), 250);
366
+ return () => clearTimeout(id);
367
+ }, [typed]);
368
+ const load = useMemo(() => (signed ? (cursor, signal) => list(t, { kind, source, scope, q, limit: PAGE, cursor }, { signal }) : null), [signed, t, kind, source, scope, q]);
369
+ const pages = usePages(load, `${kind}|${source}|${scope}|${q}|${t.org}`);
370
+ const end = useEnd(pages.more);
371
+ const look = useCallback((x) => {
372
+ side.open({ id: `${TAB}${x.id}`, kind: `${TAB}${x.id}`, title: x.name });
373
+ setShown(true);
374
+ }, [side]);
375
+ // An item a link named opens once, on arrival.
376
+ const arrived = useRef(false);
377
+ useEffect(() => {
378
+ if (arrived.current || !signed)
379
+ return;
380
+ arrived.current = true;
381
+ const id = named();
382
+ if (id)
383
+ get(t, id).then(look, () => look({ id, name: 'Item' }));
384
+ }, [signed, t, look]);
385
+ const acts = useActs((x) => {
386
+ pages.patch(x);
387
+ if (side.tabs.some((tab) => tab.kind === `${TAB}${x.id}`))
388
+ side.rename(`${TAB}${x.id}`, x.name);
389
+ }, (id) => {
390
+ pages.drop(id);
391
+ side.shut(`${TAB}${id}`);
392
+ });
393
+ const kinds = side.tabs
394
+ .filter((tab) => tab.kind.startsWith(TAB))
395
+ .map((tab) => ({ id: tab.kind, label: tab.title, render: () => _jsx(Preview, { id: tab.kind.slice(TAB.length), rows: acts.rows }, tab.kind) }));
396
+ const panel = (sheet) => _jsx(Panel, { side: side, kinds: kinds, sheet: sheet, onHide: () => setShown(false), label: "Item preview" });
397
+ const open = shown && side.tabs.length > 0;
398
+ const groups = days(pages.rows);
399
+ const narrowed = Boolean(kind || source || q || scope !== 'all');
400
+ return (_jsxs(XStack, { flex: 1, minH: 0, minW: 0, width: "100%", children: [_jsx(YStack, { flex: 1, minH: 0, minW: 0, overflow: "scroll", px: "$6", py: "$6", "$max-md": { px: '$4', py: '$4' }, children: _jsxs(YStack, { width: "100%", maxW: 1120, mx: "auto", gap: "$4", children: [_jsxs(XStack, { items: "center", gap: "$3", children: [_jsx(SizableText, { render: "h1", size: "$7", fontWeight: "600", color: "$ink", flex: 1, children: "Library" }), _jsxs(XStack, { role: "radiogroup", "aria-label": "View", gap: "$1", children: [_jsx(ViewMark, { label: "Grid", on: view === 'grid', onPress: () => setView('grid'), children: _jsx(LayoutGrid, { size: 16 }) }), _jsx(ViewMark, { label: "List", on: view === 'list', onPress: () => setView('list'), children: _jsx(List, { size: 16 }) })] })] }), _jsxs(XStack, { items: "center", gap: "$2", maxW: 480, children: [_jsx(Search, { size: 16, opacity: 0.6 }), _jsx(YStack, { flex: 1, minW: 0, children: _jsx(Input, { value: typed, onChangeText: setTyped, placeholder: "Search your library", "aria-label": "Search your library", disabled: !signed }) })] }), _jsxs(YStack, { gap: "$2", children: [_jsx(XStack, { role: "radiogroup", "aria-label": "Kind", gap: "$2", overflowX: "auto", pb: "$1", "$platform-web": { scrollbarWidth: 'none' }, children: KINDS.map((k) => (_jsx(Chip, { label: k.label, on: kind === k.id, onPress: () => setKind(k.id) }, k.id || 'all'))) }), _jsxs(XStack, { gap: "$2", flexWrap: "wrap", rowGap: "$2", children: [_jsx(ChipSelect, { name: "From", label: FROM.find((f) => f.id === source).label, chosen: FROM.find((f) => f.id === source), items: FROM, onChange: (f) => setSource(f.id), placement: "bottom-start", width: 200 }), _jsx(ChipSelect, { name: "Whose", label: WHOSE.find((w) => w.id === scope).label, chosen: WHOSE.find((w) => w.id === scope), items: WHOSE, onChange: (w) => setScope(w.id), placement: "bottom-start", width: 220 })] })] }), acts.note ? (_jsx(SizableText, { size: "$2", color: "$soft", role: "status", children: acts.note })) : null, !signed ? (_jsx(Said, { says: "Sign in to see your library.", children: _jsx(Button, { size: "sm", onPress: () => host.signIn?.(), children: "Sign in" }) })) : pages.error && !pages.rows.length ? (_jsx(Said, { says: pages.error.message, children: _jsx(Button, { size: "sm", variant: "outline", onPress: pages.reload, children: "Try again" }) })) : pages.loading && !pages.rows.length ? (_jsx(Said, { says: "Reading\u2026" })) : !pages.rows.length ? (_jsx(Said, { says: narrowed ? 'Nothing matches.' : 'Nothing here yet. What you make in Chat, Dev and Studio lands here.' })) : (_jsxs(YStack, { gap: "$5", children: [groups.map((g) => (_jsxs(YStack, { gap: "$3", role: "group", "aria-label": g.label, children: [_jsx(SizableText, { size: "$2", color: "$soft", fontWeight: "500", children: g.label }), view === 'grid' ? (_jsx("div", { role: "list", "aria-label": `Made ${g.label.toLowerCase()}`, style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(min(46%, 168px), 1fr))', gap: 16 }, children: g.rows.map((x) => (_jsx(Tile, { x: x, onOpen: () => look(x), rows: acts.rows(x) }, x.id))) })) : (_jsx(YStack, { role: "list", "aria-label": `Made ${g.label.toLowerCase()}`, gap: "$0.5", children: g.rows.map((x) => (_jsx(Row, { x: x, onOpen: () => look(x), rows: acts.rows(x) }, x.id))) }))] }, g.label))), _jsx(YStack, { ref: end, height: 1 }), pages.loading ? (_jsx(SizableText, { size: "$1", color: "$soft", children: "Reading more\u2026" })) : pages.error ? (_jsx(SizableText, { size: "$1", color: "$soft", role: "status", children: pages.error.message })) : null] })), signed && !q && !kind && (source === '' || source === 'dev') && scope !== 'shared' ? (_jsx(YStack, { pt: "$6", borderTopWidth: 1, borderColor: "$borderColor", children: _jsx(Sites, {}) })) : null] }) }), open && !wide ? panel(true) : null, open && wide ? (_jsx(YStack, { "data-slot": "library-side", ...pane, ml: gap, shrink: 0, minH: 0, width: 440, maxW: "45%", children: panel(false) })) : null, acts.dialogs] }));
401
+ }
402
+ function ViewMark({ label, on, onPress, children }) {
403
+ return (_jsx(XStack, { render: "button", role: "radio", "aria-checked": on, "aria-label": label, onPress: onPress, width: 32, height: 32, items: "center", justify: "center", rounded: "$3", bg: on ? '$hover' : 'transparent', hoverStyle: { bg: '$hover' }, cursor: "pointer", opacity: on ? 1 : 0.6, children: children }));
404
+ }
405
+ function Said({ says, children }) {
406
+ return (_jsxs(YStack, { rounded: "$4", borderWidth: 1, borderColor: "$borderColor", px: "$4", py: "$10", gap: "$3", items: "center", children: [_jsx(SizableText, { size: "$2", color: "$soft", style: { textAlign: 'center' }, children: says }), children] }));
407
+ }
package/lib/nav.js CHANGED
@@ -9,7 +9,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
9
9
  * Every row moves the builder through `go` with the address `path` writes, so
10
10
  * where a place lives is said here and in route.ts and nowhere else.
11
11
  */
12
- import { Blocks, BookOpen, CircleDot, Container, Cpu, FolderGit2, LayoutTemplate, SlidersHorizontal, SquarePen, Workflow } from '@hanzogui/lucide-icons-2';
12
+ import { BookOpen, CircleDot, Clock, Container, Cpu, FolderGit2, LayoutTemplate, Library, SlidersHorizontal, SquarePen } from '@hanzogui/lucide-icons-2';
13
13
  import { pinBoard } from './choice.js';
14
14
  import { DOCS } from './foot.js';
15
15
  import { path, route } from './route.js';
@@ -50,9 +50,9 @@ export function nav(host, go) {
50
50
  id: 'make',
51
51
  label: 'Make',
52
52
  links: [
53
- { id: 'artifacts', label: 'Artifacts', icon: _jsx(Blocks, { size: 16 }), onPress: to(path({ kind: 'screen', screen: 'artifacts' })), active: screen === 'artifacts' },
53
+ { id: 'artifacts', label: 'Library', icon: _jsx(Library, { size: 16 }), onPress: to(path({ kind: 'screen', screen: 'artifacts' })), active: screen === 'artifacts' },
54
54
  { id: 'templates', label: 'Templates', icon: _jsx(LayoutTemplate, { size: 16 }), onPress: to(path({ kind: 'screen', screen: 'templates' })), active: screen === 'templates' },
55
- { id: 'automations', label: 'Automations', icon: _jsx(Workflow, { size: 16 }), onPress: to(path({ kind: 'screen', screen: 'automations' })), active: screen === 'automations' || r.kind === 'automation' },
55
+ { id: 'automations', label: 'Scheduled tasks', icon: _jsx(Clock, { size: 16 }), onPress: to(path({ kind: 'screen', screen: 'automations' })), active: screen === 'automations' || r.kind === 'automation' },
56
56
  ],
57
57
  },
58
58
  {
package/lib/panel.d.ts CHANGED
@@ -63,6 +63,8 @@ export interface PageProps extends Acts {
63
63
  onForward?: () => void;
64
64
  /** The title the page gave itself. */
65
65
  onTitle?: (title: string) => void;
66
+ /** Pin the tab, or let it go. */
67
+ onPin?: (on: boolean) => void;
66
68
  suggested?: readonly Suggestion[];
67
69
  }
68
70
  /**
@@ -73,7 +75,7 @@ export interface PageProps extends Acts {
73
75
  * an answer wrote runs without `allow-same-origin`, never with our storage. A
74
76
  * remote page takes its own origin, and withholding same-origin breaks it.
75
77
  */
76
- export declare function Page({ tab, onGo, onBack, onForward, onTitle, suggested, ...acts }: PageProps): import("react").JSX.Element;
78
+ export declare function Page({ tab, onGo, onBack, onForward, onTitle, onPin, suggested, ...acts }: PageProps): import("react").JSX.Element;
77
79
  export interface PanelProps extends Acts {
78
80
  side: Deck;
79
81
  kinds: readonly Kind[];
package/lib/panel.js CHANGED
@@ -21,7 +21,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
21
21
  * the panel is a sheet over the page instead (`sheet`).
22
22
  */
23
23
  import { SizableText, XStack, YStack } from '@hanzo/gui';
24
- import { ArrowLeft, ArrowRight, ChevronRight, Columns2, ExternalLink, Globe, Maximize2, Minus, MoreHorizontal, Plus, RotateCcw, RotateCw, SquarePlus, X } from '@hanzogui/lucide-icons-2';
24
+ import { ArrowLeft, ArrowRight, ChevronRight, Columns2, ExternalLink, Globe, Maximize2, Minus, MoreHorizontal, Pin, Plus, RotateCcw, RotateCw, SquarePlus, X } from '@hanzogui/lucide-icons-2';
25
25
  import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, } from '@hanzo/ui';
26
26
  import { useEffect, useMemo, useRef, useState } from 'react';
27
27
  import { createPortal } from 'react-dom';
@@ -163,7 +163,7 @@ function Skills({ skills, here }) {
163
163
  * page itself for a frame it may reach — and one that needs a page is dim on
164
164
  * an empty tab.
165
165
  */
166
- function Actions({ tab, here, frame, framed, same, level, onLevel, acts, browser, }) {
166
+ function Actions({ tab, here, frame, framed, same, level, onLevel, acts, browser, onPin, }) {
167
167
  const shown = here ? { id: tab.id, ...here } : null;
168
168
  const none = !shown;
169
169
  const pick = () => {
@@ -197,6 +197,7 @@ function Actions({ tab, here, frame, framed, same, level, onLevel, acts, browser
197
197
  acts.skills?.length ? _jsx(Skills, { skills: acts.skills, here: here }, "skills") : null,
198
198
  _jsx(Row, { label: "Open in a new window", disabled: none, onSelect: () => popOut(tab) }, "out"),
199
199
  _jsx(Row, { label: "Copy link", disabled: none || !tab.url, onSelect: () => tab.url && void navigator.clipboard?.writeText(tab.url) }, "copy"),
200
+ onPin ? _jsx(Row, { label: tab.pinned ? 'Unpin tab' : 'Pin tab', onSelect: () => onPin(!tab.pinned) }, "pin") : null,
200
201
  ].filter(Boolean);
201
202
  const page = [
202
203
  browser?.find ? _jsx(Row, { label: "Find in page", disabled: none, onSelect: () => shown && browser.find?.(shown) }, "find") : null,
@@ -223,7 +224,7 @@ function Actions({ tab, here, frame, framed, same, level, onLevel, acts, browser
223
224
  * an answer wrote runs without `allow-same-origin`, never with our storage. A
224
225
  * remote page takes its own origin, and withholding same-origin breaks it.
225
226
  */
226
- export function Page({ tab, onGo, onBack, onForward, onTitle, suggested = [], ...acts }) {
227
+ export function Page({ tab, onGo, onBack, onForward, onTitle, onPin, suggested = [], ...acts }) {
227
228
  const Browse = useBrowse();
228
229
  const browser = useBrowser();
229
230
  const frame = useRef(null);
@@ -288,10 +289,10 @@ export function Page({ tab, onGo, onBack, onForward, onTitle, suggested = [], ..
288
289
  fontSize: 14,
289
290
  // The resting bar reads centred, as a browser's new tab does; typed in, it reads from the left.
290
291
  textAlign: typing || typed ? 'left' : 'center',
291
- } }, tab.id) }), _jsx(Actions, { tab: tab, here: here, frame: frame, framed: framed, same: same, level: level, onLevel: zoomTo, acts: acts, browser: browser })] }), !src ? (_jsx(Suggested, { suggested: suggested, onGo: onGo })) : native && Browse ? (_jsx(YStack, { flex: 1, minH: 0, children: _jsx(Browse, { url: src, title: tab.title, onGo: onGo, onTitle: onTitle }) }, round)) : elsewhere ? (_jsx(Elsewhere, { tab: tab })) : picture && !own ? (_jsx(YStack, { flex: 1, minH: 0, items: "center", justify: "center", p: "$3", overflowY: "auto", overflowX: "hidden", children: _jsx("img", { src: src, alt: tab.title, style: { maxWidth: '100%', maxHeight: '100%', objectFit: 'contain', ...(scale ? { transform: scale.transform } : null) } }, round) })) : shows ? (_jsx(YStack, { flex: 1, minH: 0, overflow: "hidden", position: "relative", children: _jsx("iframe", { ref: frame, src: src, title: tab.title, sandbox: local ? 'allow-scripts allow-popups allow-forms' : 'allow-same-origin allow-scripts allow-popups allow-forms allow-modals', style: { position: 'absolute', top: 0, left: 0, border: 0, width: '100%', height: '100%', display: 'block', background: 'var(--background)', ...scale } }, round) })) : (_jsxs(YStack, { flex: 1, items: "center", justify: "center", p: "$5", gap: "$2", children: [_jsx(SizableText, { size: "$2", color: "$soft", children: `${(ext || type || 'This').toUpperCase()} previews aren’t supported yet.` }), _jsx(SizableText, { size: "$2", color: "$soft", children: "Open it in a new window to view it." })] }))] }));
292
+ } }, tab.id) }), _jsx(Actions, { tab: tab, here: here, frame: frame, framed: framed, same: same, level: level, onLevel: zoomTo, acts: acts, browser: browser, onPin: onPin })] }), !src ? (_jsx(Suggested, { suggested: suggested, onGo: onGo })) : native && Browse ? (_jsx(YStack, { flex: 1, minH: 0, children: _jsx(Browse, { url: src, title: tab.title, onGo: onGo, onTitle: onTitle }) }, round)) : elsewhere ? (_jsx(Elsewhere, { tab: tab })) : picture && !own ? (_jsx(YStack, { flex: 1, minH: 0, items: "center", justify: "center", p: "$3", overflowY: "auto", overflowX: "hidden", children: _jsx("img", { src: src, alt: tab.title, style: { maxWidth: '100%', maxHeight: '100%', objectFit: 'contain', ...(scale ? { transform: scale.transform } : null) } }, round) })) : shows ? (_jsx(YStack, { flex: 1, minH: 0, overflow: "hidden", position: "relative", children: _jsx("iframe", { ref: frame, src: src, title: tab.title, sandbox: local ? 'allow-scripts allow-popups allow-forms' : 'allow-same-origin allow-scripts allow-popups allow-forms allow-modals', style: { position: 'absolute', top: 0, left: 0, border: 0, width: '100%', height: '100%', display: 'block', background: 'var(--background)', ...scale } }, round) })) : (_jsxs(YStack, { flex: 1, items: "center", justify: "center", p: "$5", gap: "$2", children: [_jsx(SizableText, { size: "$2", color: "$soft", children: `${(ext || type || 'This').toUpperCase()} previews aren’t supported yet.` }), _jsx(SizableText, { size: "$2", color: "$soft", children: "Open it in a new window to view it." })] }))] }));
292
293
  }
293
294
  const DRAG = 'text/x-hanzo-tab';
294
- /** One tab in the strip: its mark, its title, and its cross. */
295
+ /** One tab in the strip: its mark, its title, and its cross, or its pin when it is pinned. */
295
296
  function Leaf({ tab, on, side, index, label, icon }) {
296
297
  return (_jsxs(XStack, { "data-slot": "side-tab", "data-tab": tab.id, items: "center", gap: "$1", pl: "$2.5", pr: "$1", height: 30,
297
298
  // As wide as a title wants up to a browser tab's width, sharing the strip
@@ -299,7 +300,7 @@ function Leaf({ tab, on, side, index, label, icon }) {
299
300
  flexBasis: 236, shrink: 1, minW: 56, maxW: 236, rounded: 8, borderWidth: 1, borderColor: on ? '$borderColor' : 'transparent', bg: on ? '$edge' : 'transparent', hoverStyle: { bg: on ? '$edge' : '$hover' }, ...{
300
301
  // A middle press shuts any tab, as a browser's does; the chosen one also has its cross.
301
302
  onAuxClick: (e) => {
302
- if (e.button !== 1)
303
+ if (e.button !== 1 || tab.pinned)
303
304
  return;
304
305
  e.preventDefault();
305
306
  side.shut(tab.id);
@@ -337,13 +338,13 @@ function Leaf({ tab, on, side, index, label, icon }) {
337
338
  queueMicrotask(() => document.getElementById(`side-tab-${next.id}`)?.focus());
338
339
  return;
339
340
  }
340
- if (e.key === 'Delete' || e.key === 'Backspace') {
341
+ if ((e.key === 'Delete' || e.key === 'Backspace') && !tab.pinned) {
341
342
  e.preventDefault?.();
342
343
  side.shut(tab.id);
343
344
  }
344
- }, flex: 1, minW: 0, items: "center", gap: "$2", cursor: "pointer", children: [icon ? (_jsx(XStack, { "aria-hidden": true, shrink: 0, opacity: on ? 1 : 0.7, children: icon })) : null, _jsx(SizableText, { fontSize: 13, color: on ? '$ink' : '$soft', numberOfLines: 1, children: label })] }), _jsx(XStack, { render: "button", role: "button", "aria-label": `Close ${label}`, tabIndex: -1, onPress: () => side.shut(tab.id), width: 22, height: 22, items: "center", justify: "center", rounded: 6, cursor: "pointer", opacity: on ? 0.8 : 0.45, hoverStyle: { bg: '$hover', opacity: 1 },
345
+ }, flex: 1, minW: 0, items: "center", gap: "$2", cursor: "pointer", children: [icon ? (_jsx(XStack, { "aria-hidden": true, shrink: 0, opacity: on ? 1 : 0.7, children: icon })) : null, _jsx(SizableText, { fontSize: 13, color: on ? '$ink' : '$soft', numberOfLines: 1, children: label })] }), tab.pinned ? (_jsx(XStack, { render: "button", role: "button", "aria-label": `Unpin ${label}`, tabIndex: -1, onPress: () => side.unpin(tab.id), width: 22, height: 22, items: "center", justify: "center", rounded: 6, cursor: "pointer", opacity: on ? 0.8 : 0.55, hoverStyle: { bg: '$hover', opacity: 1 }, children: _jsx(Pin, { size: 12 }) })) : (_jsx(XStack, { render: "button", role: "button", "aria-label": `Close ${label}`, tabIndex: -1, onPress: () => side.shut(tab.id), width: 22, height: 22, items: "center", justify: "center", rounded: 6, cursor: "pointer", opacity: on ? 0.8 : 0.45, hoverStyle: { bg: '$hover', opacity: 1 },
345
346
  // The chosen tab's cross: the others keep their room for their titles when the strip is shared.
346
- display: on ? 'flex' : 'none', children: _jsx(X, { size: 12 }) })] }));
347
+ display: on ? 'flex' : 'none', children: _jsx(X, { size: 12 }) }))] }));
347
348
  }
348
349
  /** The panel: the strip, and the chosen tab. */
349
350
  export function Panel({ side, kinds, onHide, sheet = false, bar, menu, label = 'Side panel', suggested, ...acts }) {
@@ -366,7 +367,7 @@ export function Panel({ side, kinds, onHide, sheet = false, bar, menu, label = '
366
367
  { key: PAGE, label: 'Page', description: 'An address, framed here', onSelect: blank },
367
368
  ] })) : (plus), _jsx(XStack, { flex: 1 }), menu, _jsx(XStack, { render: "button", role: "button", "aria-label": "Open this tab in a new window", "aria-disabled": !poppable || undefined, onPress: poppable ? () => popOut(tab) : undefined, width: 28, height: 28, ...quiet(false, !poppable), children: _jsx(Maximize2, { size: 14 }) }), _jsx(XStack, { render: "button", role: "button", "data-slot": "side-toggle", "aria-label": "Hide tabs", "aria-expanded": true, "aria-keyshortcuts": SHORTCUTS, ...{ title: `Hide tabs (${KEYS})` }, onPress: onHide, width: 32, height: 32, ...quiet(true), children: _jsx(Columns2, { size: 16 }) })] }), bar, _jsx(YStack, { id: "side-body", role: "tabpanel", "aria-labelledby": tab ? `side-tab-${tab.id}` : undefined,
368
369
  // What a tab lays over itself (a door's "Connecting…") stays inside it, under the strip.
369
- position: "relative", flex: 1, minH: 0, overflowY: bleed ? 'hidden' : 'auto', overflowX: "hidden", px: bleed ? 0 : '$3', py: bleed ? 0 : '$3', children: !tab ? (_jsx(YStack, { flex: 1, items: "center", justify: "center", p: "$5", children: _jsx(SizableText, { fontSize: 13, color: "$soft", children: "No tabs open." }) })) : tab.kind === PAGE ? (_jsx(Page, { tab: tab, onGo: (url) => side.go(tab.id, url), onBack: () => side.back(tab.id), onForward: () => side.forward(tab.id), onTitle: (t) => side.rename(tab.id, t), suggested: suggested, ...acts }, tab.id)) : kind ? (kind.render()) : (_jsx(SizableText, { size: "$2", color: "$soft", children: "This tab is not offered here." })) })] }));
370
+ position: "relative", flex: 1, minH: 0, overflowY: bleed ? 'hidden' : 'auto', overflowX: "hidden", px: bleed ? 0 : '$3', py: bleed ? 0 : '$3', children: !tab ? (_jsx(YStack, { flex: 1, items: "center", justify: "center", p: "$5", children: _jsx(SizableText, { fontSize: 13, color: "$soft", children: "No tabs open." }) })) : tab.kind === PAGE ? (_jsx(Page, { tab: tab, onGo: (url) => side.go(tab.id, url), onBack: () => side.back(tab.id), onForward: () => side.forward(tab.id), onTitle: (t) => side.rename(tab.id, t), onPin: (on) => (on ? side.pin(tab.id) : side.unpin(tab.id)), suggested: suggested, ...acts }, tab.id)) : kind ? (kind.render()) : (_jsx(SizableText, { size: "$2", color: "$soft", children: "This tab is not offered here." })) })] }));
370
371
  if (!sheet || typeof document === 'undefined')
371
372
  return body;
372
373
  // A phone: a sheet from the foot of the window over a dim, the same panel in it.
package/lib/run.js CHANGED
@@ -24,7 +24,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
24
24
  * the run. Share opens its story to the public build route.
25
25
  */
26
26
  import { SizableText, useMedia, XStack, YStack } from '@hanzo/gui';
27
- import { ChevronDown, ChevronRight, Copy, ExternalLink, GitPullRequest, Globe, Pencil, Share } from '@hanzogui/lucide-icons-2';
27
+ import { ChevronDown, ChevronRight, Clock, Copy, ExternalLink, GitPullRequest, Globe, Pencil, Share } from '@hanzogui/lucide-icons-2';
28
28
  import { Button, Dialog, DialogContent, DialogTitle, DropdownMenu, Input } from '@hanzo/ui';
29
29
  import { Steer } from '@hanzo/ui/agents';
30
30
  import { Failure } from '@hanzo/ui/chat';
@@ -47,6 +47,7 @@ import { measure, Prompt, useEar } from './prompt.js';
47
47
  import { useSpoken } from './voice.js';
48
48
  import { speaker } from './reply.js';
49
49
  import { PanelToggle } from './panel.js';
50
+ import { schedule } from './scheduled.js';
50
51
  import { PAGE, useDeck, useKey, usePanel } from './tabs.js';
51
52
  import { compose, Files, pick } from './tools.js';
52
53
  import { Transcript } from './transcript.js';
@@ -324,6 +325,21 @@ export function Run({ id }) {
324
325
  ]
325
326
  : []),
326
327
  { key: 'copy', label: copied === 'id' ? 'Copied' : 'Copy run id', icon: _jsx(Copy, { size: 16 }), description: id, onSelect: () => void copy('id', id) },
328
+ // What this run was asked, as a task that runs again on a schedule: the ask it opened with.
329
+ ...(signed && record
330
+ ? [
331
+ {
332
+ key: 'schedule',
333
+ label: 'Schedule this',
334
+ icon: _jsx(Clock, { size: 16 }),
335
+ description: 'Run it again on a schedule',
336
+ onSelect: () => {
337
+ const first = shown.find((c) => c.kind === 'said' && c.who === 'person');
338
+ schedule(first && 'text' in first ? first.text : headline(record.title, record.repo), host.go);
339
+ },
340
+ },
341
+ ]
342
+ : []),
327
343
  ];
328
344
  // Sharing opens the public build route, which is addressed by the org and the project a run built.
329
345
  const link = record?.published ? story(t, record.org || host.org, record.project) : '';
@@ -0,0 +1,3 @@
1
+ /** Open Scheduled with a new task started from `prompt`: Dev's "Schedule this". */
2
+ export declare function schedule(prompt: string, go: (path: string) => void): void;
3
+ export declare function Scheduled(): import("react").JSX.Element;