@hanzo/rooms 0.1.68 → 0.1.70

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/dist/App.js CHANGED
@@ -1,7 +1,9 @@
1
1
  'use client';
2
2
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- // The web app as the whole window, in two modes: Chat, a conversation with the
4
- // product's AI, and Dev, @hanzo/build's `<Builder>` without a rail of its own.
3
+ // The web app as the whole window, in three modes: Chat, a conversation with
4
+ // the product's AI; Dev, @hanzo/build's `<Builder>` without a rail of its own;
5
+ // and Studio, @hanzo/studio's `<Studio>` the same way, its places (`nav`) in the
6
+ // sidebar where Dev's stand for Dev.
5
7
  //
6
8
  // OUTER the rail (Rail.tsx) and the sidebar beside it (Column.tsx), on the
7
9
  // window's floor, with the window's bar above them where the host has
@@ -14,11 +16,12 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
14
16
  // search, New, Dev's places and the reader's Recents. The address says the
15
17
  // mode, as a path (lib/host `talk`, `app`, `place`): `/` and `/chat` are Chat on
16
18
  // a new conversation, `/chat/<id>` one of the reader's, and a path under `/dev`
17
- // is Dev: `/dev/run/<id>` a run, `/dev/projects/<org>/<repo>` a project. Moving
19
+ // is Dev: `/dev/run/<id>` a run, `/dev/projects/<org>/<repo>` a project; a path
20
+ // under `/studio` is Studio, its own address after it (`c/<id>`, `library`). Moving
18
21
  // between the modes is a history entry (`go`), never a load, so a host serves
19
22
  // this one component for every path under each.
20
23
  //
21
- // Every host draws this: hanzo.ai at `/`, `/chat` and `/dev`, and the desktop
24
+ // Every host draws this: hanzo.ai at `/`, `/chat`, `/dev` and `/studio`, and the desktop
22
25
  // app as its main window. The host mounts IamProvider and `<Rooms>` with its
23
26
  // router and the path, and draws this only for a signed-in reader; what a
24
27
  // stranger sees is the host's.
@@ -46,13 +49,19 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
46
49
  // EVERY MOVE PUTS THE DRAWER AWAY, the one to where the reader already is
47
50
  // included.
48
51
  //
52
+ // STUDIO LOADS WITH ITS MODE. Its module (and three.js behind a 3D tab) is
53
+ // fetched the first time the reader stands in Studio; its places are a few
54
+ // lines and come with the app. Another organization opens it at its New, so
55
+ // nothing it read under the last one stays.
56
+ //
49
57
  // THE PLANS' OWN ADDRESS LEAVES for the pay site in its place (dev.tsx `leave`),
50
58
  // so the builder's own plans are never drawn.
51
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
59
+ import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
52
60
  import { ArrowLeft, ArrowRight, Menu } from 'lucide-react';
53
61
  import { AiProvider } from '@hanzo/ai/react';
54
62
  import { Text, XStack, YStack } from '@hanzo/gui';
55
63
  import { Builder, HostProvider, path as written, route } from '@hanzo/build';
64
+ import { nav as places } from '@hanzo/studio';
56
65
  import { SidebarIconButton } from '@hanzo/ui/chat';
57
66
  import { useAi } from './lib/ai.js';
58
67
  import { Chat } from './Chat.js';
@@ -66,8 +75,10 @@ import { empty, openThread, useOpen } from './open.js';
66
75
  import { Paned } from './Shell.js';
67
76
  import { floor, pane } from './ground.js';
68
77
  import { pick } from './lib/session.js';
69
- import { app, go, place, talk } from './lib/host.js';
78
+ import { app, go, place, studio, talk } from './lib/host.js';
70
79
  import { wordmark } from './where.js';
80
+ /** Studio, fetched the first time the reader stands in it. */
81
+ const Studio = lazy(() => import('@hanzo/studio').then((m) => ({ default: m.Studio })));
71
82
  /** `/` is Chat on a new conversation. */
72
83
  const HOME = { mode: 'chat', thread: null };
73
84
  /** The bar's height, px: the reference window's title bar. */
@@ -85,7 +96,7 @@ const INSET = {
85
96
  left: 'max(var(--pane-gap), var(--safe-left))',
86
97
  };
87
98
  /** Where each mode was last, so the rail goes back to it. */
88
- const last = { chat: '', dev: '' };
99
+ const last = { chat: '', dev: '', studio: '' };
89
100
  /** Whether a key press is the reader typing, where the app's keys are the field's. */
90
101
  const typing = (e) => {
91
102
  const el = e.target;
@@ -119,6 +130,22 @@ function Window({ bar }) {
119
130
  const at = place(here) ?? HOME;
120
131
  const mode = at.mode;
121
132
  const where = at.mode === 'dev' ? at.at : null;
133
+ // Studio's host: Dev's, at Studio's address, its moves under /studio, and
134
+ // another organization opening Studio's New in it.
135
+ const made = at.mode === 'studio' ? at.at : '';
136
+ const maker = useMemo(() => ({
137
+ ...host,
138
+ name: 'Hanzo Studio',
139
+ path: made,
140
+ go: (to, how) => {
141
+ shut();
142
+ go(studio(to), how?.replace);
143
+ },
144
+ chooseOrg: (next) => {
145
+ pick(next);
146
+ window.location.assign(studio(''));
147
+ },
148
+ }), [host, made, shut]);
122
149
  // A run floats two panes of its own, so the pane it stands in is not cut.
123
150
  const run = where !== null && route(where).kind === 'run';
124
151
  const asked = at.mode === 'chat' ? at.thread : null;
@@ -202,11 +229,13 @@ function Window({ bar }) {
202
229
  }, [bar, router, move]);
203
230
  const toChat = () => move(last.chat || talk());
204
231
  const toDev = () => move(last.dev || app(''));
232
+ const toStudio = () => move(last.studio || studio(''));
205
233
  // Another organization: picked, and the app opened in it at the mode's New.
206
234
  const org = (o) => {
207
235
  pick(o);
208
- window.location.assign(mode === 'dev' ? app('') : talk());
236
+ window.location.assign(mode === 'dev' ? app('') : mode === 'studio' ? studio('') : talk());
209
237
  };
238
+ const sidebar = mode === 'studio' ? places(maker, (p) => maker.go(p)) : undefined;
210
239
  const flip = () => setClosed(!closed);
211
- return (_jsx(AiProvider, { client: client ?? undefined, children: _jsx(HostProvider, { host: host, children: _jsxs(YStack, { "data-slot": "window", ...(bar ? floor : null), flex: 1, minW: 0, height: "100dvh", overflow: "hidden", children: [bar ? (_jsxs(XStack, { "data-slot": "title-bar", height: BAR, shrink: 0, items: "center", gap: 2, pl: (bar.inset ?? 0) + 6, pr: "$2", ...bar.drag, children: [_jsx(XStack, { "$max-md": { display: 'none' }, children: _jsx(Toggle, { open: !closed, onPress: flip }) }), _jsx(XStack, { $md: { display: 'none' }, children: _jsx(Toggle, { open: drawer, onPress: () => setDrawer(true), label: "Open sidebar" }) }), _jsx(Mark, { label: "Back", "data-slot": "back", onPress: () => router.back(), children: _jsx(ArrowLeft, { size: 18, "aria-hidden": true }) }), _jsx(Mark, { label: "Forward", "data-slot": "forward", onPress: () => router.forward(), children: _jsx(ArrowRight, { size: 18, "aria-hidden": true }) }), _jsx(XStack, { flex: 1, self: "stretch", ...bar.drag })] })) : null, _jsxs(XStack, { "data-room": "", "data-app": mode, "data-slot": "frame", ...(bar ? null : floor), flex: 1, minW: 0, minH: 0, pr: "var(--pane-gap)", pb: "var(--pane-gap)", pt: bar ? 0 : 'var(--pane-gap)', "$max-md": { p: 0 }, children: [_jsx(YStack, { height: "100%", "$max-md": { display: 'none' }, children: _jsx(Rail, { host: host, mode: mode, at: where, open: !closed, toggle: bar ? undefined : flip, onChat: toChat, onDev: toDev, go: move, onOrg: org }) }), closed ? null : (_jsx(YStack, { "$max-md": { display: 'none' }, children: _jsx(Column, { host: host, mode: mode, thread: thread, go: move, onShut: () => setClosed(true), seek: seek }) })), _jsxs(YStack, { "data-slot": "stage", flex: 1, minW: 0, minH: 0, ml: "var(--pane-gap)", "$max-md": { ml: 0 }, children: [bar ? null : (_jsxs(XStack, { "data-slot": "app-bar", height: 44, px: "$1", gap: "$1", items: "center", shrink: 0, $md: { display: 'none' }, children: [_jsx(SidebarIconButton, { label: "Open sidebar", onPress: () => setDrawer(true), width: 44, height: 44, rounded: 999, children: _jsx(Menu, { size: 18 }) }), _jsx(Text, { fontSize: "$4", fontWeight: "600", color: "$ink", numberOfLines: 1, children: wordmark(mode) })] })), _jsx(XStack, { "data-slot": "panes", flex: 1, minW: 0, minH: 0, children: mode === 'chat' ? (_jsx(Paned, { framed: true, children: _jsx(Chat, {}) })) : (_jsx(YStack, { render: "main", "data-slot": "pane", ...(run ? null : { ...pane(), overflow: 'hidden' }), flex: 1, minW: 0, minH: 0, children: plans ? null : _jsx(Builder, { host: host, rail: false }) })) })] })] }), drawer ? (_jsxs(_Fragment, { children: [_jsx(YStack, { render: "button", "aria-label": "Close sidebar", onPress: () => setDrawer(false), bg: DIM, position: "absolute", t: 0, r: 0, b: 0, l: 0, z: "var(--z-overlay)", $md: { display: 'none' } }), _jsx(YStack, { "data-slot": "drawer", position: "absolute", t: INSET.top, b: INSET.bottom, l: INSET.left, width: "min(320px, calc(100% - 2 * var(--pane-gap) - 48px))", z: "var(--z-modal)", $md: { display: 'none' }, children: _jsx(Column, { host: host, mode: mode, thread: thread, go: move, onShut: shut, seek: seek, drawer: true, foot: _jsx(Rail, { host: host, mode: mode, at: where, open: true, flat: true, onChat: toChat, onDev: toDev, go: move, onOrg: org }) }) })] })) : null] }) }) }));
240
+ return (_jsx(AiProvider, { client: client ?? undefined, children: _jsx(HostProvider, { host: host, children: _jsxs(YStack, { "data-slot": "window", ...(bar ? floor : null), flex: 1, minW: 0, height: "100dvh", overflow: "hidden", children: [bar ? (_jsxs(XStack, { "data-slot": "title-bar", height: BAR, shrink: 0, items: "center", gap: 2, pl: (bar.inset ?? 0) + 6, pr: "$2", ...bar.drag, children: [_jsx(XStack, { "$max-md": { display: 'none' }, children: _jsx(Toggle, { open: !closed, onPress: flip }) }), _jsx(XStack, { $md: { display: 'none' }, children: _jsx(Toggle, { open: drawer, onPress: () => setDrawer(true), label: "Open sidebar" }) }), _jsx(Mark, { label: "Back", "data-slot": "back", onPress: () => router.back(), children: _jsx(ArrowLeft, { size: 18, "aria-hidden": true }) }), _jsx(Mark, { label: "Forward", "data-slot": "forward", onPress: () => router.forward(), children: _jsx(ArrowRight, { size: 18, "aria-hidden": true }) }), _jsx(XStack, { flex: 1, self: "stretch", ...bar.drag })] })) : null, _jsxs(XStack, { "data-room": "", "data-app": mode, "data-slot": "frame", ...(bar ? null : floor), flex: 1, minW: 0, minH: 0, pr: "var(--pane-gap)", pb: "var(--pane-gap)", pt: bar ? 0 : 'var(--pane-gap)', "$max-md": { p: 0 }, children: [_jsx(YStack, { height: "100%", "$max-md": { display: 'none' }, children: _jsx(Rail, { host: host, mode: mode, at: where, open: !closed, toggle: bar ? undefined : flip, onChat: toChat, onDev: toDev, onStudio: toStudio, go: move, onOrg: org }) }), closed ? null : (_jsx(YStack, { "$max-md": { display: 'none' }, children: _jsx(Column, { host: host, mode: mode, thread: thread, go: move, onShut: () => setClosed(true), seek: seek, places: sidebar }) })), _jsxs(YStack, { "data-slot": "stage", flex: 1, minW: 0, minH: 0, ml: "var(--pane-gap)", "$max-md": { ml: 0 }, children: [bar ? null : (_jsxs(XStack, { "data-slot": "app-bar", height: 44, px: "$1", gap: "$1", items: "center", shrink: 0, $md: { display: 'none' }, children: [_jsx(SidebarIconButton, { label: "Open sidebar", onPress: () => setDrawer(true), width: 44, height: 44, rounded: 999, children: _jsx(Menu, { size: 18 }) }), _jsx(Text, { fontSize: "$4", fontWeight: "600", color: "$ink", numberOfLines: 1, children: wordmark(mode) })] })), _jsx(XStack, { "data-slot": "panes", flex: 1, minW: 0, minH: 0, children: mode === 'chat' ? (_jsx(Paned, { framed: true, children: _jsx(Chat, {}) })) : mode === 'studio' ? (_jsx(YStack, { render: "main", "data-slot": "pane", ...pane(), overflow: "hidden", flex: 1, minW: 0, minH: 0, children: _jsx(YStack, { flex: 1, minH: 0, height: "100%", children: _jsx(Suspense, { fallback: null, children: _jsx(Studio, { host: maker, rail: false }) }) }) })) : (_jsx(YStack, { render: "main", "data-slot": "pane", ...(run ? null : { ...pane(), overflow: 'hidden' }), flex: 1, minW: 0, minH: 0, children: plans ? null : _jsx(Builder, { host: host, rail: false }) })) })] })] }), drawer ? (_jsxs(_Fragment, { children: [_jsx(YStack, { render: "button", "aria-label": "Close sidebar", onPress: () => setDrawer(false), bg: DIM, position: "absolute", t: 0, r: 0, b: 0, l: 0, z: "var(--z-overlay)", $md: { display: 'none' } }), _jsx(YStack, { "data-slot": "drawer", position: "absolute", t: INSET.top, b: INSET.bottom, l: INSET.left, width: "min(320px, calc(100% - 2 * var(--pane-gap) - 48px))", z: "var(--z-modal)", $md: { display: 'none' }, children: _jsx(Column, { host: host, mode: mode, thread: thread, go: move, onShut: shut, seek: seek, drawer: true, places: sidebar, foot: _jsx(Rail, { host: host, mode: mode, at: where, open: true, flat: true, onChat: toChat, onDev: toDev, onStudio: toStudio, go: move, onOrg: org }) }) })] })) : null] }) }) }));
212
241
  }
