@hanzo/rooms 0.1.10 → 0.1.12

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/pane.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type Admitted } from './lib/attach.js';
1
+ import { type Api, type WorkFile } from './lib/files.js';
2
2
  /** One open tab in the pane's browser. */
3
3
  export interface Leaf {
4
4
  id: string;
@@ -8,20 +8,34 @@ export interface Leaf {
8
8
  title: string;
9
9
  }
10
10
  /**
11
- * A file the reader put into this conversation from their own disk, read and
12
- * waiting to go with the next message.
11
+ * A file put into this conversation, on its way to the workspace or already
12
+ * there, waiting to go with the next message.
13
13
  *
14
14
  * `href` is an object URL and is the reason this is never written down — see
15
- * the note at the top. Everything else is read off the `File` itself, so a name
16
- * and a size here are the file system's, never a guess.
15
+ * the note at the top. A name and a size are the file system's, or the
16
+ * workspace file's, never a guess.
17
17
  */
18
- export interface Held extends Admitted {
18
+ export interface Held {
19
19
  id: string;
20
20
  name: string;
21
21
  size: number;
22
22
  /** The browser's type for it, or '' where it could not tell from the name. */
23
23
  type: string;
24
+ /** An object URL over the bytes on this machine; '' for a file taken from Drive. */
24
25
  href: string;
26
+ /** Sending its bytes, then the index's own word for how far it has got. */
27
+ state: 'uploading' | WorkFile['status'];
28
+ /** The fraction of the bytes the store holds, while uploading. */
29
+ sent: number;
30
+ /** The workspace file, once the upload landed and was registered. */
31
+ file?: WorkFile;
32
+ /** Why it failed, in the reader's words. */
33
+ error?: string;
34
+ }
35
+ /** What carries a file: the client that reaches the platform, and the org whose workspace it lands in. */
36
+ export interface Via {
37
+ api: Api;
38
+ org: string | null;
25
39
  }
26
40
  /** Everything the column beside one conversation is holding. */
27
41
  export interface Pane {
@@ -87,14 +101,22 @@ export declare function pickTab(key: string, id: string): void;
87
101
  export declare function shutTab(key: string, id: string): void;
88
102
  /** Shows or hides the summary column beside this conversation's browser. */
89
103
  export declare function pin(key: string, on: boolean): void;
104
+ /** The workspace file `id` as the index last said, or undefined until it has. */
105
+ export declare function useWorkFile(id: string | undefined): WorkFile | undefined;
90
106
  /**
91
- * Takes files from the reader's disk into this conversation.
92
- *
93
- * Each is judged and read by `admit`; what is refused is said by name in
94
- * `refused` and nothing of it is held, so a chip never stands for a file that
95
- * cannot go.
107
+ * Takes files into this conversation: each goes straight to the org's
108
+ * workspace — in parts when it is large, several at once — is registered with
109
+ * the index, and is followed through its stages. Nothing is refused for its
110
+ * size or its kind: a file the index cannot read is still kept, and says why.
111
+ */
112
+ export declare function hold(key: string, list: readonly File[], via: Via): Promise<void>;
113
+ /**
114
+ * Puts files already in the workspace into this conversation — Drive's "Ask in
115
+ * chat". Nothing is uploaded; each is followed if its ingest has not settled.
96
116
  */
97
- export declare function hold(key: string, files: readonly File[]): Promise<void>;
117
+ export declare function attach(key: string, list: readonly WorkFile[], via: Via): void;
118
+ /** The workspace file a held file became, once its upload settles; null when it did not. */
119
+ export declare function landed(h: Held): Promise<WorkFile | null>;
98
120
  /**
99
121
  * Asks for files and holds what is given.
100
122
  *
@@ -103,16 +125,16 @@ export declare function hold(key: string, files: readonly File[]): Promise<void>
103
125
  * `<input type=file>` parked in the tree is one more thing for a screen reader
104
126
  * to walk past.
105
127
  */
106
- export declare function take(key: string): void;
128
+ export declare function take(key: string, via: Via): void;
107
129
  /**
108
130
  * Hands over everything held to go with a message, and lets go of it.
109
131
  *
110
- * The turn carries each file from here — an image as its `data:` URI, a text
111
- * file as its words. The object URLs are left standing, so a tab already open
112
- * on one keeps showing it.
132
+ * The turn carries each file by reference from here. The object URLs are left
133
+ * standing, so a tab already open on one keeps showing it, and an upload still
134
+ * running keeps running: the turn names the file it lands as.
113
135
  */
114
136
  export declare function spend(key: string): Held[];
115
- /** Lets go of a file, and of the handle the browser minted for it. */
137
+ /** Lets go of a file: stops its upload if it is still going, and drops the handle the browser minted. */
116
138
  export declare function drop(key: string, id: string): void;
117
139
  /** This conversation's pane, and it alone — a change to another redraws nothing
118
140
  * here, because the snapshot returned is that key's own object. */
package/dist/pane.js CHANGED
@@ -22,12 +22,13 @@
22
22
  // worse than no row, so they are held for the session and no longer.
23
23
  //
24
24
  // ONE WAY IN FOR A FILE. The composer's paperclip, a drop on the room, a paste
25
- // into the field and the column's `+` all call `hold`, so a file is judged by
26
- // one rule (`lib/attach`), shows as a chip over the field and a row under
27
- // Sources at once, and goes with the next message. Sending spends what is
28
- // held; from then on the turn itself is the record Sources reads.
25
+ // into the field and the column's `+` all call `hold`, so a file goes up by one
26
+ // road (`lib/files`: straight to the org's workspace, in parts when it is
27
+ // large), shows as a chip over the field and a row under Sources at once, and
28
+ // goes with the next message by reference. Sending spends what is held; from
29
+ // then on the turn itself is the record Sources reads.
29
30
  import { useSyncExternalStore } from 'react';
30
- import { admit } from './lib/attach.js';
31
+ import { chatKey, follow, pool, register, settled, upload, workspace } from './lib/files.js';
31
32
  /** A pane nobody has touched. Shared, and never mutated — see `EMPTY` in
32
33
  * `open.ts` for why a fresh object per read is an infinite render. */
33
34
  const BARE = { tabs: [], at: null, pinned: true, held: [], refused: null };
@@ -209,27 +210,119 @@ export function shutTab(key, id) {
209
210
  export function pin(key, on) {
210
211
  edit(key, (was) => (was.pinned === on ? was : { ...was, pinned: on }));
211
212
  }
213
+ // THE WORKSPACE FILES THIS DOCUMENT HAS SEEN, by id, as the index last said.
214
+ // A chip over the field and a chip in a sent turn read one record, so a file
215
+ // that finishes indexing after the message went shows ready in both.
216
+ const files = new Map();
217
+ const fileListeners = new Set();
218
+ function know(f) {
219
+ files.set(f.id, f);
220
+ for (const l of fileListeners)
221
+ l();
222
+ }
223
+ /** The workspace file `id` as the index last said, or undefined until it has. */
224
+ export function useWorkFile(id) {
225
+ return useSyncExternalStore((l) => {
226
+ fileListeners.add(l);
227
+ return () => {
228
+ fileListeners.delete(l);
229
+ };
230
+ }, () => (id ? files.get(id) : undefined), () => undefined);
231
+ }
232
+ // Each held file's upload, by held id: what stops it, and what it settles to.
233
+ const carrying = new Map();
234
+ /** How many files go up at once; each large one also sends several parts at once. */
235
+ const FILES_AT_ONCE = 3;
236
+ function patch(key, id, fields) {
237
+ edit(key, (was) => {
238
+ const at = was.held.findIndex((h) => h.id === id);
239
+ if (at < 0)
240
+ return was;
241
+ const held = was.held.slice();
242
+ held[at] = { ...held[at], ...fields };
243
+ return { ...was, held };
244
+ });
245
+ }
246
+ const fromFile = (f) => ({ state: f.status, sent: 1, file: f, error: f.status === 'failed' || f.status === 'stored' ? f.error : undefined });
247
+ /** Watches a workspace file until its ingest settles, keeping every chip that names it current. */
248
+ function watch(api, f, key, id) {
249
+ know(f);
250
+ if (settled(f))
251
+ return;
252
+ void follow(api, f.id, (seen) => {
253
+ know(seen);
254
+ if (key && id)
255
+ patch(key, id, fromFile(seen));
256
+ }).catch(() => { });
257
+ }
212
258
  /**
213
- * Takes files from the reader's disk into this conversation.
214
- *
215
- * Each is judged and read by `admit`; what is refused is said by name in
216
- * `refused` and nothing of it is held, so a chip never stands for a file that
217
- * cannot go.
259
+ * Takes files into this conversation: each goes straight to the org's
260
+ * workspace — in parts when it is large, several at once — is registered with
261
+ * the index, and is followed through its stages. Nothing is refused for its
262
+ * size or its kind: a file the index cannot read is still kept, and says why.
218
263
  */
219
- export async function hold(key, files) {
220
- if (!files.length)
264
+ export async function hold(key, list, via) {
265
+ if (!list.length)
266
+ return;
267
+ if (!via.org) {
268
+ edit(key, (was) => ({ ...was, refused: 'Sign in to attach files: they are kept in your workspace Drive.' }));
221
269
  return;
222
- const judged = await Promise.all(files.map(admit));
223
- const taken = [];
224
- const refused = [];
225
- judged.forEach((one, i) => {
226
- const file = files[i];
227
- if (typeof one === 'string')
228
- refused.push(one);
229
- else
230
- taken.push({ id: mint('h'), name: file.name, size: file.size, type: file.type, href: URL.createObjectURL(file), ...one });
270
+ }
271
+ const org = via.org;
272
+ const taken = list.map((file) => ({
273
+ id: mint('h'),
274
+ name: file.name,
275
+ size: file.size,
276
+ type: file.type,
277
+ href: URL.createObjectURL(file),
278
+ state: 'uploading',
279
+ sent: 0,
280
+ }));
281
+ edit(key, (was) => ({ ...was, held: [...was.held, ...taken], refused: null }));
282
+ const jobs = taken.map((h, i) => {
283
+ const stop = new AbortController();
284
+ let land;
285
+ const done = new Promise((r) => (land = r));
286
+ carrying.set(h.id, { stop, done });
287
+ return { h, file: list[i], stop, land };
288
+ });
289
+ await pool(jobs, FILES_AT_ONCE, async ({ h, file, stop, land }) => {
290
+ try {
291
+ if (stop.signal.aborted)
292
+ return land(null);
293
+ const bucket = await workspace(via.api, org);
294
+ const objectKey = chatKey(file);
295
+ await upload(via.api, bucket, objectKey, file, (sent) => patch(key, h.id, { sent }), stop.signal);
296
+ const got = await register(via.api, bucket, objectKey);
297
+ patch(key, h.id, fromFile(got));
298
+ watch(via.api, got, key, h.id);
299
+ land(got);
300
+ }
301
+ catch (e) {
302
+ if (!stop.signal.aborted)
303
+ patch(key, h.id, { state: 'failed', error: e?.message || `${file.name} did not upload.` });
304
+ land(null);
305
+ }
231
306
  });
232
- edit(key, (was) => ({ ...was, held: [...was.held, ...taken], refused: refused.length ? refused.join(' ') : null }));
307
+ }
308
+ /**
309
+ * Puts files already in the workspace into this conversation — Drive's "Ask in
310
+ * chat". Nothing is uploaded; each is followed if its ingest has not settled.
311
+ */
312
+ export function attach(key, list, via) {
313
+ const taken = list.map((f) => ({ id: mint('h'), name: f.name, size: f.size, type: f.type, href: '', state: f.status, sent: 1, file: f }));
314
+ edit(key, (was) => ({ ...was, held: [...was.held.filter((h) => !list.some((f) => f.id === h.file?.id)), ...taken], refused: null }));
315
+ taken.forEach((h) => {
316
+ carrying.set(h.id, { stop: new AbortController(), done: Promise.resolve(h.file ?? null) });
317
+ if (h.file)
318
+ watch(via.api, h.file, key, h.id);
319
+ });
320
+ }
321
+ /** The workspace file a held file became, once its upload settles; null when it did not. */
322
+ export function landed(h) {
323
+ if (h.file)
324
+ return Promise.resolve(h.file);
325
+ return carrying.get(h.id)?.done ?? Promise.resolve(null);
233
326
  }
234
327
  /**
235
328
  * Asks for files and holds what is given.
@@ -239,21 +332,21 @@ export async function hold(key, files) {
239
332
  * `<input type=file>` parked in the tree is one more thing for a screen reader
240
333
  * to walk past.
241
334
  */
242
- export function take(key) {
335
+ export function take(key, via) {
243
336
  if (typeof document === 'undefined')
244
337
  return;
245
338
  const ask = document.createElement('input');
246
339
  ask.type = 'file';
247
340
  ask.multiple = true;
248
- ask.onchange = () => void hold(key, Array.from(ask.files ?? []));
341
+ ask.onchange = () => void hold(key, Array.from(ask.files ?? []), via);
249
342
  ask.click();
250
343
  }
251
344
  /**
252
345
  * Hands over everything held to go with a message, and lets go of it.
253
346
  *
254
- * The turn carries each file from here — an image as its `data:` URI, a text
255
- * file as its words. The object URLs are left standing, so a tab already open
256
- * on one keeps showing it.
347
+ * The turn carries each file by reference from here. The object URLs are left
348
+ * standing, so a tab already open on one keeps showing it, and an upload still
349
+ * running keeps running: the turn names the file it lands as.
257
350
  */
258
351
  export function spend(key) {
259
352
  const was = read(key);
@@ -262,13 +355,16 @@ export function spend(key) {
262
355
  write(key, { ...was, held: [], refused: null });
263
356
  return was.held;
264
357
  }
265
- /** Lets go of a file, and of the handle the browser minted for it. */
358
+ /** Lets go of a file: stops its upload if it is still going, and drops the handle the browser minted. */
266
359
  export function drop(key, id) {
267
360
  edit(key, (was) => {
268
361
  const going = was.held.find((h) => h.id === id);
269
362
  if (!going)
270
363
  return was;
271
- URL.revokeObjectURL(going.href);
364
+ carrying.get(id)?.stop.abort();
365
+ carrying.delete(id);
366
+ if (going.href)
367
+ URL.revokeObjectURL(going.href);
272
368
  return { ...was, held: was.held.filter((h) => h.id !== id) };
273
369
  });
274
370
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanzo/rooms",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
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",
@@ -58,7 +58,7 @@
58
58
  "@hanzo/plans": ">=1.8.9",
59
59
  "@hanzo/ui": ">=8.27.51",
60
60
  "@hanzo/usage": ">=0.1.17",
61
- "@hanzo/voice": ">=0.1.10",
61
+ "@hanzo/voice": ">=0.1.11",
62
62
  "@hanzogui/shell": "^8.3.20",
63
63
  "lucide-react": ">=1.42.0",
64
64
  "react": ">=19.2.8",
@@ -77,7 +77,7 @@
77
77
  "@hanzo/plans": "1.8.9",
78
78
  "@hanzo/ui": "8.27.51",
79
79
  "@hanzo/usage": "0.1.17",
80
- "@hanzo/voice": "0.1.10",
80
+ "@hanzo/voice": "0.1.11",
81
81
  "@hanzogui/config": "8.3.5",
82
82
  "@hanzogui/core": "8.3.5",
83
83
  "@hanzogui/lucide-icons-2": "8.3.5",