@hanzo/rooms 0.1.33 → 0.1.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,13 +11,13 @@ import { CalScheduler } from '@hanzo/rooms/CalScheduler'
11
11
 
12
12
  configure({ site: '', home: '/home', signUp: '/signup' })
13
13
 
14
- <Rooms router={router} search={search} route={route} Link={Link}>
14
+ <Rooms router={router} search={search} path={pathname} route={route} Link={Link}>
15
15
  <Room mode="cal"><CalScheduler /></Room>
16
16
  </Rooms>
17
17
  ```
18
18
 
19
19
  `configure()` names the host's addresses (where its own pages live, the
20
20
  gateway and IAM, the plan catalogue to paint first). `<Rooms>` carries what
21
- moves: the router, the query, the route and the link component. The rooms
21
+ moves: the router, the query, the path, the route and the link component. The rooms
22
22
  import nothing from a host, and their look comes from `@hanzo/appearance` and
23
23
  `@hanzo/design` through `@hanzo/ui`.
package/dist/Chat.js CHANGED
@@ -34,8 +34,8 @@ import { useAgent, useAi, useChat, useModels, } from "@hanzo/ai/react";
34
34
  import { freeCopy, grantConsent, hasConsent, isFree, } from "@hanzo/ai";
35
35
  import { ASK, Chat as ChatSurface, Code, Failure, Parts, words } from "@hanzo/ui/chat";
36
36
  import { carriedIn, compose, partsOf, said as told, weigh } from "./lib/attach.js";
37
- import { dropped, pct, readable, refOf, retrieve } from "./lib/files.js";
38
- import { channel, drop, hold, landed, spend, take, usePane, useWorkFile } from "./pane.js";
37
+ import { download, dropped, fileOf, pct, readable, refOf, retrieve } from "./lib/files.js";
38
+ import { channel, drop, hold, landed, localOf, spend, take, usePane, useWorkFile } from "./pane.js";
39
39
  import { Face, brief as roomBrief, join, roleOf, roomTurn, roster as rosterLine, rosterOf, speakers, speaksOf, voiceOf } from "./cast.js";
40
40
  import { SpeakButton, cleanForSpeech } from "./speech.js";
41
41
  import { Crew, HOUSE, useCrew } from "./crew.js";
@@ -52,12 +52,13 @@ import { useHydrated } from "./lib/hydrated.js";
52
52
  import { base, served } from "./lib/ai.js";
53
53
  import { onServed } from "./lib/served.js";
54
54
  import { refused as worded, speech, useDictation, useTalk, useVoice, Voice } from "@hanzo/voice";
55
- import { ArrowUp, AudioLines, Image as ImageMark, Mic, Paperclip, Square, Star, PanelRight, X } from "lucide-react";
55
+ import { ArrowUp, AudioLines, FileText, Image as ImageMark, Mic, Paperclip, Square, Star, PanelRight, X } from "lucide-react";
56
56
  import { ENSO, FREE } from "./lib/ai.js";
57
57
  import { openThread, useOpen } from "./open.js";
58
58
  import { Beside, Framed } from "./Shell.js";
59
59
  import { pane } from "./ground.js";
60
- import { RightPane, usePinned } from "./RightPane.js";
60
+ import { RightPane, addressOf, look, usePinned } from "./RightPane.js";
61
+ import { useMedia } from "@hanzo/gui";
61
62
  import { Take } from "./copy.js";
62
63
  import { Share } from "./Share.js";
63
64
  import { useStarred } from "./stars.js";
@@ -351,27 +352,113 @@ const STAGE = {
351
352
  graph: "linking",
352
353
  embed: "vectors",
353
354
  };
354
- /** One chip: the file's glyph, its name, and how far it has got. */
355
- function Chip({ name, type, state, sent, file, onRemove, }) {
355
+ /** How a file is drawn: a picture, a PDF's tile, or a chip. Read off its type, else its name, since a store may answer with neither. */
356
+ function kindOf(name, type) {
357
+ if (type.startsWith("image/") || /\.(png|jpe?g|gif|webp|avif|svg)$/i.test(name))
358
+ return "image";
359
+ if (type === "application/pdf" || /\.pdf$/i.test(name))
360
+ return "pdf";
361
+ return "file";
362
+ }
363
+ // SIGNED ADDRESSES FOR PICTURES, by workspace file: a list of turns mounts and
364
+ // remounts its chips, and asking the store each time would be a request per
365
+ // picture per redraw. Kept for less than a signature lives (the store's are
366
+ // five minutes), so a chip mounted later reads a fresh one; a picture already
367
+ // drawn keeps its pixels whatever becomes of its address.
368
+ const signed = new Map();
369
+ const SIGNED_FOR = 4 * 60_000;
370
+ function signedOf(api, id, file) {
371
+ const kept = signed.get(id);
372
+ if (kept && Date.now() - kept.at < SIGNED_FOR)
373
+ return kept.got;
374
+ const got = (file?.bucket && file.key ? Promise.resolve(file) : fileOf(api, id)).then((f) => download(api, f.bucket, f.key));
375
+ signed.set(id, { at: Date.now(), got });
376
+ got.catch(() => signed.delete(id));
377
+ return got;
378
+ }
379
+ /**
380
+ * Where a picture's thumbnail reads from: the bytes on this machine where this
381
+ * document holds them (the held file's object URL, which outlives its send),
382
+ * else a signed address for the workspace's copy — a file taken from Drive, or
383
+ * a turn read back after a reload. '' until there is one.
384
+ */
385
+ function usePicture(on, href, id, file) {
386
+ const api = useAi();
387
+ const near = on ? href || (id ? localOf(id) : "") : "";
388
+ const [far, setFar] = useState("");
389
+ const bucket = file?.bucket;
390
+ useEffect(() => {
391
+ if (!on || near || !id)
392
+ return;
393
+ let live = true;
394
+ signedOf(api, id, file).then((url) => live && setFar(url), () => { });
395
+ return () => {
396
+ live = false;
397
+ };
398
+ // The file's record moves with every stage of its ingest; its address does not.
399
+ // eslint-disable-next-line react-hooks/exhaustive-deps
400
+ }, [on, near, id, bucket, api]);
401
+ return near || far;
402
+ }
403
+ /** Edge of a picture's or a PDF's thumbnail, in px. */
404
+ const THUMB = 48;
405
+ /**
406
+ * One file put into a message.
407
+ *
408
+ * A PICTURE SHOWS ITSELF and a PDF shows a tile, each with its name and size
409
+ * beside it; any other kind is a chip of its name. Nothing is filled behind
410
+ * them: a hairline on whatever they sit on, so a file reads alike over the
411
+ * composer and inside a turn's bubble, in a light room and a dark one. A second
412
+ * fill on the bubble's was the darker grey that read as a stain. The word for
413
+ * how far a file has got shows only while it is getting there — a picture of
414
+ * the file already says it is attached — and `data-state` keeps the index's own
415
+ * word for whoever reads it.
416
+ *
417
+ * Pressing one opens it beside the conversation (`onOpen`).
418
+ */
419
+ function Chip({ name, type, size, state, sent, file, picture, onOpen, onRemove, }) {
420
+ const kind = kindOf(name, type);
356
421
  const bad = state === "failed";
357
422
  const why = state === "failed" || state === "stored" ? file?.error : file?.note;
358
- return (_jsxs(XStack, { role: "listitem", items: "center", gap: "$1.5", pl: "$2.5", pr: onRemove ? "$1" : "$2.5", py: "$1", rounded: "$10", borderWidth: 1, borderColor: "$borderColor", bg: "$hover", maxW: 320, "data-slot": "file-chip", "data-state": state, "data-stage": file?.stage ?? "", ...(why ? { title: why } : null), children: [type.startsWith("image/") ? (_jsx(ImageMark, { size: 12, "aria-hidden": true, color: "var(--muted-foreground)" })) : (_jsx(Paperclip, { size: 12, "aria-hidden": true, color: "var(--muted-foreground)" })), _jsx(Text, { fontSize: "$2", color: "$ink", numberOfLines: 1, children: name }), _jsx(Text, { fontSize: "$1", color: bad ? BAD : "$soft", numberOfLines: 1, shrink: 0, children: progress(state, sent, file) }), onRemove ? (_jsx(Box, { render: "button", "aria-label": `Remove ${name}`, onClick: onRemove, p: "$1", rounded: "$10", hoverStyle: { bg: "$raised" }, children: _jsx(X, { size: 12, "aria-hidden": true }) })) : null] }));
423
+ // A picture the browser cannot draw (HEIC, a lapsed signature) falls back to its glyph.
424
+ const [broken, setBroken] = useState("");
425
+ const word = state === "ready" ? (file?.clipped ? "in part" : "") : progress(state, sent, file);
426
+ const meta = [kind === "pdf" ? "PDF" : "", size > 0 ? weigh(size) : "", word].filter(Boolean).join(" \u00b7 ");
427
+ 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 marks = {
429
+ role: "listitem",
430
+ "data-slot": "file-chip",
431
+ "data-state": state,
432
+ "data-kind": kind,
433
+ "data-stage": file?.stage ?? "",
434
+ ...(why ? { title: why } : null),
435
+ };
436
+ if (kind === "file")
437
+ return (_jsxs(XStack, { ...marks, self: "center", items: "center", gap: "$1", pr: onRemove ? "$1" : "$2.5", rounded: "$10", borderWidth: 1, borderColor: "$borderColor", maxW: 320, children: [_jsxs(XStack, { render: "button", "aria-label": `Open ${name}`, onClick: onOpen, items: "center", gap: "$1.5", pl: "$2.5", py: "$1", minW: 0, cursor: "pointer", children: [_jsx(Paperclip, { size: 12, "aria-hidden": true, color: "var(--muted-foreground)" }), _jsx(Text, { fontSize: "$2", color: "$ink", numberOfLines: 1, children: name }), meta ? (_jsx(Text, { fontSize: "$1", color: bad ? BAD : "$soft", numberOfLines: 1, shrink: 0, children: meta })) : null] }), remove] }));
438
+ const drawn = kind === "image" && picture && broken !== picture;
439
+ const Glyph = kind === "pdf" ? FileText : ImageMark;
440
+ 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] }));
359
441
  }
360
442
  /** A file held for the next message: uploading, then indexing, then ready. */
361
- function HeldChip({ one, onRemove }) {
443
+ function HeldChip({ one, onOpen, onRemove }) {
362
444
  const live = useWorkFile(one.file?.id);
363
445
  const file = live ?? one.file;
364
446
  const state = one.state === "uploading" ? one.state : (file?.status ?? one.state);
365
- return _jsx(Chip, { name: one.name, type: one.type, state: state, sent: one.sent, file: file ?? (one.error ? { error: one.error } : undefined), onRemove: onRemove });
447
+ const picture = usePicture(kindOf(one.name, one.type) === "image", one.href, file?.id, file);
448
+ // The mark the column's Sources row opens for the same file, so both open it alike.
449
+ 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 };
450
+ 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 }));
366
451
  }
367
452
  /** A file a sent turn carried, as the index says of it now. */
368
- function TurnChip({ f }) {
453
+ function TurnChip({ f, onOpen }) {
369
454
  const live = useWorkFile(f.id);
370
- return _jsx(Chip, { name: f.name, type: f.type, state: live?.status ?? "ready", sent: 1, file: live });
455
+ const picture = usePicture(kindOf(f.name, f.type) === "image", "", f.id, live);
456
+ const mark = { id: f.id, name: f.name, kind: kindOf(f.name, f.type) === "image" ? "image" : "file", file: f.id, mime: f.type };
457
+ 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) });
371
458
  }
372
459
  /** A question that carried files: its words, the files put into it, and those read again. */
373
- function Carried({ text, attached, reused }) {
374
- return (_jsxs(YStack, { gap: "$1.5", "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 }, f.id))) })) : null, reused.length ? (_jsxs(Text, { fontSize: "$1", color: "$soft", children: ["Read again: ", reused.map((f) => `${f.name} (${weigh(f.size)})`).join(", ")] })) : null] }));
460
+ function Carried({ text, attached, reused, onOpen }) {
461
+ 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] }));
375
462
  }
376
463
  /**
377
464
  * The conversation. One component for a visitor and for an account.
@@ -420,6 +507,29 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
420
507
  // switch for it. Kept per conversation by `pane.ts`, so the header's control
421
508
  // reflects THIS room rather than the last one that was open.
422
509
  const [pinned, flipPin] = usePinned();
510
+ // The frame draws the column only from `$lg` (Shell.tsx `Paned`).
511
+ const wide = useMedia().lg;
512
+ /**
513
+ * OPENS A FILE A MESSAGE CARRIES, by the road the column's Sources row takes
514
+ * (`look`): a tab in the browser beside the conversation, the column opened
515
+ * first when it is shut. A phone has no column — the frame spends the width
516
+ * on the room — so there the file opens in a tab of the browser's own, minted
517
+ * before the signed address is asked for, since a window opened after an
518
+ * await is a popup the browser blocks.
519
+ */
520
+ const openFile = useCallback((m) => {
521
+ if (wide) {
522
+ if (aside === null)
523
+ showAside("tasks");
524
+ void look(at, m, ai);
525
+ return;
526
+ }
527
+ const tab = window.open("", "_blank");
528
+ if (!tab)
529
+ return;
530
+ tab.opener = null;
531
+ void addressOf(m, ai).then((href) => (href ? tab.location.replace(href) : tab.close()));
532
+ }, [wide, aside, showAside, at, ai]);
423
533
  /**
424
534
  * WHO YOU ARE TALKING TO, when it is somebody rather than the plain model.
425
535
  *
@@ -1132,7 +1242,7 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
1132
1242
  if (turn.role === "user" && said) {
1133
1243
  const read = told(said);
1134
1244
  if (read.carried)
1135
- return _jsx(Carried, { text: read.text, attached: read.carried.attached, reused: read.carried.reused });
1245
+ return _jsx(Carried, { text: read.text, attached: read.carried.attached, reused: read.carried.reused, onOpen: openFile });
1136
1246
  }
1137
1247
  // WHILE THE ANSWER HAS NO WORDS YET: who is answering (the agents the
1138
1248
  // question addresses, or the whole room when it names nobody), and the
@@ -1238,7 +1348,7 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
1238
1348
  },
1239
1349
  // WHAT GOES WITH THE MESSAGE, above the frame: one chip a file, each
1240
1350
  // with its own remove, and the reason any file was refused.
1241
- head: files.length || turnedAway || preparing || talk.open || talk.refusal || pause ? (_jsxs(YStack, { width: "calc(100% - 32px)", maxW: `calc(${MEASURE} - 1.5rem)`, mx: "auto", gap: "$1.5", children: [pause ? (_jsx(LimitedBanner, { message: pause.message, actions: pause.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, 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
1351
+ head: files.length || turnedAway || preparing || talk.open || talk.refusal || pause ? (_jsxs(YStack, { width: "calc(100% - 32px)", maxW: `calc(${MEASURE} - 1.5rem)`, mx: "auto", gap: "$1.5", children: [pause ? (_jsx(LimitedBanner, { message: pause.message, actions: pause.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
1242
1352
  ? worded(talk.refusal)
1243
1353
  : talk.reply
1244
1354
  ? `Hanzo: ${talk.reply}`
@@ -1,4 +1,23 @@
1
1
  import { type ChatMessage } from '@hanzo/ai/react';
2
+ import { type Api } from './lib/files.js';
3
+ /**
4
+ * One thing the conversation carries.
5
+ *
6
+ * `href` is where it can be opened and `body` is the text of something that was
7
+ * never a file — a fenced block exists only in the transcript until somebody
8
+ * asks to look at it, at which point `body` becomes a blob and gets an address.
9
+ */
10
+ export interface Mark {
11
+ id: string;
12
+ name: string;
13
+ kind: 'image' | 'code' | 'file';
14
+ href?: string;
15
+ body?: string;
16
+ /** The type a blob should carry, so a browser renders it rather than downloads it. */
17
+ mime?: string;
18
+ /** A workspace file's id: it opens through a signed address minted when it is looked at. */
19
+ file?: string;
20
+ }
2
21
  /**
3
22
  * The column. `said` is the conversation it stands beside; without one it draws
4
23
  * the same shape with nothing in it, which is what a room nobody has spoken in
@@ -8,6 +27,22 @@ export declare function RightPane({ said, onClose, }: {
8
27
  said?: ChatMessage[];
9
28
  onClose?: () => void;
10
29
  }): import("react").JSX.Element;
30
+ /**
31
+ * Opens a mark in this conversation's browser.
32
+ *
33
+ * A BLOCK BECOMES A FILE THE MOMENT SOMEBODY LOOKS AT IT. It has no address
34
+ * until then — it is text inside a turn — so one is minted here from its own
35
+ * bytes, carrying the type its fence declared. That is what makes an answer's
36
+ * `index.html` something you can actually see rather than something you can
37
+ * only read the source of.
38
+ */
39
+ export declare function look(at: string, m: Mark, api: Api): Promise<void>;
40
+ /**
41
+ * Where a mark can be opened: its own address, a signed one minted for its
42
+ * workspace file, or one minted from its text. Null when there is none — a file
43
+ * forgotten or a store unreachable opens nothing, and the row stays.
44
+ */
45
+ export declare function addressOf(m: Mark, api: Api): Promise<string | null>;
11
46
  /** Shows or hides the summary above this conversation's browser. Exported for
12
47
  * the conversation header, which is where the control belongs. */
13
48
  export declare function usePinned(): [boolean, () => void];
package/dist/RightPane.js CHANGED
@@ -210,21 +210,27 @@ function Marks({ at, kind, title, icon, marks, children, }) {
210
210
  * `index.html` something you can actually see rather than something you can
211
211
  * only read the source of.
212
212
  */
213
- async function look(at, m, api) {
213
+ export async function look(at, m, api) {
214
+ const href = await addressOf(m, api);
215
+ if (href)
216
+ openTab(at, href, m.name);
217
+ }
218
+ /**
219
+ * Where a mark can be opened: its own address, a signed one minted for its
220
+ * workspace file, or one minted from its text. Null when there is none — a file
221
+ * forgotten or a store unreachable opens nothing, and the row stays.
222
+ */
223
+ export async function addressOf(m, api) {
214
224
  if (!m.href && m.file) {
215
225
  try {
216
226
  const f = await fileOf(api, m.file);
217
- openTab(at, await download(api, f.bucket, f.key), m.name);
227
+ return await download(api, f.bucket, f.key);
218
228
  }
219
229
  catch {
220
- // A file forgotten or a store unreachable opens nothing; the row stays.
230
+ return null;
221
231
  }
222
- return;
223
232
  }
224
- const href = m.href ?? (m.body === undefined ? null : URL.createObjectURL(new Blob([m.body], { type: m.mime })));
225
- if (!href)
226
- return;
227
- openTab(at, href, m.name);
233
+ return m.href ?? (m.body === undefined ? null : URL.createObjectURL(new Blob([m.body], { type: m.mime })));
228
234
  }
229
235
  /**
230
236
  * A menu row's words.
package/dist/dev.js CHANGED
@@ -7,12 +7,12 @@
7
7
  // to leave. The name drawn at the top left is Hanzo Dev: this host is the
8
8
  // dev agent. Chat, on the rooms shell, is Hanzo AI.
9
9
  //
10
- // THE ADDRESS IS THE QUERY. The app is `/` (app/page.tsx), and what it shows
11
- // rides `?at=` in the builder's own grammar (lib/host.ts `AT`), so a run, a
10
+ // THE ADDRESS IS THE PATH. Dev is `/dev`, and what it shows is the path under
11
+ // it in the builder's own grammar (lib/host.ts `app`, `under`), so a run, a
12
12
  // screen, a Settings section, a Customize tab, the plans and a project all
13
13
  // survive a reload, and Back steps through them. A move is a native history
14
- // entry, which the Next router folds into `useSearchParams`, so reading the
15
- // address and moving it are one query.
14
+ // entry, which the Next router folds into `usePathname`, so reading the address
15
+ // and moving it are one path.
16
16
  import { useMemo } from 'react';
17
17
  import { useLook, useRooms } from './host.js';
18
18
  import { useIam } from '@hanzo/iam/react';
@@ -21,7 +21,7 @@ import { administers, path, route } from '@hanzo/build';
21
21
  import { bearer, named, org, orgs, pick } from './lib/session.js';
22
22
  import { enter } from './lib/destination.js';
23
23
  import { api } from './lib/api.js';
24
- import { app, AT } from './lib/host.js';
24
+ import { app, under } from './lib/host.js';
25
25
  import { link } from './lib/tags.js';
26
26
  /** Where the builder links out: every one of them an address of this site. */
27
27
  const LINKS = {
@@ -32,14 +32,14 @@ const LINKS = {
32
32
  };
33
33
  /** The person's theme, when they have chosen one the builder can name. */
34
34
  const theme = (t) => (t === 'light' || t === 'dark' || t === 'system' ? t : undefined);
35
- /** The app address a query value names, canonical; '' for anything that is not one. */
35
+ /** The app address a builder path names, canonical; '' for anything that is not one. */
36
36
  const canonical = (at) => path(route(at ?? ''));
37
37
  /** The host for a signed-in reader. */
38
38
  export function useDevHost() {
39
39
  const { user, accessToken } = useIam();
40
40
  const logout = useSignOut();
41
- const { router, search } = useRooms();
42
- const here = canonical(search?.get(AT) ?? null);
41
+ const { router, path: pathname } = useRooms();
42
+ const here = canonical(under(pathname));
43
43
  const look = useLook();
44
44
  // The token's claims change only when the token does: a sign-in, a refresh.
45
45
  const memberships = useMemo(() => orgs(), [accessToken]); // eslint-disable-line react-hooks/exhaustive-deps
@@ -70,7 +70,7 @@ export function useDevHost() {
70
70
  path: here,
71
71
  go: (to, how) => {
72
72
  const next = app(canonical(to));
73
- if (`${window.location.pathname}${window.location.search}` === next)
73
+ if (window.location.pathname === next && !window.location.search)
74
74
  return;
75
75
  if (how?.replace)
76
76
  window.history.replaceState(null, '', next);
package/dist/host.d.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * configure({ site: 'https://hanzo.ai', home: '/', signUp: '/start' })
9
9
  *
10
- * <Rooms router={useRouter()} search={useSearchParams()} route={route} Link={Link}>
10
+ * <Rooms router={useRouter()} search={useSearchParams()} path={usePathname()} route={route} Link={Link}>
11
11
  * <Room mode="cal" />
12
12
  * </Rooms>
13
13
  *
@@ -17,7 +17,7 @@
17
17
  * any component and on pages the rooms never draw. So the host states them once
18
18
  * with `configure()`, in a module its root imports, before anything renders.
19
19
  *
20
- * The ROUTER, the query and the route change as the page moves, so they ride
20
+ * The ROUTER, the query, the path and the route change as the page moves, so they ride
21
21
  * React context, and a room reads them with `useRooms()`.
22
22
  */
23
23
  import { type AnchorHTMLAttributes, type ComponentType, type ReactNode } from 'react';
@@ -34,14 +34,18 @@ export interface Router {
34
34
  }
35
35
  interface Moving {
36
36
  router: Router;
37
- /** The address's query, for the rooms that read one (Dev's `?at=`). */
37
+ /** The address's query, for the rooms that read one (Chat's `?chat=`). */
38
38
  search: URLSearchParams | null;
39
+ /** The address's path, for the rooms that read one (Dev's, under `/dev`). */
40
+ path: string | null;
39
41
  route: string;
40
42
  Link: Link;
41
43
  }
42
- export interface RoomsProps extends Omit<Moving, 'search'> {
44
+ export interface RoomsProps extends Omit<Moving, 'search' | 'path'> {
43
45
  /** Absent where no mounted room reads the query, so a static export needs no Suspense for it. */
44
46
  search?: URLSearchParams | null;
47
+ /** Absent where no mounted room reads the path. */
48
+ path?: string | null;
45
49
  children?: ReactNode;
46
50
  }
47
51
  /** The router, the query, the route and the link the host mounted the rooms with. */
@@ -72,5 +76,5 @@ export declare function useLook(): {
72
76
  * keeps their choice in step with IAM.
73
77
  */
74
78
  export declare function Look(): any;
75
- export declare function Rooms({ router, search, route, Link, children }: RoomsProps): import("react").JSX.Element;
79
+ export declare function Rooms({ router, search, path, route, Link, children }: RoomsProps): import("react").JSX.Element;
76
80
  export {};
package/dist/host.js CHANGED
@@ -9,7 +9,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
9
9
  *
10
10
  * configure({ site: 'https://hanzo.ai', home: '/', signUp: '/start' })
11
11
  *
12
- * <Rooms router={useRouter()} search={useSearchParams()} route={route} Link={Link}>
12
+ * <Rooms router={useRouter()} search={useSearchParams()} path={usePathname()} route={route} Link={Link}>
13
13
  * <Room mode="cal" />
14
14
  * </Rooms>
15
15
  *
@@ -19,7 +19,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
19
19
  * any component and on pages the rooms never draw. So the host states them once
20
20
  * with `configure()`, in a module its root imports, before anything renders.
21
21
  *
22
- * The ROUTER, the query and the route change as the page moves, so they ride
22
+ * The ROUTER, the query, the path and the route change as the page moves, so they ride
23
23
  * React context, and a room reads them with `useRooms()`.
24
24
  */
25
25
  import { createContext, useContext } from 'react';
@@ -35,6 +35,7 @@ const Context = createContext({
35
35
  forward: () => window.history.forward(),
36
36
  },
37
37
  search: null,
38
+ path: null,
38
39
  route: '/',
39
40
  Link: Plain,
40
41
  });
@@ -71,6 +72,6 @@ export function Look() {
71
72
  useLook();
72
73
  return null;
73
74
  }
74
- export function Rooms({ router, search = null, route, Link, children }) {
75
- return _jsx(Context.Provider, { value: { router, search, route, Link }, children: children });
75
+ export function Rooms({ router, search = null, path = null, route, Link, children }) {
76
+ return _jsx(Context.Provider, { value: { router, search, path, route, Link }, children: children });
76
77
  }
@@ -30,30 +30,35 @@ export declare const ENTRY = "https://hanzo.team/?signin";
30
30
  */
31
31
  export declare const START = "https://hanzo.team/start";
32
32
  /**
33
- * The web app, at `/` for a signed-in reader, in two modes. Chat is the default.
34
- * Dev is @hanzo/build's `<Builder>`, and what it shows rides ONE query, `?at=`,
35
- * in the builder's own grammar (`route` in @hanzo/build): '' New, `sess_<id>` a
36
- * run, `-/<screen>`, `-/settings[/<section>]`, `-/customize[/<tab>]`,
37
- * `-/plans`, `<slug>` a project. A query and not a path, because a static
38
- * export has no server to answer a path under `/`.
33
+ * The web app, for a signed-in reader, in two modes. Chat is the default, at
34
+ * `/`. Dev is @hanzo/build's `<Builder>` at `/dev`, and what it shows is the
35
+ * PATH under it, in the builder's own grammar (`route` in @hanzo/build): `/dev`
36
+ * New, `/dev/sess_<id>` a run, `/dev/<org>/<repo>` a project,
37
+ * `/dev/-/<screen>`, `/dev/-/settings[/<section>]`, `/dev/-/customize[/<tab>]`,
38
+ * `/dev/<slug>` a deployed site. The export holds one page for all of them,
39
+ * `/dev`, and the edge serves it for every path under it (hanzo.ai's
40
+ * lib/edge.ts `APP`).
39
41
  */
40
- export declare const AT = "at";
41
- /** `/` in Dev at an app address; '' is Dev's New, `/?at=`. The key is the mode. */
42
+ export declare const DEV = "/dev";
43
+ /** Dev at an app address; '' is Dev's New, `/dev` itself. */
42
44
  export declare const app: (path: string) => string;
45
+ /** The app address a pathname names under `/dev`, or null for a pathname that is not Dev's. */
46
+ export declare const under: (pathname: string | null | undefined) => string | null;
43
47
  /**
44
48
  * Chat, the app's default mode: `/` is a new conversation and `?chat=<id>` one
45
- * of the reader's. So the address says which mode the app is in — `at` for Dev,
46
- * anything else Chat — and a reload keeps it.
49
+ * of the reader's. So the address says which mode the app is in — a path under
50
+ * `/dev` for Dev, anything else Chat — and a reload keeps it.
47
51
  */
48
52
  export declare const TALK = "chat";
49
53
  /** `/` in Chat, on a conversation, or `/` itself for a new one. */
50
54
  export declare const talk: (id?: string | null) => string;
51
55
  /**
52
- * The door opened on one run or project: `?run=` or `?project=`, the query the
53
- * door turns into `?at=` — and the one the console and shared links already
54
- * carry. Only a value that is exactly a session id or a project slug is
55
- * carried; anything else opens /dev itself, so no caller can make this name
56
- * another address.
56
+ * Dev opened on one run, project or site, from anywhere: every "Try Hanzo Dev"
57
+ * link, a room's Dev, the console's "open in the builder". It signs a stranger
58
+ * in on the way (app/(app)/dev/_door.tsx), and is reached through `site()`:
59
+ * here on hanzo.ai, across to hanzo.ai from hanzo.team. Only a value that is
60
+ * exactly a session id, a repository or a site's slug is carried; anything else
61
+ * opens /dev itself, so no caller can make this name another address.
57
62
  */
58
63
  export declare const dev: (ref?: string | null) => string;
59
64
  /**
package/dist/lib/host.js CHANGED
@@ -32,44 +32,59 @@ export const ENTRY = `${WORKSPACE}/?signin`;
32
32
  */
33
33
  export const START = `${WORKSPACE}/start`;
34
34
  /**
35
- * The web app, at `/` for a signed-in reader, in two modes. Chat is the default.
36
- * Dev is @hanzo/build's `<Builder>`, and what it shows rides ONE query, `?at=`,
37
- * in the builder's own grammar (`route` in @hanzo/build): '' New, `sess_<id>` a
38
- * run, `-/<screen>`, `-/settings[/<section>]`, `-/customize[/<tab>]`,
39
- * `-/plans`, `<slug>` a project. A query and not a path, because a static
40
- * export has no server to answer a path under `/`.
35
+ * The web app, for a signed-in reader, in two modes. Chat is the default, at
36
+ * `/`. Dev is @hanzo/build's `<Builder>` at `/dev`, and what it shows is the
37
+ * PATH under it, in the builder's own grammar (`route` in @hanzo/build): `/dev`
38
+ * New, `/dev/sess_<id>` a run, `/dev/<org>/<repo>` a project,
39
+ * `/dev/-/<screen>`, `/dev/-/settings[/<section>]`, `/dev/-/customize[/<tab>]`,
40
+ * `/dev/<slug>` a deployed site. The export holds one page for all of them,
41
+ * `/dev`, and the edge serves it for every path under it (hanzo.ai's
42
+ * lib/edge.ts `APP`).
41
43
  */
42
- export const AT = 'at';
43
- /** `/` in Dev at an app address; '' is Dev's New, `/?at=`. The key is the mode. */
44
- export const app = (path) => `/?${AT}=${path}`;
44
+ export const DEV = '/dev';
45
+ /** Dev at an app address; '' is Dev's New, `/dev` itself. */
46
+ export const app = (path) => {
47
+ const p = path.replace(/^\/+|\/+$/g, '');
48
+ return p ? `${DEV}/${p}` : DEV;
49
+ };
50
+ /** The app address a pathname names under `/dev`, or null for a pathname that is not Dev's. */
51
+ export const under = (pathname) => {
52
+ if (!pathname)
53
+ return null;
54
+ if (pathname === DEV || pathname === `${DEV}/`)
55
+ return '';
56
+ if (!pathname.startsWith(`${DEV}/`))
57
+ return null;
58
+ try {
59
+ return decodeURIComponent(pathname.slice(DEV.length + 1));
60
+ }
61
+ catch {
62
+ return '';
63
+ }
64
+ };
45
65
  /**
46
66
  * Chat, the app's default mode: `/` is a new conversation and `?chat=<id>` one
47
- * of the reader's. So the address says which mode the app is in — `at` for Dev,
48
- * anything else Chat — and a reload keeps it.
67
+ * of the reader's. So the address says which mode the app is in — a path under
68
+ * `/dev` for Dev, anything else Chat — and a reload keeps it.
49
69
  */
50
70
  export const TALK = 'chat';
51
71
  /** `/` in Chat, on a conversation, or `/` itself for a new one. */
52
72
  export const talk = (id) => (id ? `/?${TALK}=${encodeURIComponent(id)}` : '/');
53
- /**
54
- * The door into the app: every "Try Hanzo Dev" link, the rail's Dev, the
55
- * console's "open in the builder", dev.hanzo.ai. It signs a stranger in and
56
- * forwards a member to Dev in the app, `/?at=` (app/(app)/dev/_door.tsx). It is
57
- * the app's page, so it is reached through `site()`: here on hanzo.ai, across
58
- * to hanzo.ai from hanzo.team.
59
- */
60
- const DEV = '/dev';
61
73
  /** A run, as the platform mints its id. */
62
74
  const SESSION = /^sess_[0-9a-f]{32}$/;
63
- /** A project, as the Sites plane slugs it. */
75
+ /** A deployed site, as the Sites plane slugs it. */
64
76
  const SLUG = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/;
77
+ /** A repository on the forge, `<org>/<name>`. */
78
+ const REPO = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}\/[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
65
79
  /**
66
- * The door opened on one run or project: `?run=` or `?project=`, the query the
67
- * door turns into `?at=` — and the one the console and shared links already
68
- * carry. Only a value that is exactly a session id or a project slug is
69
- * carried; anything else opens /dev itself, so no caller can make this name
70
- * another address.
80
+ * Dev opened on one run, project or site, from anywhere: every "Try Hanzo Dev"
81
+ * link, a room's Dev, the console's "open in the builder". It signs a stranger
82
+ * in on the way (app/(app)/dev/_door.tsx), and is reached through `site()`:
83
+ * here on hanzo.ai, across to hanzo.ai from hanzo.team. Only a value that is
84
+ * exactly a session id, a repository or a site's slug is carried; anything else
85
+ * opens /dev itself, so no caller can make this name another address.
71
86
  */
72
- export const dev = (ref) => site(!ref ? DEV : SESSION.test(ref) ? `${DEV}?run=${ref}` : SLUG.test(ref) ? `${DEV}?project=${ref}` : DEV);
87
+ export const dev = (ref) => site(ref && (SESSION.test(ref) || REPO.test(ref) || SLUG.test(ref)) ? app(ref) : DEV);
73
88
  /** The routes under app/(app) the apex keeps: the app — /chat, a chat shared by link, and /dev. */
74
89
  const KEPT = new Set(['/chat', '/chat/shared', '/dev']);
75
90
  /** Settings, and every page under it: the person's and the organization's, served wherever the app is. */
@@ -14,7 +14,7 @@
14
14
  * who was signed in, which reads as a permissions problem rather than a typo.
15
15
  * Two derivations of one name never agree; this is the one derivation.
16
16
  */
17
- import { AT, TALK } from './host.js';
17
+ import { TALK } from './host.js';
18
18
  import { api } from './api.js';
19
19
  /** The SDK's own keys, `hanzo_iam_`-prefixed. The far side of a boundary. */
20
20
  const PREFIX = 'hanzo_iam_';
@@ -59,12 +59,13 @@ export function live() {
59
59
  /** The attribute on `<html>` that says `/` is showing the app, not the landing. */
60
60
  export const APP = 'data-app';
61
61
  /**
62
- * An address on `/` that names a place in the app — `?at=` in Dev, `?chat` in
63
- * Chat (lib/host.ts `AT`, `TALK`). The app is signed-in only, so a reader
64
- * nobody has signed in is sent to hanzo.id with it rather than shown the
65
- * landing, and it comes back with them.
62
+ * An address on `/` that names a place in the app — `?chat` in Chat
63
+ * (lib/host.ts `TALK`; Dev's places are paths under `/dev`, whose own page
64
+ * signs a stranger in). The app is signed-in only, so a reader nobody has
65
+ * signed in is sent to hanzo.id with it rather than shown the landing, and it
66
+ * comes back with them.
66
67
  */
67
- const PLACE = new RegExp(`[?&](?:${AT}|${TALK})(?:[=&]|$)`);
68
+ const PLACE = new RegExp(`[?&]${TALK}(?:[=&]|$)`);
68
69
  /** Whether `search` names a place in the app. */
69
70
  export const place = (search) => PLACE.test(search);
70
71
  /**
package/dist/pane.d.ts CHANGED
@@ -103,6 +103,8 @@ export declare function shutTab(key: string, id: string): void;
103
103
  export declare function pin(key: string, on: boolean): void;
104
104
  /** The workspace file `id` as the index last said, or undefined until it has. */
105
105
  export declare function useWorkFile(id: string | undefined): WorkFile | undefined;
106
+ /** The object URL over workspace file `id`'s bytes on this machine, or '' where this document never held them. */
107
+ export declare const localOf: (id: string) => string;
106
108
  /**
107
109
  * Takes files into this conversation: each goes straight to the org's
108
110
  * workspace — in parts when it is large, several at once — is registered with
package/dist/pane.js CHANGED
@@ -229,6 +229,13 @@ export function useWorkFile(id) {
229
229
  };
230
230
  }, () => (id ? files.get(id) : undefined), () => undefined);
231
231
  }
232
+ // THE BYTES ON THIS MACHINE, by the workspace file they became. `spend` leaves a
233
+ // held file's object URL standing, so a turn that carried a picture draws it
234
+ // from here with no request, for as long as this document lives. A reload ends
235
+ // it, and the turn reads the workspace's copy instead.
236
+ const local = new Map();
237
+ /** The object URL over workspace file `id`'s bytes on this machine, or '' where this document never held them. */
238
+ export const localOf = (id) => local.get(id) ?? '';
232
239
  // Each held file's upload, by held id: what stops it, and what it settles to.
233
240
  const carrying = new Map();
234
241
  /** How many files go up at once; each large one also sends several parts at once. */
@@ -294,6 +301,7 @@ export async function hold(key, list, via) {
294
301
  const objectKey = chatKey(file);
295
302
  await upload(via.api, bucket, objectKey, file, (sent) => patch(key, h.id, { sent }), stop.signal);
296
303
  const got = await register(via.api, bucket, objectKey);
304
+ local.set(got.id, h.href);
297
305
  patch(key, h.id, fromFile(got));
298
306
  watch(via.api, got, key, h.id);
299
307
  land(got);
@@ -363,8 +371,11 @@ export function drop(key, id) {
363
371
  return was;
364
372
  carrying.get(id)?.stop.abort();
365
373
  carrying.delete(id);
366
- if (going.href)
374
+ if (going.href) {
375
+ if (going.file)
376
+ local.delete(going.file.id);
367
377
  URL.revokeObjectURL(going.href);
378
+ }
368
379
  return { ...was, held: was.held.filter((h) => h.id !== id) };
369
380
  });
370
381
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanzo/rooms",
3
- "version": "0.1.33",
3
+ "version": "0.1.35",
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",