package/dist/Chat.js CHANGED
@@ -54,13 +54,15 @@ import { useHydrated } from "./lib/hydrated.js";
54
54
  import { base, served } from "./lib/ai.js";
55
55
  import { onServed } from "./lib/served.js";
56
56
  import { refused as worded, speech, useDictation, useTalk, useVoice } from "@hanzo/voice";
57
- import { FileText, Image as ImageMark, Paperclip, X } from "lucide-react";
57
+ import { Check, Clock, FileText, Image as ImageMark, Library as LibraryMark, Paperclip, X } from "lucide-react";
58
58
  import { FREE } from "./lib/ai.js";
59
59
  import { openThread, useOpen } from "./open.js";
60
60
  import { Beside, Framed } from "./Shell.js";
61
61
  import { SEED, look, useSections, useSites } from "./RightPane.js";
62
62
  import { Share } from "./Share.js";
63
63
  import { ConversationMenu, ThreadMore, useThreadList } from "./thread.js";
64
+ import { Schedule } from "./Schedule.js";
65
+ import { keepFile, useAttach, useKeep } from "./keep.js";
64
66
  import { artifacts, lead, nameOf, PAGE, Panel, PanelToggle, prompt, renders, Reply, Served, Summary, useDeck, useKey, useMind, usePanel, frames, name as tabName, } from "@hanzo/build";
65
67
  import { checkoutUrl } from './lib/pay.js';
66
68
  import { Upgrade, useFree } from './Upgrade.js';
@@ -415,7 +417,7 @@ const THUMB = 48;
415
417
  *
416
418
  * Pressing one opens it beside the conversation (`onOpen`).
417
419
  */
