@hanzo/rooms 0.1.33 → 0.1.34

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/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/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.34",
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",