418
- function Chip({ name, type, size, state, sent, file, picture, onOpen, onRemove, }) {
420
+ function Chip({ name, type, size, state, sent, file, picture, onOpen, onRemove, onSave, }) {
419
421
  const kind = kindOf(name, type);
420
422
  const bad = state === "failed";
421
423
  const why = state === "failed" || state === "stored" ? file?.error : file?.note;
@@ -423,7 +425,7 @@ function Chip({ name, type, size, state, sent, file, picture, onOpen, onRemove,
423
425
  const [broken, setBroken] = useState("");
424
426
  const word = state === "ready" ? (file?.clipped ? "in part" : "") : progress(state, sent, file);
425
427
  const meta = [kind === "pdf" ? "PDF" : "", size > 0 ? weigh(size) : "", word].filter(Boolean).join(" \u00b7 ");
426
- const remove = onRemove ? (_jsx(Box, { render: "button", "aria-label": `Remove ${name}`, onClick: onRemove, p: "$1", rounded: "$10", shrink: 0, hoverStyle: { bg: "$hover" }, children: _jsx(X, { size: 12, "aria-hidden": true }) })) : null;
428
+ const remove = (_jsxs(_Fragment, { children: [onSave ? _jsx(Keep, { name: name, onSave: onSave }) : null, onRemove ? (_jsx(Box, { render: "button", "aria-label": `Remove ${name}`, onClick: onRemove, p: "$1", rounded: "$10", shrink: 0, hoverStyle: { bg: "$hover" }, children: _jsx(X, { size: 12, "aria-hidden": true }) })) : null] }));
427
429
  const marks = {
428
430
  role: "listitem",
429
431
  "data-slot": "file-chip",
@@ -438,26 +440,57 @@ function Chip({ name, type, size, state, sent, file, picture, onOpen, onRemove,
438
440
  const Glyph = kind === "pdf" ? FileText : ImageMark;
439
441
  return (_jsxs(XStack, { ...marks, items: "center", gap: "$1", p: "$1", pr: onRemove ? "$1" : "$2.5", rounded: "$4", borderWidth: 1, borderColor: "$borderColor", maxW: 280, children: [_jsxs(XStack, { render: "button", "aria-label": `Open ${name}`, onClick: onOpen, items: "center", gap: "$2", minW: 0, cursor: "pointer", children: [_jsx(YStack, { "data-slot": "file-thumb", width: THUMB, height: THUMB, shrink: 0, rounded: "$3", overflow: "hidden", items: "center", justify: "center", gap: "$0.5", borderWidth: drawn ? 0 : 1, borderColor: "$borderColor", children: drawn ? (_jsx(View, { render: _jsx("img", { src: picture, alt: "", loading: "lazy", decoding: "async", onError: () => setBroken(picture) }), width: "100%", height: "100%", objectFit: "cover", display: "block" })) : (_jsxs(_Fragment, { children: [_jsx(Glyph, { size: 18, "aria-hidden": true, color: "var(--muted-foreground)" }), kind === "pdf" ? (_jsx(Text, { fontSize: 9, fontWeight: "600", letterSpacing: 0.5, color: "$soft", children: "PDF" })) : null] })) }), _jsxs(YStack, { minW: 0, gap: "$0.5", children: [_jsx(Text, { fontSize: "$2", color: "$ink", numberOfLines: 1, text: "left", children: name }), meta ? (_jsx(Text, { fontSize: "$1", color: bad ? BAD : "$soft", numberOfLines: 1, text: "left", children: meta })) : null] })] }), remove] }));
440
442
  }
443
+ /**
444
+ * "Save to Library" on a file the reader put into the conversation: pressed, it
445
+ * keeps the file in their Library and says so; a refusal is its title.
446
+ */
447
+ function Keep({ name, onSave }) {
448
+ const [state, setState] = useState("");
449
+ const [wrong, setWrong] = useState("");
450
+ const label = state === "saved" ? `${name} is in your Library` : wrong || `Save ${name} to Library`;
451
+ return (_jsx(Box, { render: "button", "aria-label": label, ...{ title: label }, "data-slot": "keep", "data-state": state || (wrong ? "failed" : ""), "aria-disabled": state !== "", onClick: () => {
452
+ if (state)
453
+ return;
454
+ setState("saving");
455
+ setWrong("");
456
+ onSave().then(() => setState("saved"), (e) => {
457
+ setState("");
458
+ setWrong(e?.message || `${name} was not saved.`);
459
+ });
460
+ }, p: "$1", rounded: "$10", shrink: 0, opacity: state === "saving" ? 0.5 : 1, hoverStyle: { bg: "$hover" }, children: state === "saved" ? _jsx(Check, { size: 12, "aria-hidden": true }) : _jsx(LibraryMark, { size: 12, "aria-hidden": true, color: wrong ? BAD : undefined }) }));
461
+ }
441
462
  /** A file held for the next message: uploading, then indexing, then ready. */
442
- function HeldChip({ one, onOpen, onRemove }) {
463
+ function HeldChip({ one, onOpen, onRemove, onSave }) {
443
464
  const live = useWorkFile(one.file?.id);
444
465
  const file = live ?? one.file;
445
466
  const state = one.state === "uploading" ? one.state : (file?.status ?? one.state);
446
467
  const picture = usePicture(kindOf(one.name, one.type) === "image", one.href, file?.id, file);
447
468
  // The mark the column's Sources row opens for the same file, so both open it alike.
448
469
  const mark = { id: one.id, name: one.name, kind: kindOf(one.name, one.type) === "image" ? "image" : "file", href: one.href || undefined, file: file?.id, mime: one.type };
449
- return (_jsx(Chip, { name: one.name, type: one.type, size: one.size, state: state, sent: one.sent, file: file ?? (one.error ? { error: one.error } : undefined), picture: picture, onOpen: () => onOpen(mark), onRemove: onRemove }));
470
+ return (_jsx(Chip, { name: one.name, type: one.type, size: one.size, state: state, sent: one.sent, file: file ?? (one.error ? { error: one.error } : undefined), picture: picture, onOpen: () => onOpen(mark), onRemove: onRemove,
471
+ // Its bytes are on this machine until the upload lands, and are kept by the workspace file it became.
472
+ onSave: onSave && one.href ? () => onSave(one.href, file?.id ?? one.id) : undefined }));
450
473
  }
451
474
  /** A file a sent turn carried, as the index says of it now. */
452
- function TurnChip({ f, onOpen }) {
475
+ function TurnChip({ f, onOpen, onSave }) {
476
+ const api = useAi();
453
477
  const live = useWorkFile(f.id);
454
478
  const picture = usePicture(kindOf(f.name, f.type) === "image", "", f.id, live);
455
479
  const mark = { id: f.id, name: f.name, kind: kindOf(f.name, f.type) === "image" ? "image" : "file", file: f.id, mime: f.type };
456
- return _jsx(Chip, { name: f.name, type: f.type, size: f.size, state: live?.status ?? "ready", sent: 1, file: live, picture: picture, onOpen: () => onOpen(mark) });
480
+ // The bytes on this machine where this document still holds them, else the workspace's copy.
481
+ const save = onSave ? async () => onSave(localOf(f.id) || (await signedOf(api, f.id, live)), f.id) : undefined;
482
+ return _jsx(Chip, { name: f.name, type: f.type, size: f.size, state: live?.status ?? "ready", sent: 1, file: live, picture: picture, onOpen: () => onOpen(mark), onSave: save });
457
483
  }
458
484
  /** A question that carried files: its words, the files put into it, and those read again. */
459
- function Carried({ text, attached, reused, onOpen }) {
460
- return (_jsxs(YStack, { gap: "$2", "data-slot": "turn-files", children: [text ? _jsx(Prose, { text: text }) : null, attached.length ? (_jsx(XStack, { gap: "$1.5", flexWrap: "wrap", role: "list", "aria-label": "Files in this message", children: attached.map((f) => (_jsx(TurnChip, { f: f, onOpen: onOpen }, f.id))) })) : null, reused.length ? (_jsxs(Text, { fontSize: "$1", color: "$soft", children: ["Read again: ", reused.map((f) => `${f.name} (${weigh(f.size)})`).join(", ")] })) : null] }));
485
+ function Carried({ text, attached, reused, onOpen, onSave, }) {
486
+ return (_jsxs(YStack, { gap: "$2", "data-slot": "turn-files", children: [text ? _jsx(Prose, { text: text }) : null, attached.length ? (_jsx(XStack, { gap: "$1.5", flexWrap: "wrap", role: "list", "aria-label": "Files in this message", children: attached.map((f) => (_jsx(TurnChip, { f: f, onOpen: onOpen, onSave: onSave ? (url, id) => onSave(url, id, f) : undefined }, f.id))) })) : null, reused.length ? (_jsxs(Text, { fontSize: "$1", color: "$soft", children: ["Read again: ", reused.map((f) => `${f.name} (${weigh(f.size)})`).join(", ")] })) : null] }));
487
+ }
488
+ /**
489
+ * "Schedule this" under a question: the quiet control that opens the dialog
490
+ * which makes the question run itself (Schedule.tsx).
491
+ */
492
+ function Plan({ onPress }) {
493
+ return (_jsx(XStack, { justify: "flex-end", "data-slot": "plan", children: _jsxs(XStack, { render: "button", "aria-label": "Schedule this", onPress: onPress, items: "center", gap: "$1", px: "$1", height: 22, rounded: "$2", cursor: "pointer", opacity: 0.55, hoverStyle: { opacity: 1, bg: "$hover" }, focusStyle: { opacity: 1 }, children: [_jsx(Clock, { size: 12, "aria-hidden": true }), _jsx(Text, { fontSize: "$1", color: "$ink", children: "Schedule this" })] }) }));
461
494
  }
462
495
  /**
463
496
  * The conversation. One component for a visitor and for an account.
@@ -827,6 +860,12 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
827
860
  // read from it for the question.
828
861
  const { held: files, refused: turnedAway } = usePane(at);
829
862
  const via = useMemo(() => ({ api: ai, org: anonymous ? null : org() }), [ai, anonymous]);
863
+ // "Use in Chat" from the Library: `?attach=<item>` holds it for the next message.
864
+ useAttach(at, via);
865
+ // "Schedule this": the prompt the dialog opens on, or null while it is shut.
866
+ const [plan, setPlan] = useState(null);
867
+ // A file the reader keeps from this conversation goes to their Library under it.
868
+ const keepOne = useCallback((url, id, name, type) => keepFile(ai, { id, name, type, url, thread: kept ?? open }), [ai, kept, open]);
830
869
  // What the send is waiting on — uploads landing, the files being read — said
831
870
  // over the field, and the reason a second press does nothing meanwhile.
832
871
  const [preparing, setPreparing] = useState(null);
@@ -885,6 +924,16 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
885
924
  streaming.current = chat.streaming;
886
925
  const turns = useRef(chat.messages);
887
926
  turns.current = chat.messages;
927
+ // The conversation's last question as its person wrote it: what the header's
928
+ // Schedule… opens on.
929
+ const lastAsked = () => {
930
+ const q = [...turns.current].reverse().find((m) => m.role === "user");
931
+ if (!q)
932
+ return "";
933
+ if (typeof q.content === "string")
934
+ return told(q.content).text;
935
+ return Array.isArray(q.content) ? partsOf(q.content).flatMap((p) => (p.type === "text" ? [p.text] : [])).join("\n") : "";
936
+ };
888
937
  /**
889
938
  * WHAT EACH UNANSWERED QUESTION WAS TOLD, by the question's own turn id: the
890
939
  * refusal it met, or that its answer came back empty, or that it streamed
@@ -1020,6 +1069,10 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
1020
1069
  const a = lead(artifacts(text));
1021
1070
  return a ? () => see(a) : undefined;
1022
1071
  };
1072
+ // WHAT AN ANSWER MAKES IS KEPT IN THE LIBRARY as it finishes (keep.ts): the
1073
+ // answer that just stopped, once the conversation it belongs to has an id.
1074
+ const [finished, setFinished] = useState(null);
1075
+ useKeep(ai, !anonymous, kept ?? open, finished, artifacts);
1023
1076
  // WHAT AN ANSWER MAKES OPENS BESIDE IT. With the panel open, a page or a
1024
1077
  // picture an answer just finished writing is opened as a tab; with it shut,
1025
1078
  // the answer's own Open does it.
@@ -1027,9 +1080,14 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
1027
1080
  useEffect(() => {
1028
1081
  const was = writing.current;
1029
1082
  writing.current = chat.streaming;
1030
- if (!was || chat.streaming || !panel)
1083
+ if (!was || chat.streaming)
1031
1084
  return;
1032
- const answer = [...turns.current].reverse().find((m) => m.role === "assistant");
1085
+ const done = [...turns.current].reverse().find((m) => m.role === "assistant");
1086
+ if (done && typeof done.content === "string" && done.content)
1087
+ setFinished({ id: String(done.id), text: done.content });
1088
+ if (!panel)
1089
+ return;
1090
+ const answer = done;
1033
1091
  const made = typeof answer?.content === "string" ? artifacts(answer.content).filter(renders) : [];
1034
1092
  if (made.length)
1035
1093
  see(made[made.length - 1]);
@@ -1275,7 +1333,10 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
1275
1333
  e.preventDefault();
1276
1334
  // A dropped folder arrives as entries; each file under it goes up with its path.
1277
1335
  void dropped(e.dataTransfer).then((list) => hold(at, list, via));
1278
- }, children: [dragging ? (_jsx(YStack, { "aria-hidden": true, position: "absolute", t: 0, r: 0, b: 0, l: 0, z: 10, items: "center", justify: "center", bg: "var(--surface-scrim)", borderWidth: 2, borderStyle: "dashed", borderColor: "$borderColor", rounded: "var(--pane-round)", pointerEvents: "none", children: _jsxs(XStack, { items: "center", gap: "$2", px: "$4", py: "$2.5", rounded: "$10", bg: "$background", borderWidth: 1, borderColor: "$borderColor", children: [_jsx(Paperclip, { size: 15, "aria-hidden": true }), _jsx(Text, { fontSize: "$4", color: "$ink", children: "Drop files to attach" })] }) })) : null, _jsxs(XStack, { "data-slot": "conversation-head", height: 52, items: "center", justify: "space-between", gap: "$3", pl: "$4", pr: "$2", shrink: 0, children: [_jsxs(XStack, { items: "center", gap: "$2", minW: 0, flex: 1, children: [mine ? (_jsx(Text, { fontSize: 15, lineHeight: 20, color: "$ink", numberOfLines: 1, shrink: 1, minW: 0, children: mine.title || "Untitled" })) : null, room.length ? (_jsx(XStack, { role: "list", "aria-label": "Agents in this chat", items: "center", gap: "$1.5", minW: 0, overflowX: "auto", "$platform-web": { scrollbarWidth: "none" }, children: room.map((name) => (_jsxs(XStack, { role: "listitem", items: "center", gap: "$1.5", pl: "$1", pr: "$0.5", py: "$0.5", rounded: 999, borderWidth: 1, borderColor: "$borderColor", shrink: 0, children: [_jsx(Face, { src: faces[name]?.avatar ?? (name === withWhom ? portrait : undefined), emoji: faces[name]?.emoji ?? (name === withWhom ? persona?.emoji : undefined), name: name, size: 22 }), _jsx(Text, { fontSize: "$2", fontWeight: "600", color: "$color", numberOfLines: 1, display: "none", $sm: { display: "flex" }, children: name }), _jsx(Box, { render: "button", onClick: () => openAgent(name), "aria-label": `Remove ${name} from this chat`, title: `Remove ${name}`, width: 24, height: 24, rounded: 999, items: "center", justify: "center", hoverStyle: { bg: "$hover" }, children: _jsx(X, { size: 13, "aria-hidden": true, color: "var(--soft, var(--muted-foreground))" }) })] }, name))) })) : null] }), _jsxs(XStack, { "data-slot": "conversation-controls", items: "center", gap: 2, shrink: 0, children: [open ? _jsx(Share, { thread: open }) : null, mine ? (_jsx(ConversationMenu, { threads: alone, active: open, children: _jsx(XStack, { children: _jsx(ThreadMore, { t: mine }) }) })) : null, _jsx(Summary, { sections: sections, onOpen: () => setPanel(true) }), _jsx(PanelToggle, { open: panel, onToggle: () => setPanel(!panel) })] })] }), wide && aside !== null ? (_jsx(Beside, { children: _jsx(Panel, { side: deck, kinds: NONE, onHide: () => setPanel(false), label: "Tabs", ...browsing }) })) : null, !wide && panel ? _jsx(Panel, { side: deck, kinds: NONE, sheet: true, onHide: () => setPanel(false), label: "Tabs", ...browsing }) : null, isFree(model) ? (_jsxs(Text, { fontSize: "$2", color: "$soft", text: "center", maxW: MEASURE, mx: "auto", px: "$4", py: "$2", children: [freeCopy.notice, " ", _jsx(Anchor, { href: site("/legal/privacy"), children: freeCopy.noticeCta })] })) : null, _jsx(ChatSurface, { ...chat, messages: shown, send: guarded,
1336
+ }, children: [_jsx(Schedule, { open: plan !== null, onOpenChange: (open) => {
1337
+ if (!open)
1338
+ setPlan(null);
1339
+ }, prompt: plan ?? "", model: model, models: models.map((m) => ({ id: m.id, name: nameOf(m.id, listed) })) }), dragging ? (_jsx(YStack, { "aria-hidden": true, position: "absolute", t: 0, r: 0, b: 0, l: 0, z: 10, items: "center", justify: "center", bg: "var(--surface-scrim)", borderWidth: 2, borderStyle: "dashed", borderColor: "$borderColor", rounded: "var(--pane-round)", pointerEvents: "none", children: _jsxs(XStack, { items: "center", gap: "$2", px: "$4", py: "$2.5", rounded: "$10", bg: "$background", borderWidth: 1, borderColor: "$borderColor", children: [_jsx(Paperclip, { size: 15, "aria-hidden": true }), _jsx(Text, { fontSize: "$4", color: "$ink", children: "Drop files to attach" })] }) })) : null, _jsxs(XStack, { "data-slot": "conversation-head", height: 52, items: "center", justify: "space-between", gap: "$3", pl: "$4", pr: "$2", shrink: 0, children: [_jsxs(XStack, { items: "center", gap: "$2", minW: 0, flex: 1, children: [mine ? (_jsx(Text, { fontSize: 15, lineHeight: 20, color: "$ink", numberOfLines: 1, shrink: 1, minW: 0, children: mine.title || "Untitled" })) : null, room.length ? (_jsx(XStack, { role: "list", "aria-label": "Agents in this chat", items: "center", gap: "$1.5", minW: 0, overflowX: "auto", "$platform-web": { scrollbarWidth: "none" }, children: room.map((name) => (_jsxs(XStack, { role: "listitem", items: "center", gap: "$1.5", pl: "$1", pr: "$0.5", py: "$0.5", rounded: 999, borderWidth: 1, borderColor: "$borderColor", shrink: 0, children: [_jsx(Face, { src: faces[name]?.avatar ?? (name === withWhom ? portrait : undefined), emoji: faces[name]?.emoji ?? (name === withWhom ? persona?.emoji : undefined), name: name, size: 22 }), _jsx(Text, { fontSize: "$2", fontWeight: "600", color: "$color", numberOfLines: 1, display: "none", $sm: { display: "flex" }, children: name }), _jsx(Box, { render: "button", onClick: () => openAgent(name), "aria-label": `Remove ${name} from this chat`, title: `Remove ${name}`, width: 24, height: 24, rounded: 999, items: "center", justify: "center", hoverStyle: { bg: "$hover" }, children: _jsx(X, { size: 13, "aria-hidden": true, color: "var(--soft, var(--muted-foreground))" }) })] }, name))) })) : null] }), _jsxs(XStack, { "data-slot": "conversation-controls", items: "center", gap: 2, shrink: 0, children: [open ? _jsx(Share, { thread: open }) : null, mine ? (_jsx(ConversationMenu, { threads: alone, active: open, onSchedule: anonymous ? undefined : () => setPlan(lastAsked()), children: _jsx(XStack, { children: _jsx(ThreadMore, { t: mine }) }) })) : null, _jsx(Summary, { sections: sections, onOpen: () => setPanel(true) }), _jsx(PanelToggle, { open: panel, onToggle: () => setPanel(!panel) })] })] }), wide && aside !== null ? (_jsx(Beside, { children: _jsx(Panel, { side: deck, kinds: NONE, onHide: () => setPanel(false), label: "Tabs", ...browsing }) })) : null, !wide && panel ? _jsx(Panel, { side: deck, kinds: NONE, sheet: true, onHide: () => setPanel(false), label: "Tabs", ...browsing }) : null, isFree(model) ? (_jsxs(Text, { fontSize: "$2", color: "$soft", text: "center", maxW: MEASURE, mx: "auto", px: "$4", py: "$2", children: [freeCopy.notice, " ", _jsx(Anchor, { href: site("/legal/privacy"), children: freeCopy.noticeCta })] })) : null, _jsx(ChatSurface, { ...chat, messages: shown, send: guarded,
1279
1340
  // AN EMPTY ROOM CENTRES ITS COMPOSER. While nothing has been said the
1280
1341
  // opening (the heading and the starters) and the composer sit as one
1281
1342
  // group in the middle of the height, with or without the plan bars in
@@ -1294,15 +1355,18 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
1294
1355
  return question ? failure(question) : null;
1295
1356
  }
1296
1357
  // A QUESTION THAT CARRIED PICTURES draws them beside its words.
1297
- if (turn.role === "user" && Array.isArray(turn.content))
1298
- return _jsx(Parts, { parts: partsOf(turn.content), prose: (text) => _jsx(Prose, { text: text }) });
1358
+ if (turn.role === "user" && Array.isArray(turn.content)) {
1359
+ const parts = partsOf(turn.content);
1360
+ const asked = parts.flatMap((p) => (p.type === "text" ? [p.text] : [])).join("\n");
1361
+ return (_jsxs(YStack, { gap: "$1", children: [_jsx(Parts, { parts: parts, prose: (text) => _jsx(Prose, { text: text }) }), !anonymous && asked ? _jsx(Plan, { onPress: () => setPlan(asked) }) : null] }));
1362
+ }
1299
1363
  const said = typeof turn.content === "string" ? turn.content : "";
1300
1364
  // A QUESTION THAT CARRIED FILES draws its words and the files' chips;
1301
1365
  // the passages it handed the model are the model's to read, not ours.
1302
1366
  if (turn.role === "user" && said) {
1303
1367
  const read = told(said);
1304
1368
  if (read.carried)
1305
- return _jsx(Carried, { text: read.text, attached: read.carried.attached, reused: read.carried.reused, onOpen: openFile });
1369
+ return (_jsxs(YStack, { gap: "$1", children: [_jsx(Carried, { text: read.text, attached: read.carried.attached, reused: read.carried.reused, onOpen: openFile, onSave: anonymous ? undefined : (url, id, f) => keepOne(url, id, f.name, f.type) }), !anonymous && read.text ? _jsx(Plan, { onPress: () => setPlan(read.text) }) : null] }));
1306
1370
  }
1307
1371
  // WHILE THE ANSWER HAS NO WORDS YET: who is answering (the agents the
1308
1372
  // question addresses, or the whole room when it names nobody), and the
@@ -1329,7 +1393,7 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
1329
1393
  : [];
1330
1394
  return (_jsxs(YStack, { gap: "$3", children: [parts.map((part, i) => (_jsxs(YStack, { gap: "$1", children: [part.who ? (_jsxs(XStack, { items: "center", gap: "$2", children: [_jsx(Face, { src: faces[part.who]?.avatar, emoji: faces[part.who]?.emoji, name: part.who, size: 20 }), _jsx(Text, { fontSize: "$2", fontWeight: "600", color: "$ink", children: part.who })] })) : null, _jsx(Prose, { text: part.text }), part.text ? _jsx(Reply, { text: part.text, listen: listen(part.who ?? withWhom ?? undefined), onOpen: opener(part.text) }) : null] }, i))), silent.length ? (_jsxs(Text, { fontSize: "$2", color: "$soft", children: ["No answer from ", join(silent), " in this reply."] })) : null, byline(turn.id)] }));
1331
1395
  }
1332
- return (_jsxs(YStack, { gap: "$1", children: [_jsx(Prose, { text: said }), turn.role === "assistant" && said ? _jsx(Reply, { text: said, listen: listen(withWhom ?? undefined), onOpen: opener(said) }) : null, turn.role === "assistant" && said ? byline(turn.id) : null] }));
1396
+ return (_jsxs(YStack, { gap: "$1", children: [_jsx(Prose, { text: said }), turn.role === "assistant" && said ? _jsx(Reply, { text: said, listen: listen(withWhom ?? undefined), onOpen: opener(said) }) : null, turn.role === "assistant" && said ? byline(turn.id) : null, turn.role === "user" && said && !anonymous ? _jsx(Plan, { onPress: () => setPlan(said) }) : null] }));
1333
1397
  },
1334
1398
  // The page's ONE heading, and it belongs to the empty transcript rather
1335
1399
  // than to a signed-out branch — the branch is gone, and the heading went
@@ -1370,7 +1434,7 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
1370
1434
  },
1371
1435
  // WHAT GOES WITH THE MESSAGE, above the frame and at its width: the
1372
1436
  // pause and its ways past, one chip a file, and talk mode's words.
1373
- head: files.length || turnedAway || preparing || talk.open || talk.refusal || pause ? (_jsxs(_Fragment, { children: [banner ? (_jsx(LimitedBanner, { message: banner.message, actions: banner.actions, onAction: act, onUsage: () => showSettings("usage") })) : null, actWrong ? (_jsx(Text, { role: "alert", "data-slot": "limited-banner-wrong", fontSize: "$2", color: BAD, children: actWrong })) : null, files.length ? (_jsx(XStack, { gap: "$1.5", flexWrap: "wrap", role: "list", "aria-label": "Attached files", "data-slot": "composer-attached", children: files.map((one) => (_jsx(HeldChip, { one: one, onOpen: openFile, onRemove: () => drop(at, one.id) }, one.id))) })) : null, preparing ? (_jsxs(Text, { role: "status", "aria-live": "polite", "data-slot": "composer-preparing", fontSize: "$2", color: "$soft", children: [preparing, "\u2026"] })) : null, turnedAway ? (_jsx(Text, { role: "alert", fontSize: "$2", color: BAD, children: turnedAway })) : null, talk.open || talk.refusal ? (_jsx(Text, { role: "status", "aria-live": "polite", "data-slot": "composer-talk-reply", fontSize: "$2", color: talk.refusal ? BAD : "$soft", children: talk.refusal
1437
+ head: files.length || turnedAway || preparing || talk.open || talk.refusal || pause ? (_jsxs(_Fragment, { children: [banner ? (_jsx(LimitedBanner, { message: banner.message, actions: banner.actions, onAction: act, onUsage: () => showSettings("usage") })) : null, actWrong ? (_jsx(Text, { role: "alert", "data-slot": "limited-banner-wrong", fontSize: "$2", color: BAD, children: actWrong })) : null, files.length ? (_jsx(XStack, { gap: "$1.5", flexWrap: "wrap", role: "list", "aria-label": "Attached files", "data-slot": "composer-attached", children: files.map((one) => (_jsx(HeldChip, { one: one, onOpen: openFile, onRemove: () => drop(at, one.id), onSave: anonymous ? undefined : (url, id) => keepOne(url, id, one.name, one.type) }, one.id))) })) : null, preparing ? (_jsxs(Text, { role: "status", "aria-live": "polite", "data-slot": "composer-preparing", fontSize: "$2", color: "$soft", children: [preparing, "\u2026"] })) : null, turnedAway ? (_jsx(Text, { role: "alert", fontSize: "$2", color: BAD, children: turnedAway })) : null, talk.open || talk.refusal ? (_jsx(Text, { role: "status", "aria-live": "polite", "data-slot": "composer-talk-reply", fontSize: "$2", color: talk.refusal ? BAD : "$soft", children: talk.refusal
1374
1438
  ? worded(talk.refusal)
1375
1439
  : talk.reply
1376
1440
  ? `Hanzo: ${talk.reply}`
package/dist/Column.d.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  import { type Host } from '@hanzo/build';
2
+ import { type RailLink } from '@hanzo/ui/chat';
2
3
  import { type ReactNode } from 'react';
3
- import { type Mode } from './recent.js';
4
- export declare function Column({ host, mode, thread, go, onShut, seek, drawer, foot, }: {
4
+ import { type Place } from './lib/host.js';
5
+ export declare function Column({ host, mode, thread, go, onShut, seek, drawer, foot, places, }: {
5
6
  host: Host;
6
- mode: Mode;
7
+ mode: Place['mode'];
7
8
  /** The Chat conversation the pane holds, or null. */
8
9
  thread: string | null;
9
10
  /** Moves the app to an address of this page. */
@@ -16,4 +17,10 @@ export declare function Column({ host, mode, thread, go, onShut, seek, drawer, f
16
17
  drawer?: boolean;
17
18
  /** What stands at its foot: the drawer's rail, laid flat. */
18
19
  foot?: ReactNode;
20
+ /** The mode's places, where they are not Dev's (Studio's `nav`). */
21
+ places?: {
22
+ id: string;
23
+ label: string;
24
+ links: RailLink[];
25
+ }[];
19
26
  }): import("react").JSX.Element;
package/dist/Column.js CHANGED
@@ -89,7 +89,7 @@ function Activity({ host }) {
89
89
  ];
90
90
  return (_jsx(DropdownMenu, { minWidth: 280, trigger: _jsx(XStack, { render: "button", "aria-label": "Activity", ...{ title: 'Activity' }, ...HEAD, items: "center", justify: "center", cursor: "pointer", opacity: 0.7, hoverStyle: { bg: '$hover', opacity: 1 }, focusVisibleStyle: { outlineWidth: 2, outlineStyle: 'solid', outlineColor: '$outlineColor' }, children: _jsx(Bell, { size: 16, "aria-hidden": true }) }), items: items }));
91
91
  }
92
- export function Column({ host, mode, thread, go, onShut, seek = 0, drawer = false, foot, }) {
92
+ export function Column({ host, mode, thread, go, onShut, seek = 0, drawer = false, foot, places, }) {
93
93
  const [words, setWords] = useState('');
94
94
  // The search field shows once asked for, and while it holds words.
95
95
  const [seeking, setSeeking] = useState(false);
@@ -98,7 +98,7 @@ export function Column({ host, mode, thread, go, onShut, seek = 0, drawer = fals
98
98
  const uid = useId();
99
99
  const span = useSpan(SPAN, WIDTH, FLOOR, CEIL);
100
100
  const dev = (p) => host.go(p);
101
- const groups = nav(host, dev);
101
+ const groups = places ?? nav(host, dev);
102
102
  const open = (row) => (row.kind === 'chat' ? go(talk(row.id)) : dev(row.id));
103
103
  // Chat's New; Dev's is the first of its places.
104
104
  const create = () => {
@@ -119,13 +119,13 @@ export function Column({ host, mode, thread, go, onShut, seek = 0, drawer = fals
119
119
  }, [seek]);
120
120
  return (_jsxs(YStack, { "data-slot": "app-column", ref: drawer ? undefined : span.ref,
121
121
  // inline-style: `--span`, the width a drag rewrites on the element itself (span.ts); no prop sets a variable.
122
- style: drawer ? undefined : span.style, position: "relative", flex: drawer ? 1 : undefined, shrink: drawer ? 1 : 0, minW: 0, height: "100%", width: drawer ? undefined : 'var(--span)', children: [_jsxs(Sidebar, { "data-slot": "app-rail", role: "navigation", "aria-label": brand(), ...(drawer ? { ...pane(), ...FLOAT } : { bg: 'transparent', borderWidth: 0 }), ...FILL, px: "$2", pt: drawer ? '$2' : 0, pb: "$2", gap: "$0", children: [_jsxs(XStack, { "data-slot": "app-head", items: "center", gap: "$1", pl: "$2", pr: 2, height: 52, shrink: 0, minW: 0, children: [_jsx(Text, { flex: 1, minW: 0, fontSize: 18, lineHeight: 24, fontWeight: "600", color: "$ink", numberOfLines: 1, children: wordmark(mode) }), _jsx(Activity, { host: host }), _jsx(SidebarIconButton, { label: mode === 'chat' ? 'Search chats' : 'Search runs', onPress: seeking ? done : ask, "aria-expanded": seeking || !!words, ...HEAD, children: _jsx(Search, { size: 16, "aria-hidden": true }) })] }), seeking || words ? (_jsxs(XStack, { "data-slot": "app-filter", items: "center", gap: "$2", pl: "$3", pr: "$1", height: 36, rounded: 999, borderWidth: 1, borderColor: "$borderColor", bg: "$edge", shrink: 0, mb: "$1.5", children: [_jsx(Search, { size: 15, "aria-hidden": true, color: "var(--soft, var(--muted-foreground))" }), _jsx(Text, { render: _jsx("input", { ref: field, value: words, onChange: (e) => setWords(e.target.value), onKeyDown: (e) => {
122
+ style: drawer ? undefined : span.style, position: "relative", flex: drawer ? 1 : undefined, shrink: drawer ? 1 : 0, minW: 0, height: "100%", width: drawer ? undefined : 'var(--span)', children: [_jsxs(Sidebar, { "data-slot": "app-rail", role: "navigation", "aria-label": brand(), ...(drawer ? { ...pane(), ...FLOAT } : { bg: 'transparent', borderWidth: 0 }), ...FILL, px: "$2", pt: drawer ? '$2' : 0, pb: "$2", gap: "$0", children: [_jsxs(XStack, { "data-slot": "app-head", items: "center", gap: "$1", pl: "$2", pr: 2, height: 52, shrink: 0, minW: 0, children: [_jsx(Text, { flex: 1, minW: 0, fontSize: 18, lineHeight: 24, fontWeight: "600", color: "$ink", numberOfLines: 1, children: wordmark(mode) }), _jsx(Activity, { host: host }), mode === 'studio' ? null : (_jsx(SidebarIconButton, { label: mode === 'chat' ? 'Search chats' : 'Search runs', onPress: seeking ? done : ask, "aria-expanded": seeking || !!words, ...HEAD, children: _jsx(Search, { size: 16, "aria-hidden": true }) }))] }), seeking || words ? (_jsxs(XStack, { "data-slot": "app-filter", items: "center", gap: "$2", pl: "$3", pr: "$1", height: 36, rounded: 999, borderWidth: 1, borderColor: "$borderColor", bg: "$edge", shrink: 0, mb: "$1.5", children: [_jsx(Search, { size: 15, "aria-hidden": true, color: "var(--soft, var(--muted-foreground))" }), _jsx(Text, { render: _jsx("input", { ref: field, value: words, onChange: (e) => setWords(e.target.value), onKeyDown: (e) => {
123
123
  if (e.key === 'Escape')
124
124
  done();
125
125
  }, onBlur: () => {
126
126
  if (!words)
127
127
  setSeeking(false);
128
- }, placeholder: mode === 'chat' ? 'Search chats' : 'Search runs', "aria-label": "Filter recents" }), flex: 1, minW: 0, bg: "transparent", borderWidth: 0, outlineStyle: "none", color: "inherit", fontSize: "$3" }), words ? (_jsx(SidebarIconButton, { label: "Clear search", onPress: done, width: 28, height: 28, rounded: 999, children: _jsx(X, { size: 14, "aria-hidden": true }) })) : null] })) : null, mode === 'chat' ? (_jsx(YStack, { role: "list", gap: 2, shrink: 0, children: _jsx(YStack, { role: "listitem", children: _jsx(SidebarItem, { "data-slot": "app-new", ...ROW, active: !thread, bg: "transparent", hoverStyle: { bg: '$hover' }, icon: _jsx(SquarePen, { size: 16, "aria-hidden": true }), onPress: create, ...keys(create), children: _jsx(Text, { fontSize: "$3", color: "$ink", children: "New chat" }) }) }) })) : (_jsx(YStack, { "data-slot": "app-places", shrink: 0, children: groups.map((g, i) => (_jsxs(YStack, { pt: i ? '$2' : 0, children: [i ? (_jsx(Text, { id: `${uid}-${g.id}`, pl: "$2", pb: 2, fontSize: "$3", color: "$soft", children: g.label })) : null, _jsx(YStack, { role: "list", "aria-labelledby": i ? `${uid}-${g.id}` : undefined, "aria-label": i ? undefined : g.label, gap: 1, children: g.links.map((link) => (_jsx(YStack, { role: "listitem", children: _jsx(SidebarItem, { ...ROW, ...(link.id === NEW ? { 'data-slot': 'app-new' } : {}), active: link.active, icon: link.id === NEW ? _jsx(SquarePen, { size: 16, "aria-hidden": true }) : link.icon, onPress: link.onPress, ...keys(() => link.onPress?.()), children: _jsx(Text, { fontSize: "$3", color: link.active ? '$ink' : '$quiet', children: link.label }) }) }, link.id))) })] }, g.id))) })), _jsx(Recents, { host: host, mode: mode, thread: thread, words: words, onOpen: open, onNew: create }), _jsxs(YStack, { gap: "$2", pt: "$2", shrink: 0, children: [_jsx(Made, {}), _jsx(Setup, {})] }), foot] }), drawer ? null : (_jsx(Grip, { side: "left", span: span.span, floor: FLOOR, ceil: CEIL, reset: WIDTH, onSpan: span.move, onKeep: span.keep, onShut: onShut, label: "Resize sidebar",
128
+ }, placeholder: mode === 'chat' ? 'Search chats' : 'Search runs', "aria-label": "Filter recents" }), flex: 1, minW: 0, bg: "transparent", borderWidth: 0, outlineStyle: "none", color: "inherit", fontSize: "$3" }), words ? (_jsx(SidebarIconButton, { label: "Clear search", onPress: done, width: 28, height: 28, rounded: 999, children: _jsx(X, { size: 14, "aria-hidden": true }) })) : null] })) : null, mode === 'chat' ? (_jsx(YStack, { role: "list", gap: 2, shrink: 0, children: _jsx(YStack, { role: "listitem", children: _jsx(SidebarItem, { "data-slot": "app-new", ...ROW, active: !thread, bg: "transparent", hoverStyle: { bg: '$hover' }, icon: _jsx(SquarePen, { size: 16, "aria-hidden": true }), onPress: create, ...keys(create), children: _jsx(Text, { fontSize: "$3", color: "$ink", children: "New chat" }) }) }) })) : (_jsx(YStack, { "data-slot": "app-places", shrink: 0, children: groups.map((g, i) => (_jsxs(YStack, { pt: i ? '$2' : 0, children: [i ? (_jsx(Text, { id: `${uid}-${g.id}`, pl: "$2", pb: 2, fontSize: "$3", color: "$soft", children: g.label })) : null, _jsx(YStack, { role: "list", "aria-labelledby": i ? `${uid}-${g.id}` : undefined, "aria-label": i ? undefined : g.label, gap: 1, children: g.links.map((link) => (_jsx(YStack, { role: "listitem", children: _jsx(SidebarItem, { ...ROW, ...(link.id === NEW ? { 'data-slot': 'app-new' } : {}), active: link.active, icon: link.id === NEW ? _jsx(SquarePen, { size: 16, "aria-hidden": true }) : link.icon, onPress: link.onPress, ...keys(() => link.onPress?.()), children: _jsx(Text, { fontSize: "$3", color: link.active ? '$ink' : '$quiet', children: link.label }) }) }, link.id))) })] }, g.id))) })), mode === 'studio' ? null : _jsx(Recents, { host: host, mode: mode, thread: thread, words: words, onOpen: open, onNew: create }), _jsxs(YStack, { gap: "$2", pt: "$2", shrink: 0, children: [_jsx(Made, {}), _jsx(Setup, {})] }), foot] }), drawer ? null : (_jsx(Grip, { side: "left", span: span.span, floor: FLOOR, ceil: CEIL, reset: WIDTH, onSpan: span.move, onKeep: span.keep, onShut: onShut, label: "Resize sidebar",
129
129
  // In the gutter between the sidebar and the panel, not over the sidebar's own rows.
130
130
  r: GUTTER_END, width: "var(--pane-gap)" }))] }));
131
131
  }
package/dist/Rail.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { ReactNode } from 'react';
2
2
  import { type Host } from '@hanzo/build';
3
- import type { Mode } from './recent.js';
3
+ import { type Place } from './lib/host.js';
4
4
  /** The rail's width, px. */
5
5
  export declare const RAIL = 48;
6
6
  /** One mark: an icon button named for where it goes, its name its tooltip, lit where the reader is. */
@@ -18,9 +18,9 @@ export declare function Toggle({ open, onPress, label }: {
18
18
  onPress: () => void;
19
19
  label?: string;
20
20
  }): import("react").JSX.Element;
21
- export declare function Rail({ host, mode, at, open, toggle, flat, onChat, onDev, go, onOrg, }: {
21
+ export declare function Rail({ host, mode, at, open, toggle, flat, onChat, onDev, onStudio, go, onOrg, }: {
22
22
  host: Host;
23
- mode: Mode;
23
+ mode: Place['mode'];
24
24
  /** Dev's builder address, or null in Chat. */
25
25
  at: string | null;
26
26
  /** The sidebar beside the rail is open. */
@@ -29,9 +29,10 @@ export declare function Rail({ host, mode, at, open, toggle, flat, onChat, onDev
29
29
  toggle?: () => void;
30
30
  /** Laid along the foot of the phone's drawer rather than down the window's edge. */
31
31
  flat?: boolean;
32
- /** To Chat and to Dev, each where the reader left it. */
32
+ /** To Chat, Dev and Studio, each where the reader left it. */
33
33
  onChat: () => void;
34
34
  onDev: () => void;
35
+ onStudio: () => void;
35
36
  /** Moves the app to an address of this page. */
36
37
  go: (address: string) => void;
37
38
  /** Work in another organization. */
package/dist/Rail.js CHANGED
@@ -5,8 +5,8 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
5
5
  // menu of the rest, and the account at the foot. It is how the app moves
6
6
  // between its products; there is no switch anywhere else.
7
7
  //
8
- // Studio is its own page, hanzo.ai/studio, opened in this tab: the app draws it
9
- // there for a signed-in reader.
8
+ // Studio is the app's third mode (@hanzo/studio at /studio), each of the three
9
+ // opened where the reader left it.
10
10
  //
11
11
  // Library, Scheduled tasks and Customize are Dev's own places (@hanzo/build's
12
12
  // Artifacts, Automations and Customize), raised to the rail; each is lit while
@@ -27,8 +27,7 @@ import { XStack, YStack } from '@hanzo/gui';
27
27
  import { route } from '@hanzo/build';
28
28
  import { DropdownMenu } from '@hanzo/ui';
29
29
  import { Me } from './Me.js';
30
- import { app, STUDIO, TABS, WORKSPACE } from './lib/host.js';
31
- import { site } from './where.js';
30
+ import { app, TABS, WORKSPACE } from './lib/host.js';
32
31
  /** The rail's width, px. */
33
32
  export const RAIL = 48;
34
33
  /** A mark, px: a 36px square on a 44px pitch. */
@@ -72,7 +71,7 @@ export function Mark({ label, on = false, onPress, children, ...rest }) {
72
71
  export function Toggle({ open, onPress, label }) {
73
72
  return (_jsx(Mark, { label: label ?? (open ? 'Close sidebar' : 'Open sidebar'), "data-slot": "sidebar-toggle", "aria-expanded": open, onPress: onPress, children: _jsx(PanelLeft, { size: 18, "aria-hidden": true }) }));
74
73
  }
75
- export function Rail({ host, mode, at, open, toggle, flat = false, onChat, onDev, go, onOrg, }) {
74
+ export function Rail({ host, mode, at, open, toggle, flat = false, onChat, onDev, onStudio, go, onOrg, }) {
76
75
  const here = mode === 'dev' ? raised(at) : null;
77
76
  const to = (p) => () => go(app(p));
78
77
  // Everything else the app reaches, behind More: on a phone the raised places first, then Dev's other places, then the products on hosts of their own.
@@ -95,5 +94,5 @@ export function Rail({ host, mode, at, open, toggle, flat = false, onChat, onDev
95
94
  { key: 'team', label: 'Team', icon: _jsx(Users, { size: 16, "aria-hidden": true }), onSelect: () => out(WORKSPACE) },
96
95
  ];
97
96
  const Stack = flat ? XStack : YStack;
98
- return (_jsxs(Stack, { "data-slot": "rail", render: "nav", "aria-label": "Products", ...(flat ? { pt: '$2', mt: '$2', borderTopWidth: 1, borderColor: '$borderColor', gap: '$1' } : { width: RAIL, flex: 1, pt: '$2', pb: '$2', gap: 8 }), shrink: 0, items: "center", children: [toggle ? (_jsx(YStack, { pb: 8, children: _jsx(Toggle, { open: open, onPress: toggle }) })) : null, _jsx(Mark, { label: "Chat", on: mode === 'chat', onPress: onChat, "data-slot": "rail-chat", children: _jsx(MessageSquare, { size: 18, "aria-hidden": true }) }), _jsx(Mark, { label: "Dev", on: mode === 'dev' && !here, onPress: onDev, "data-slot": "rail-dev", children: _jsx(Code2, { size: 18, "aria-hidden": true }) }), _jsx(Mark, { label: "Studio", onPress: () => window.location.assign(site(STUDIO)), "data-slot": "rail-studio", children: _jsx(WandSparkles, { size: 18, "aria-hidden": true }) }), flat ? null : (_jsxs(_Fragment, { children: [_jsx(Mark, { label: "Library", on: here === 'library', onPress: to(RAISED.library), "data-slot": "rail-library", children: _jsx(Library, { size: 18, "aria-hidden": true }) }), _jsx(Mark, { label: "Scheduled tasks", on: here === 'scheduled', onPress: to(RAISED.scheduled), "data-slot": "rail-scheduled", children: _jsx(Clock, { size: 18, "aria-hidden": true }) }), _jsx(Mark, { label: "Customize", on: here === 'customize', onPress: to(RAISED.customize), "data-slot": "rail-customize", children: _jsx(Puzzle, { size: 18, "aria-hidden": true }) })] })), _jsx(DropdownMenu, { placement: flat ? 'top-start' : 'right-start', minWidth: 220, trigger: _jsx(XStack, { render: "button", "aria-label": "More", ...{ title: 'More' }, "data-slot": "rail-more", width: MARK, height: MARK, rounded: "$4", items: "center", justify: "center", cursor: "pointer", opacity: 0.62, hoverStyle: { bg: '$hover', opacity: 1 }, pressStyle: { bg: '$raised' }, focusVisibleStyle: { outlineWidth: 2, outlineStyle: 'solid', outlineColor: '$outlineColor' }, $touchable: { width: 44, height: 44 }, children: _jsx(Ellipsis, { size: 18, "aria-hidden": true }) }), items: rest }), _jsx(YStack, { flex: 1 }), _jsx(Me, { host: host, narrow: true, above: flat, onOrg: onOrg })] }));
97
+ return (_jsxs(Stack, { "data-slot": "rail", render: "nav", "aria-label": "Products", ...(flat ? { pt: '$2', mt: '$2', borderTopWidth: 1, borderColor: '$borderColor', gap: '$1' } : { width: RAIL, flex: 1, pt: '$2', pb: '$2', gap: 8 }), shrink: 0, items: "center", children: [toggle ? (_jsx(YStack, { pb: 8, children: _jsx(Toggle, { open: open, onPress: toggle }) })) : null, _jsx(Mark, { label: "Chat", on: mode === 'chat', onPress: onChat, "data-slot": "rail-chat", children: _jsx(MessageSquare, { size: 18, "aria-hidden": true }) }), _jsx(Mark, { label: "Dev", on: mode === 'dev' && !here, onPress: onDev, "data-slot": "rail-dev", children: _jsx(Code2, { size: 18, "aria-hidden": true }) }), _jsx(Mark, { label: "Studio", on: mode === 'studio', onPress: onStudio, "data-slot": "rail-studio", children: _jsx(WandSparkles, { size: 18, "aria-hidden": true }) }), flat ? null : (_jsxs(_Fragment, { children: [_jsx(Mark, { label: "Library", on: here === 'library', onPress: to(RAISED.library), "data-slot": "rail-library", children: _jsx(Library, { size: 18, "aria-hidden": true }) }), _jsx(Mark, { label: "Scheduled tasks", on: here === 'scheduled', onPress: to(RAISED.scheduled), "data-slot": "rail-scheduled", children: _jsx(Clock, { size: 18, "aria-hidden": true }) }), _jsx(Mark, { label: "Customize", on: here === 'customize', onPress: to(RAISED.customize), "data-slot": "rail-customize", children: _jsx(Puzzle, { size: 18, "aria-hidden": true }) })] })), _jsx(DropdownMenu, { placement: flat ? 'top-start' : 'right-start', minWidth: 220, trigger: _jsx(XStack, { render: "button", "aria-label": "More", ...{ title: 'More' }, "data-slot": "rail-more", width: MARK, height: MARK, rounded: "$4", items: "center", justify: "center", cursor: "pointer", opacity: 0.62, hoverStyle: { bg: '$hover', opacity: 1 }, pressStyle: { bg: '$raised' }, focusVisibleStyle: { outlineWidth: 2, outlineStyle: 'solid', outlineColor: '$outlineColor' }, $touchable: { width: 44, height: 44 }, children: _jsx(Ellipsis, { size: 18, "aria-hidden": true }) }), items: rest }), _jsx(YStack, { flex: 1 }), _jsx(Me, { host: host, narrow: true, above: flat, onOrg: onOrg })] }));
99
98
  }
@@ -0,0 +1,14 @@
1
+ /** A model the dialog offers: its id and the name a reader knows it by. */
2
+ export interface Choice {
3
+ id: string;
4
+ name: string;
5
+ }
6
+ export declare function Schedule({ open, onOpenChange, prompt, model, models, }: {
7
+ open: boolean;
8
+ onOpenChange: (open: boolean) => void;
9
+ /** The prompt it opens with. */
10
+ prompt: string;
11
+ /** The model it opens on: the conversation's. */
12
+ model: string;
13
+ models: readonly Choice[];
14
+ }): import("react").JSX.Element;
@@ -0,0 +1,107 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // "Schedule this": a prompt from Chat that runs itself.
4
+ //
5
+ // The dialog holds the prompt, a "When" said in the reader's words — read live
6
+ // by the platform's scheduler (`POST /v1/auto/cron`) in their own time zone and
7
+ // said back with the next run — and the model it thinks with. Create keeps it
8
+ // as an automation made in Chat (lib/auto); its runs land in the Library, and
9
+ // the list of them is Scheduled tasks.
10
+ import { useEffect, useRef, useState } from 'react';
11
+ import { useAi } from '@hanzo/ai/react';
12
+ import { Anchor, Button, Dialog, DialogContent, DialogTitle, Input, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, SizableText, Text, Textarea, XStack, YStack } from '@hanzo/ui';
13
+ import { cron, schedule, zone } from './lib/auto.js';
14
+ import { app } from './lib/host.js';
15
+ import { site } from './where.js';
16
+ import { BAD } from './lib/mix.js';
17
+ /** How long the "When" field rests before it is read. */
18
+ const REST = 400;
19
+ /** A moment as the reader's clock says it. */
20
+ function local(at) {
21
+ const d = new Date(at);
22
+ if (Number.isNaN(d.getTime()))
23
+ return '';
24
+ return d.toLocaleString(undefined, { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
25
+ }
26
+ export function Schedule({ open, onOpenChange, prompt, model, models, }) {
27
+ const ai = useAi();
28
+ const [words, setWords] = useState('');
29
+ const [said, setSaid] = useState('');
30
+ const [chose, setChose] = useState(model);
31
+ const [read, setRead] = useState(null);
32
+ const [reading, setReading] = useState(false);
33
+ const [wrong, setWrong] = useState('');
34
+ const [busy, setBusy] = useState(false);
35
+ const [made, setMade] = useState(false);
36
+ // Each opening starts from what it was opened on; the typing that follows is the reader's.
37
+ useEffect(() => {
38
+ if (!open)
39
+ return;
40
+ setWords(prompt);
41
+ setSaid('');
42
+ setChose(model);
43
+ setRead(null);
44
+ setWrong('');
45
+ setMade(false);
46
+ }, [open, prompt, model]);
47
+ // The "When", read once the reader rests, and the last read only.
48
+ const asked = useRef(0);
49
+ useEffect(() => {
50
+ const text = said.trim();
51
+ if (!open || !text) {
52
+ setRead(null);
53
+ setReading(false);
54
+ return;
55
+ }
56
+ const n = ++asked.current;
57
+ const stop = new AbortController();
58
+ setReading(true);
59
+ const t = setTimeout(() => {
60
+ cron(ai, text, zone(), stop.signal).then((got) => {
61
+ if (n !== asked.current)
62
+ return;
63
+ setRead(got);
64
+ setWrong('');
65
+ setReading(false);
66
+ }, (e) => {
67
+ if (n !== asked.current || stop.signal.aborted)
68
+ return;
69
+ setRead(null);
70
+ setWrong(e?.message || 'That is not a time the scheduler can read.');
71
+ setReading(false);
72
+ });
73
+ }, REST);
74
+ return () => {
75
+ clearTimeout(t);
76
+ stop.abort();
77
+ };
78
+ }, [said, open, ai]);
79
+ const ready = Boolean(words.trim() && read && !reading && !busy);
80
+ const create = async () => {
81
+ if (!ready || !read)
82
+ return;
83
+ setBusy(true);
84
+ setWrong('');
85
+ try {
86
+ await schedule(ai, words, read.schedule, chose || undefined);
87
+ setMade(true);
88
+ }
89
+ catch (e) {
90
+ setWrong(e?.message || 'The task was not scheduled.');
91
+ }
92
+ finally {
93
+ setBusy(false);
94
+ }
95
+ };
96
+ const options = models.some((m) => m.id === chose) || !chose ? models : [{ id: chose, name: chose }, ...models];
97
+ return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { maxW: 520, rounded: "$6", gap: "$3", "data-slot": "schedule", children: [_jsx(DialogTitle, { children: "Schedule this" }), made ? (_jsxs(YStack, { gap: "$3", children: [_jsxs(SizableText, { size: "$3", color: "$ink", role: "status", children: ["Scheduled.", ' ', _jsx(Anchor, { href: site(app('-/automations')), color: "$ink", textDecorationLine: "underline", children: "See it in Scheduled tasks." })] }), _jsx(XStack, { justify: "flex-end", children: _jsx(Button, { size: "sm", rounded: 999, onPress: () => onOpenChange(false), children: "Done" }) })] })) : (_jsxs(YStack, { gap: "$3", children: [_jsxs(YStack, { gap: "$1", children: [_jsx(Text, { fontSize: "$1", color: "$soft", children: "Prompt" }), _jsx(Textarea, { value: words, onChangeText: setWords, rows: 4, "aria-label": "Prompt" })] }), _jsxs(YStack, { gap: "$1", children: [_jsx(Text, { fontSize: "$1", color: "$soft", children: "When" }), _jsx(Input, { autoFocus: true, value: said, onChangeText: setSaid, placeholder: "Every Monday at 9am", "aria-label": "When it runs", "aria-describedby": "schedule-when", onKeyDown: (e) => {
98
+ if (e.key === 'Enter' && !e.nativeEvent?.isComposing)
99
+ void create();
100
+ } }), _jsx(Text, { id: "schedule-when", fontSize: "$1", color: wrong ? BAD : '$soft', role: "status", minH: 18, children: wrong
101
+ ? wrong
102
+ : reading
103
+ ? 'Reading.'
104
+ : read
105
+ ? [read.says, read.next ? `next ${local(read.next)}` : 'when you run it'].filter(Boolean).join(' · ')
106
+ : 'Say it the way you would: every weekday at 8, every 5 minutes, tomorrow at noon.' })] }), options.length ? (_jsxs(YStack, { gap: "$1", children: [_jsx(Text, { fontSize: "$1", color: "$soft", children: "Model" }), _jsxs(Select, { value: chose, onValueChange: setChose, children: [_jsx(SelectTrigger, { "aria-label": "Model", width: "100%", children: _jsx(SelectValue, {}) }), _jsx(SelectContent, { children: options.map((m) => (_jsx(SelectItem, { value: m.id, children: m.name }, m.id))) })] })] })) : null, _jsxs(XStack, { gap: "$2", justify: "flex-end", children: [_jsx(Button, { size: "sm", variant: "outline", rounded: 999, onPress: () => onOpenChange(false), children: "Cancel" }), _jsx(Button, { size: "sm", rounded: 999, disabled: !ready, onPress: () => void create(), children: busy ? 'Scheduling…' : 'Create' })] })] }))] }) }));
107
+ }
package/dist/keep.d.ts ADDED
@@ -0,0 +1,40 @@
1
+ import type { Artifact } from '@hanzo/build';
2
+ import { type Api, type Item } from './lib/library.js';
3
+ import { type Via } from './pane.js';
4
+ /** A picture's still: its SVG drawn onto a canvas, as base64 PNG, with its size. Null where the browser cannot draw it. */
5
+ export declare function still(svg: string): Promise<{
6
+ png: string;
7
+ width: number;
8
+ height: number;
9
+ } | null>;
10
+ /** Keeps what one finished answer made. Each save stands alone: one that fails leaves the rest kept. */
11
+ export declare function keepAnswer(api: Api, thread: string, message: string, blocks: readonly Artifact[]): Promise<Item[]>;
12
+ /**
13
+ * Keeps each answer's makings once it finishes. `finished` is the answer that
14
+ * just stopped streaming (its id and its words), `thread` the conversation's
15
+ * id, which can land a moment after the answer does: the answer waits for it.
16
+ */
17
+ export declare function useKeep(api: Api, signedIn: boolean, thread: string | null, finished: {
18
+ id: string;
19
+ text: string;
20
+ } | null, blocks: (text: string) => Artifact[]): void;
21
+ /** A Library item's bytes as a File, read through its signed address. */
22
+ export declare function fileOf(item: Item): Promise<File>;
23
+ /**
24
+ * `/chat?attach=<item>`: the Library item, held for the next message in
25
+ * conversation `at`. The parameter is struck from the address once read, so a
26
+ * reload does not attach it twice. Answers why it could not be, or ''.
27
+ */
28
+ export declare function useAttach(at: string, via: Via): string;
29
+ /**
30
+ * Keeps a file the reader put into the conversation, when they ask: its bytes
31
+ * read from `url` (the object URL this document holds, or the workspace copy's
32
+ * signed address), keyed by the workspace file it is, so asking twice keeps one.
33
+ */
34
+ export declare function keepFile(api: Api, f: {
35
+ id: string;
36
+ name: string;
37
+ type: string;
38
+ url: string;
39
+ thread: string | null;
40
+ }): Promise<Item>;
package/dist/keep.js ADDED
@@ -0,0 +1,148 @@
1
+ 'use client';
2
+ // What Chat keeps in the Library, and what it takes back out.
3
+ //
4
+ // KEPT AS IT IS MADE. When an answer finishes, every page, picture and file it
5
+ // wrote out (lib/library `made`) is saved to the reader's Library under the
6
+ // conversation, keyed `<thread>/<message>/<index>`, so it is kept once whatever
7
+ // redraws it. A picture is kept with a still drawn here, so the Library shows it
8
+ // whatever type its bytes are served as. Nothing is kept for a reader with no
9
+ // account, or before the conversation has an id: the item names where it came
10
+ // from.
11
+ //
12
+ // TAKEN BACK IN by `/chat?attach=<item>` (the Library's "Use in Chat"): the
13
+ // item's bytes are held for the next message by the one road a picked file
14
+ // takes (pane.ts `hold`).
15
+ import { useEffect, useRef, useState } from 'react';
16
+ import { base64, get, keyOf, made, save } from './lib/library.js';
17
+ import { talk } from './lib/host.js';
18
+ import { hold } from './pane.js';
19
+ /** The longest side of a picture's still, px. */
20
+ const STILL = 512;
21
+ /** A picture's still: its SVG drawn onto a canvas, as base64 PNG, with its size. Null where the browser cannot draw it. */
22
+ export async function still(svg) {
23
+ if (typeof document === 'undefined')
24
+ return null;
25
+ const url = URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml' }));
26
+ try {
27
+ const img = new Image();
28
+ await new Promise((done, fail) => {
29
+ img.onload = () => done();
30
+ img.onerror = () => fail(new Error('undrawable'));
31
+ img.src = url;
32
+ });
33
+ const w = img.naturalWidth || STILL;
34
+ const h = img.naturalHeight || STILL;
35
+ const scale = Math.min(1, STILL / Math.max(w, h));
36
+ const canvas = document.createElement('canvas');
37
+ canvas.width = Math.max(1, Math.round(w * scale));
38
+ canvas.height = Math.max(1, Math.round(h * scale));
39
+ canvas.getContext('2d')?.drawImage(img, 0, 0, canvas.width, canvas.height);
40
+ const blob = await new Promise((done) => canvas.toBlob(done, 'image/png'));
41
+ return blob ? { png: await base64(blob), width: w, height: h } : null;
42
+ }
43
+ catch {
44
+ return null;
45
+ }
46
+ finally {
47
+ URL.revokeObjectURL(url);
48
+ }
49
+ }
50
+ /** Keeps what one finished answer made. Each save stands alone: one that fails leaves the rest kept. */
51
+ export async function keepAnswer(api, thread, message, blocks) {
52
+ const out = [];
53
+ for (const [i, a] of made(blocks).entries()) {
54
+ try {
55
+ const pic = a.mime === 'image/svg+xml' ? await still(a.body) : null;
56
+ out.push(await save(api, {
57
+ name: a.name,
58
+ mime: a.mime,
59
+ data: new Blob([a.body], { type: a.mime }),
60
+ ref: thread,
61
+ origin: talk(thread),
62
+ key: keyOf(thread, message, i),
63
+ ...(pic ? { preview: pic.png, width: pic.width, height: pic.height } : {}),
64
+ }));
65
+ }
66
+ catch {
67
+ // Kept on the next answer that makes it; the conversation holds it meanwhile.
68
+ }
69
+ }
70
+ return out;
71
+ }
72
+ /**
73
+ * Keeps each answer's makings once it finishes. `finished` is the answer that
74
+ * just stopped streaming (its id and its words), `thread` the conversation's
75
+ * id, which can land a moment after the answer does: the answer waits for it.
76
+ */
77
+ export function useKeep(api, signedIn, thread, finished, blocks) {
78
+ const waiting = useRef([]);
79
+ const kept = useRef(new Set());
80
+ useEffect(() => {
81
+ if (!signedIn || !finished || kept.current.has(finished.id))
82
+ return;
83
+ waiting.current.push(finished);
84
+ kept.current.add(finished.id);
85
+ }, [signedIn, finished]);
86
+ useEffect(() => {
87
+ if (!thread || !signedIn || !waiting.current.length)
88
+ return;
89
+ const now = waiting.current.splice(0);
90
+ for (const one of now) {
91
+ const list = blocks(one.text);
92
+ if (list.length)
93
+ void keepAnswer(api, thread, one.id, list);
94
+ }
95
+ }, [thread, signedIn, finished, api, blocks]);
96
+ }
97
+ /** A Library item's bytes as a File, read through its signed address. */
98
+ export async function fileOf(item) {
99
+ const res = await fetch(item.url);
100
+ if (!res.ok)
101
+ throw new Error(`${item.name} could not be read from your Library.`);
102
+ const blob = await res.blob();
103
+ return new File([blob], item.name, { type: item.mime || blob.type });
104
+ }
105
+ /**
106
+ * `/chat?attach=<item>`: the Library item, held for the next message in
107
+ * conversation `at`. The parameter is struck from the address once read, so a
108
+ * reload does not attach it twice. Answers why it could not be, or ''.
109
+ */
110
+ export function useAttach(at, via) {
111
+ const [why, setWhy] = useState('');
112
+ const done = useRef(false);
113
+ useEffect(() => {
114
+ if (done.current || typeof window === 'undefined' || !via.org)
115
+ return;
116
+ const url = new URL(window.location.href);
117
+ const id = url.searchParams.get('attach')?.trim();
118
+ if (!id)
119
+ return;
120
+ done.current = true;
121
+ url.searchParams.delete('attach');
122
+ window.history.replaceState(null, '', `${url.pathname}${url.search}${url.hash}`);
123
+ get(via.api, id)
124
+ .then(fileOf)
125
+ .then((f) => hold(at, [f], via))
126
+ .catch((e) => setWhy(e?.message || 'That item could not be attached.'));
127
+ }, [at, via]);
128
+ return why;
129
+ }
130
+ /**
131
+ * Keeps a file the reader put into the conversation, when they ask: its bytes
132
+ * read from `url` (the object URL this document holds, or the workspace copy's
133
+ * signed address), keyed by the workspace file it is, so asking twice keeps one.
134
+ */
135
+ export async function keepFile(api, f) {
136
+ const res = await fetch(f.url);
137
+ if (!res.ok)
138
+ throw new Error(`${f.name} could not be read.`);
139
+ const blob = await res.blob();
140
+ return save(api, {
141
+ name: f.name,
142
+ mime: f.type || blob.type || 'application/octet-stream',
143
+ data: blob,
144
+ ref: f.thread ?? '',
145
+ origin: f.thread ? talk(f.thread) : talk(),
146
+ key: `file/${f.id}`,
147
+ });
148
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * SCHEDULED — a prompt from Chat that runs itself (`/v1/auto`).
3
+ *
4
+ * The one scheduler is the platform's automations: a schedule said the way a
5
+ * person says it ("every Monday at 9am") is turned into one by `POST
6
+ * /v1/auto/cron` in the reader's own time zone, and `POST /v1/auto/automations`
7
+ * keeps it, made in Chat (`source: "chat"`). Each run's answer lands in the
8
+ * reader's Library; the list of them is Scheduled tasks in Dev.
9
+ */
10
+ import type { Api } from './library.js';
11
+ /** When it runs, in the platform's words. */
12
+ export interface Schedule {
13
+ kind: string;
14
+ at?: string;
15
+ day?: string;
16
+ cron?: string;
17
+ tz?: string;
18
+ }
19
+ /** A schedule read from words: itself, the words it is said back in, and its next run. */
20
+ export interface When {
21
+ schedule: Schedule;
22
+ says: string;
23
+ /** RFC 3339, or null when it runs only when asked. */
24
+ next: string | null;
25
+ }
26
+ /** The reader's own time zone, or UTC where the browser will not say. */
27
+ export declare function zone(): string;
28
+ /** Reads a schedule off the wire, or null for one with no kind. */
29
+ export declare function when(raw: unknown): When | null;
30
+ /** Turns words into a schedule, in `tz`. */
31
+ export declare function cron(api: Api, text: string, tz: string, signal?: AbortSignal): Promise<When>;
32
+ /** A schedule's name: the prompt's first line, held to what the platform keeps. */
33
+ export declare function title(prompt: string): string;
34
+ /** Keeps a prompt from Chat as an automation on `schedule`, and answers its id. */
35
+ export declare function schedule(api: Api, prompt: string, at: Schedule, model?: string): Promise<string>;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * SCHEDULED — a prompt from Chat that runs itself (`/v1/auto`).
3
+ *
4
+ * The one scheduler is the platform's automations: a schedule said the way a
5
+ * person says it ("every Monday at 9am") is turned into one by `POST
6
+ * /v1/auto/cron` in the reader's own time zone, and `POST /v1/auto/automations`
7
+ * keeps it, made in Chat (`source: "chat"`). Each run's answer lands in the
8
+ * reader's Library; the list of them is Scheduled tasks in Dev.
9
+ */
10
+ /** The reader's own time zone, or UTC where the browser will not say. */
11
+ export function zone() {
12
+ try {
13
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
14
+ }
15
+ catch {
16
+ return 'UTC';
17
+ }
18
+ }
19
+ const obj = (v) => (v && typeof v === 'object' ? v : {});
20
+ const str = (v) => (typeof v === 'string' ? v : '');
21
+ /** Reads a schedule off the wire, or null for one with no kind. */
22
+ export function when(raw) {
23
+ const o = obj(raw);
24
+ const s = obj(o.schedule);
25
+ if (!str(s.kind))
26
+ return null;
27
+ const schedule = { kind: str(s.kind) };
28
+ for (const k of ['at', 'day', 'cron', 'tz'])
29
+ if (str(s[k]))
30
+ schedule[k] = str(s[k]);
31
+ return { schedule, says: str(o.says), next: str(o.next) || null };
32
+ }
33
+ /** Turns words into a schedule, in `tz`. */
34
+ export async function cron(api, text, tz, signal) {
35
+ const got = when(await api.http.json({ method: 'POST', path: '/v1/auto/cron', body: { text, tz }, signal }));
36
+ if (!got)
37
+ throw new Error('That is not a time the scheduler can read.');
38
+ return got;
39
+ }
40
+ /** A schedule's name: the prompt's first line, held to what the platform keeps. */
41
+ export function title(prompt) {
42
+ const first = prompt.trim().split('\n')[0]?.trim() ?? '';
43
+ return first.length > 80 ? `${first.slice(0, 79).trimEnd()}…` : first || 'Scheduled prompt';
44
+ }
45
+ /** Keeps a prompt from Chat as an automation on `schedule`, and answers its id. */
46
+ export async function schedule(api, prompt, at, model) {
47
+ const body = {
48
+ name: title(prompt),
49
+ instructions: prompt.trim(),
50
+ schedule: at,
51
+ source: 'chat',
52
+ permissions: 'ask',
53
+ notify: true,
54
+ };
55
+ if (model)
56
+ body.model = model;
57
+ const got = obj(await api.http.json({ method: 'POST', path: '/v1/auto/automations', body }));
58
+ const id = str(got.id);
59
+ if (!id)
60
+ throw new Error('The scheduler answered with no task.');
61
+ return id;
62
+ }
@@ -51,6 +51,8 @@ export declare const DEV = "/dev";
51
51
  * hanzo.ai/studio, whose page draws the app for a signed-in reader.
52
52
  */
53
53
  export declare const STUDIO = "/studio";
54
+ /** Studio at an address of its own (@hanzo/studio `route`): '' is New, /studio. */
55
+ export declare const studio: (at: string) => string;
54
56
  /** Chat on a conversation, or /chat itself for a new one. */
55
57
  export declare const talk: (id?: string | null) => string;
56
58
  /** Dev at a builder address (`route` in @hanzo/build); '' is New, /dev. What is not an address is New. */
@@ -62,6 +64,9 @@ export type Place = {
62
64
  } | {
63
65
  mode: 'dev';
64
66
  at: string;
67
+ } | {
68
+ mode: 'studio';
69
+ at: string;
65
70
  };
66
71
  /** The place a path names, or null when it names none of the app's. */
67
72
  export declare function place(pathname: string): Place | null;
package/dist/lib/host.js CHANGED
@@ -54,6 +54,8 @@ export const DEV = '/dev';
54
54
  * hanzo.ai/studio, whose page draws the app for a signed-in reader.
55
55
  */
56
56
  export const STUDIO = '/studio';
57
+ /** Studio at an address of its own (@hanzo/studio `route`): '' is New, /studio. */
58
+ export const studio = (at) => (at ? `${STUDIO}/${at.replace(/^\/+/, '')}` : STUDIO);
57
59
  /** Chat on a conversation, or /chat itself for a new one. */
58
60
  export const talk = (id) => (id ? `${CHAT}/${encodeURIComponent(id)}` : CHAT);
59
61
  /** Dev at a builder address (`route` in @hanzo/build); '' is New, /dev. What is not an address is New. */
@@ -67,6 +69,8 @@ export function place(pathname) {
67
69
  return { mode: 'chat', thread: null };
68
70
  if (p === DEV || p.startsWith(`${DEV}/`))
69
71
  return { mode: 'dev', at: path(parse(p.slice(DEV.length))) };
72
+ if (p === STUDIO || p.startsWith(`${STUDIO}/`))
73
+ return { mode: 'studio', at: p.slice(STUDIO.length + 1) };
70
74
  if (!p.startsWith(`${CHAT}/`))
71
75
  return null;
72
76
  const id = p.slice(CHAT.length + 1);
@@ -136,7 +140,8 @@ export function remember(mode) {
136
140
  /** Where `/` takes a member: the mode they were last in, Chat when none is kept. */
137
141
  export function resume() {
138
142
  try {
139
- return window.localStorage.getItem(MODE) === 'dev' ? DEV : CHAT;
143
+ const kept = window.localStorage.getItem(MODE);
144
+ return kept === 'dev' ? DEV : kept === 'studio' ? STUDIO : CHAT;
140
145
  }
141
146
  catch {
142
147
  return CHAT;
@@ -0,0 +1,72 @@
1
+ /**
2
+ * THE LIBRARY — everything a person made, in one place (`/v1/library`).
3
+ *
4
+ * Chat keeps what its answers make there: a page or a picture an answer wrote
5
+ * out, a file it wrote out whole, and a file the reader chose to keep. Each is
6
+ * one POST of its bytes; the item is the reader's alone until they share it.
7
+ *
8
+ * A save names itself with a key — `<thread>/<message>/<index>` for what an
9
+ * answer made — and the Library answers the item already kept for a key it has
10
+ * seen, so a turn drawn again, a retry, or a second tab files one item, not two.
11
+ */
12
+ import type { HttpClient } from '@hanzo/ai';
13
+ import type { Artifact } from '@hanzo/build';
14
+ /** The HTTP half of an `@hanzo/ai` client: all this module needs. */
15
+ export interface Api {
16
+ http: Pick<HttpClient, 'json'>;
17
+ }
18
+ /** One thing in the Library, as it answers. */
19
+ export interface Item {
20
+ id: string;
21
+ kind: string;
22
+ source: string;
23
+ name: string;
24
+ mime: string;
25
+ bytes: number;
26
+ /** A signed address that opens it in place, for an hour. */
27
+ url: string;
28
+ /** A signed address that downloads it. */
29
+ download: string;
30
+ /** A signed address of its still, or '' for none. */
31
+ preview: string;
32
+ /** The path back to what made it. */
33
+ origin: string;
34
+ mine: boolean;
35
+ shared: boolean;
36
+ }
37
+ /** What a save sends. */
38
+ export interface Keep {
39
+ name: string;
40
+ mime: string;
41
+ /** The bytes: base64, or a Blob read here. */
42
+ data: string | Blob;
43
+ /** What made it: the conversation. */
44
+ ref: string;
45
+ /** The path back to it. */
46
+ origin: string;
47
+ /** Names the save, so the same one twice is one item. */
48
+ key: string;
49
+ /** A still that shows it, base64 PNG. */
50
+ preview?: string;
51
+ width?: number;
52
+ height?: number;
53
+ }
54
+ /** The most one save sends; the Library answers 413 past it. */
55
+ export declare const MAX: number;
56
+ /** The key of the `index`th thing message `message` of `thread` made. */
57
+ export declare const keyOf: (thread: string, message: string, index: number) => string;
58
+ /** A Blob's bytes, base64. */
59
+ export declare function base64(blob: Blob): Promise<string>;
60
+ /** Reads an item off the wire, or null for something that is not one. */
61
+ export declare function read(raw: unknown): Item | null;
62
+ /** Keeps one thing from Chat in the reader's Library. */
63
+ export declare function save(api: Api, k: Keep): Promise<Item>;
64
+ /** One item the reader may see — theirs, or shared in their organization. */
65
+ export declare function get(api: Api, id: string): Promise<Item>;
66
+ /**
67
+ * What an answer made, worth keeping, of the fenced blocks it wrote
68
+ * (@hanzo/build `artifacts`): a page or a picture, and a file written out under
69
+ * a file's name. A snippet in a language (`ts block`) is part of the answer,
70
+ * not a thing it made, so it is not kept.
71
+ */
72
+ export declare function made(blocks: readonly Artifact[]): Artifact[];
@@ -0,0 +1,83 @@
1
+ /**
2
+ * THE LIBRARY — everything a person made, in one place (`/v1/library`).
3
+ *
4
+ * Chat keeps what its answers make there: a page or a picture an answer wrote
5
+ * out, a file it wrote out whole, and a file the reader chose to keep. Each is
6
+ * one POST of its bytes; the item is the reader's alone until they share it.
7
+ *
8
+ * A save names itself with a key — `<thread>/<message>/<index>` for what an
9
+ * answer made — and the Library answers the item already kept for a key it has
10
+ * seen, so a turn drawn again, a retry, or a second tab files one item, not two.
11
+ */
12
+ /** The most one save sends; the Library answers 413 past it. */
13
+ export const MAX = 10 * 1024 * 1024;
14
+ /** The key of the `index`th thing message `message` of `thread` made. */
15
+ export const keyOf = (thread, message, index) => `${thread}/${message}/${index}`;
16
+ /** A Blob's bytes, base64. */
17
+ export async function base64(blob) {
18
+ const bytes = new Uint8Array(await blob.arrayBuffer());
19
+ let s = '';
20
+ for (let i = 0; i < bytes.length; i += 0x8000)
21
+ s += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
22
+ return btoa(s);
23
+ }
24
+ /** Reads an item off the wire, or null for something that is not one. */
25
+ export function read(raw) {
26
+ const o = (raw && typeof raw === 'object' ? raw : {});
27
+ const str = (k) => (typeof o[k] === 'string' ? o[k] : '');
28
+ if (!str('id'))
29
+ return null;
30
+ return {
31
+ id: str('id'),
32
+ kind: str('kind'),
33
+ source: str('source'),
34
+ name: str('name') || str('id'),
35
+ mime: str('mime'),
36
+ bytes: typeof o.bytes === 'number' ? o.bytes : 0,
37
+ url: str('url'),
38
+ download: str('download'),
39
+ preview: str('preview'),
40
+ origin: str('origin'),
41
+ mine: o.mine === true,
42
+ shared: o.shared === true,
43
+ };
44
+ }
45
+ /** Keeps one thing from Chat in the reader's Library. */
46
+ export async function save(api, k) {
47
+ const data = typeof k.data === 'string' ? k.data : await base64(k.data);
48
+ if ((data.length * 3) / 4 > MAX)
49
+ throw new Error(`${k.name} is larger than 10 MB, the most the Library takes from Chat.`);
50
+ const body = { name: k.name, mime: k.mime, data, source: 'chat', ref: k.ref, origin: k.origin, key: k.key };
51
+ if (k.preview)
52
+ body.preview = k.preview;
53
+ if (k.width)
54
+ body.width = Math.round(k.width);
55
+ if (k.height)
56
+ body.height = Math.round(k.height);
57
+ const got = read(await api.http.json({ method: 'POST', path: '/v1/library', body }));
58
+ if (!got)
59
+ throw new Error('The Library answered with no item.');
60
+ return got;
61
+ }
62
+ /** One item the reader may see — theirs, or shared in their organization. */
63
+ export async function get(api, id) {
64
+ const got = read(await api.http.json({ path: `/v1/library/${encodeURIComponent(id)}` }));
65
+ if (!got)
66
+ throw new Error('That item is not in your Library.');
67
+ return got;
68
+ }
69
+ const EXT = { 'text/html': 'html', 'image/svg+xml': 'svg' };
70
+ const FILE = /\.[a-z0-9]{1,8}$/i;
71
+ /** Whether a browser draws it as a page or a picture (@hanzo/build `renders`). */
72
+ const drawn = (a) => a.mime === 'text/html' || a.mime === 'image/svg+xml';
73
+ /**
74
+ * What an answer made, worth keeping, of the fenced blocks it wrote
75
+ * (@hanzo/build `artifacts`): a page or a picture, and a file written out under
76
+ * a file's name. A snippet in a language (`ts block`) is part of the answer,
77
+ * not a thing it made, so it is not kept.
78
+ */
79
+ export function made(blocks) {
80
+ return blocks
81
+ .filter((a) => drawn(a) || FILE.test(a.name))
82
+ .map((a, i) => (FILE.test(a.name) ? a : { ...a, name: `${a.mime === 'image/svg+xml' ? 'Picture' : 'Page'} ${i + 1}.${EXT[a.mime] ?? 'txt'}` }));
83
+ }
package/dist/thread.d.ts CHANGED
@@ -29,12 +29,15 @@ export declare const href: (id: string) => string;
29
29
  * child holds the rows (`ThreadRow`). `threads` are the list's conversations by
30
30
  * id, `active` the one the pane holds (what the keys act on with no menu open),
31
31
  * and `onGone` runs after that one is deleted — the room moves to a new chat —
32
- * defaulting to emptying the room.
32
+ * defaulting to emptying the room. `onSchedule`, where the list's host can say
33
+ * what the conversation asks, adds Schedule…: the conversation's question as a
34
+ * task that runs itself.
33
35
  */
34
- export declare function ConversationMenu({ threads, active, onGone, children, }: {
36
+ export declare function ConversationMenu({ threads, active, onGone, onSchedule, children, }: {
35
37
  threads: Map<string, Thread>;
36
38
  active?: string | null;
37
39
  onGone?: () => void;
40
+ onSchedule?: (t: Thread) => void;
38
41
  children: ReactElement;
39
42
  }): import("react").JSX.Element;
40
43
  /**
package/dist/thread.js CHANGED
@@ -22,7 +22,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
22
22
  // device they sign in on.
23
23
  import { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore, } from 'react';
24
24
  import { useAi } from '@hanzo/ai/react';
25
- import { Archive, ArchiveRestore, ExternalLink, Link2, MoreHorizontal, Pencil, Pin, PinOff, Share2, Trash2 } from 'lucide-react';
25
+ import { Archive, ArchiveRestore, Clock, ExternalLink, Link2, MoreHorizontal, Pencil, Pin, PinOff, Share2, Trash2 } from 'lucide-react';
26
26
  import { Button, ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuShortcut, ContextMenuTrigger, Dialog, DialogContent, DialogTitle, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuTrigger, Input, SizableText, Text, XStack, YStack, toast, } from '@hanzo/ui';
27
27
  import { empty } from './open.js';
28
28
  import { say } from './lib/org.js';
@@ -182,9 +182,11 @@ const own = (row) => row?.querySelector('[data-slot="sidebar-item"], [role="butt
182
182
  * child holds the rows (`ThreadRow`). `threads` are the list's conversations by
183
183
  * id, `active` the one the pane holds (what the keys act on with no menu open),
184
184
  * and `onGone` runs after that one is deleted — the room moves to a new chat —
185
- * defaulting to emptying the room.
185
+ * defaulting to emptying the room. `onSchedule`, where the list's host can say
186
+ * what the conversation asks, adds Schedule…: the conversation's question as a
187
+ * task that runs itself.
186
188
  */
187
- export function ConversationMenu({ threads, active = null, onGone = empty, children, }) {
189
+ export function ConversationMenu({ threads, active = null, onGone = empty, onSchedule, children, }) {
188
190
  const act = useThreadActions();
189
191
  // The conversation the pane holds NOW: a delete answered after the reader moved on leaves the room they moved to.
190
192
  const holds = useRef(active);
@@ -280,6 +282,7 @@ export function ConversationMenu({ threads, active = null, onGone = empty, child
280
282
  run: () => void act.pin(subject),
281
283
  },
282
284
  { key: 'share', label: 'Share…', icon: _jsx(Share2, { size: 16, "aria-hidden": true }), run: () => ask('share', subject) },
285
+ ...(onSchedule ? [{ key: 'schedule', label: 'Schedule…', icon: _jsx(Clock, { size: 16, "aria-hidden": true }), run: () => onSchedule(subject) }] : []),
283
286
  { key: 'copy', label: 'Copy link', icon: _jsx(Link2, { size: 16, "aria-hidden": true }), run: () => void copy(subject.id) },
284
287
  { key: 'tab', label: 'Open in new tab', icon: _jsx(ExternalLink, { size: 16, "aria-hidden": true }), run: () => away(subject.id) },
285
288
  {
package/dist/where.d.ts CHANGED
@@ -68,6 +68,6 @@ export declare const where: () => Addresses;
68
68
  /** The product's name, as the host states it. Read at call time, like every address. */
69
69
  export declare const brand: () => string;
70
70
  /** The product's name in each mode of the app: the host's brand in Chat (Hanzo AI, Hanzo Team), Hanzo Dev in Dev. */
71
- export declare const wordmark: (mode: 'chat' | 'dev') => string;
71
+ export declare const wordmark: (mode: 'chat' | 'dev' | 'studio') => string;
72
72
  /** A page of the app's own, from wherever the rooms are mounted. */
73
73
  export declare const site: (path: string) => string;
package/dist/where.js CHANGED
@@ -12,6 +12,6 @@ export const where = () => addresses;
12
12
  /** The product's name, as the host states it. Read at call time, like every address. */
13
13
  export const brand = () => addresses.brand ?? 'Hanzo AI';
14
14
  /** The product's name in each mode of the app: the host's brand in Chat (Hanzo AI, Hanzo Team), Hanzo Dev in Dev. */
15
- export const wordmark = (mode) => (mode === 'dev' ? 'Hanzo Dev' : brand());
15
+ export const wordmark = (mode) => (mode === 'dev' ? 'Hanzo Dev' : mode === 'studio' ? 'Hanzo Studio' : brand());
16
16
  /** A page of the app's own, from wherever the rooms are mounted. */
17
17
  export const site = (path) => `${addresses.site}${path}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanzo/rooms",
3
- "version": "0.1.68",
3
+ "version": "0.1.70",
4
4
  "description": "The workspace rooms — Home, Chat, Inbox, Contacts, Meet, Cal, Drive, Board, Work, Bots — and the frame that holds them, once, for every Hanzo surface.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -55,6 +55,7 @@
55
55
  "@hanzo/gui": "^8.3.5",
56
56
  "@hanzo/iam": ">=0.21.28",
57
57
  "@hanzo/logo": ">=1.0.26",
58
+ "@hanzo/studio": ">=0.1.0",
58
59
  "@hanzo/ui": ">=8.27.67",
59
60
  "@hanzo/usage": ">=0.1.18",
60
61
  "@hanzo/voice": ">=0.1.16",
@@ -73,6 +74,7 @@
73
74
  "@hanzo/gui": "8.3.5",
74
75
  "@hanzo/iam": "0.21.28",
75
76
  "@hanzo/logo": "1.0.26",
77
+ "@hanzo/studio": "0.1.0",
76
78
  "@hanzo/ui": "8.27.67",
77
79
  "@hanzo/usage": "0.1.18",
78
80
  "@hanzo/voice": "0.1.16",