@hanzo/rooms 0.1.32 → 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/Account.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /** The block at the foot of the sidebar: press it for the menu. */
2
- export declare function Account({ onToggleCollapse, onProfile, onAppearance, onUsage, }?: {
2
+ export declare function Account({ onToggleCollapse, onProfile, onAppearance, onUsage, onKeys, }?: {
3
3
  onToggleCollapse?: () => void;
4
4
  /** Opens the profile beside the room. Absent, the row goes to hanzo.id. */
5
5
  onProfile?: () => void;
@@ -7,4 +7,6 @@ export declare function Account({ onToggleCollapse, onProfile, onAppearance, onU
7
7
  onAppearance?: () => void;
8
8
  /** Opens the plan's usage settings. Absent, the row is not drawn. */
9
9
  onUsage?: () => void;
10
+ /** Opens the API keys settings. Absent, the row is not drawn. */
11
+ onKeys?: () => void;
10
12
  }): import("react").JSX.Element;
package/dist/Account.js CHANGED
@@ -3,13 +3,14 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  /**
4
4
  * WHO YOU ARE, at the foot of the sidebar: the person, and the person's menu.
5
5
  *
6
- * Profile, appearance, usage, security and sign out — the account, and nothing that
7
- * belongs to the workspace. Which workspace you stand in is the switcher at the
8
- * TOP of the sidebar, and what it may spend is its Billing, so this card shows
9
- * neither: a balance here read as the person's money when it is the workspace's.
6
+ * Profile, appearance, usage, API keys, security and sign out — the account, and
7
+ * nothing that belongs to the workspace. Which workspace you stand in is the
8
+ * switcher at the TOP of the sidebar, and what it may spend is its Billing, so
9
+ * this card shows neither: a balance here read as the person's money when it is
10
+ * the workspace's.
10
11
  */
11
12
  import { useEffect, useRef, useState } from 'react';
12
- import { Activity, ChevronsUpDown, LogOut, PanelLeftClose, Palette, Shield, UserRound } from 'lucide-react';
13
+ import { Activity, ChevronsUpDown, KeyRound, LogOut, PanelLeftClose, Palette, Shield, UserRound } from 'lucide-react';
13
14
  import { XStack, YStack, Text, Box } from '@hanzo/ui';
14
15
  import { sheet } from '@hanzo/ui/glass';
15
16
  import { useIam } from '@hanzo/iam/react';
@@ -22,7 +23,7 @@ function Row({ icon, onPress, children, }) {
22
23
  return (_jsx(Box, { render: "button", onClick: onPress, width: "100%", px: "$3", py: "$2", rounded: "$2", hoverStyle: { bg: '$hover' }, children: _jsxs(XStack, { items: "center", gap: "$2", children: [icon, _jsx(Text, { fontSize: "$2", color: "$ink", children: children })] }) }));
23
24
  }
24
25
  /** The block at the foot of the sidebar: press it for the menu. */
25
- export function Account({ onToggleCollapse, onProfile, onAppearance, onUsage, } = {}) {
26
+ export function Account({ onToggleCollapse, onProfile, onAppearance, onUsage, onKeys, } = {}) {
26
27
  const { user } = useIam();
27
28
  const logout = useSignOut();
28
29
  const { user: account } = useAccount();
@@ -54,5 +55,5 @@ export function Account({ onToggleCollapse, onProfile, onAppearance, onUsage, }
54
55
  // @ts-expect-error — ref is forwarded to the DOM node by the primitive.
55
56
  , {
56
57
  // @ts-expect-error — ref is forwarded to the DOM node by the primitive.
57
- ref: holder, position: "relative", borderTopWidth: 1, borderColor: "$borderColor", children: [open ? (_jsxs(YStack, { role: "menu", "aria-label": "Account", position: "absolute", b: "calc(100% + 6px)", l: "$2", r: "$2", rounded: "$3", borderWidth: 1, borderColor: "$borderColor", ...sheet(2), py: "$2", gap: "$1", z: "var(--z-modal)", children: [_jsx(Row, { icon: _jsx(UserRound, { size: 15, "aria-hidden": true }), onPress: act(() => (onProfile ? onProfile() : window.location.assign(link(`${iam()}/account`)))), children: "Profile" }), onAppearance ? (_jsx(Row, { icon: _jsx(Palette, { size: 15, "aria-hidden": true }), onPress: act(onAppearance), children: "Appearance" })) : null, onUsage ? (_jsx(Row, { icon: _jsx(Activity, { size: 15, "aria-hidden": true }), onPress: act(onUsage), children: "Usage" })) : null, _jsx(Row, { icon: _jsx(Shield, { size: 15, "aria-hidden": true }), onPress: act(() => window.location.assign(link(`${iam()}/account/security`))), children: "Security" }), _jsx(Box, { height: 1, bg: "$borderColor", my: "$1" }), _jsx(Row, { icon: _jsx(LogOut, { size: 15, "aria-hidden": true }), onPress: act(() => void logout()), children: "Sign out" })] })) : null, _jsxs(XStack, { items: "center", width: "100%", children: [_jsx(Box, { render: "button", onClick: () => setOpen((v) => !v), "aria-expanded": open, "aria-haspopup": "menu", "aria-label": "Account", flex: 1, minW: 0, px: "$3", py: "$2", hoverStyle: { bg: '$hover' }, children: _jsxs(XStack, { items: "center", gap: "$2", minW: 0, children: [_jsx(Face, { src: face, name: called, size: 22 }), _jsxs(YStack, { flex: 1, minW: 0, items: "flex-start", children: [_jsx(Text, { fontSize: "$2", fontWeight: "600", color: "$color", numberOfLines: 1, children: called }), email && email !== called ? (_jsx(Text, { fontSize: "$1", color: "$soft", numberOfLines: 1, children: email })) : null] }), _jsx(ChevronsUpDown, { size: 14, "aria-hidden": true })] }) }), onToggleCollapse ? (_jsx(Box, { render: "button", onClick: onToggleCollapse, "aria-label": "Collapse sidebar", p: "$2", mr: "$1.5", rounded: "$2", hoverStyle: { bg: '$hover' }, cursor: "pointer", children: _jsx(PanelLeftClose, { size: 16, "aria-hidden": true }) })) : null] })] }));
58
+ ref: holder, position: "relative", borderTopWidth: 1, borderColor: "$borderColor", children: [open ? (_jsxs(YStack, { role: "menu", "aria-label": "Account", position: "absolute", b: "calc(100% + 6px)", l: "$2", r: "$2", rounded: "$3", borderWidth: 1, borderColor: "$borderColor", ...sheet(2), py: "$2", gap: "$1", z: "var(--z-modal)", children: [_jsx(Row, { icon: _jsx(UserRound, { size: 15, "aria-hidden": true }), onPress: act(() => (onProfile ? onProfile() : window.location.assign(link(`${iam()}/account`)))), children: "Profile" }), onAppearance ? (_jsx(Row, { icon: _jsx(Palette, { size: 15, "aria-hidden": true }), onPress: act(onAppearance), children: "Appearance" })) : null, onUsage ? (_jsx(Row, { icon: _jsx(Activity, { size: 15, "aria-hidden": true }), onPress: act(onUsage), children: "Usage" })) : null, onKeys ? (_jsx(Row, { icon: _jsx(KeyRound, { size: 15, "aria-hidden": true }), onPress: act(onKeys), children: "API keys" })) : null, _jsx(Row, { icon: _jsx(Shield, { size: 15, "aria-hidden": true }), onPress: act(() => window.location.assign(link(`${iam()}/account/security`))), children: "Security" }), _jsx(Box, { height: 1, bg: "$borderColor", my: "$1" }), _jsx(Row, { icon: _jsx(LogOut, { size: 15, "aria-hidden": true }), onPress: act(() => void logout()), children: "Sign out" })] })) : null, _jsxs(XStack, { items: "center", width: "100%", children: [_jsx(Box, { render: "button", onClick: () => setOpen((v) => !v), "aria-expanded": open, "aria-haspopup": "menu", "aria-label": "Account", flex: 1, minW: 0, px: "$3", py: "$2", hoverStyle: { bg: '$hover' }, children: _jsxs(XStack, { items: "center", gap: "$2", minW: 0, children: [_jsx(Face, { src: face, name: called, size: 22 }), _jsxs(YStack, { flex: 1, minW: 0, items: "flex-start", children: [_jsx(Text, { fontSize: "$2", fontWeight: "600", color: "$color", numberOfLines: 1, children: called }), email && email !== called ? (_jsx(Text, { fontSize: "$1", color: "$soft", numberOfLines: 1, children: email })) : null] }), _jsx(ChevronsUpDown, { size: 14, "aria-hidden": true })] }) }), onToggleCollapse ? (_jsx(Box, { render: "button", onClick: onToggleCollapse, "aria-label": "Collapse sidebar", p: "$2", mr: "$1.5", rounded: "$2", hoverStyle: { bg: '$hover' }, cursor: "pointer", children: _jsx(PanelLeftClose, { size: 16, "aria-hidden": true }) })) : null] })] }));
58
59
  }
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}`
package/dist/Me.js CHANGED
@@ -8,8 +8,8 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
8
8
  // switches with `pick()` (lib/auth/session.ts), the one switch the app has.
9
9
  // Under the list: Create organization, Organization settings, and Hanzo Team —
10
10
  // opened in the same organization for a team, offered as "Create a team" for a
11
- // personal one. Then the balance with Add funds, Settings, Usage, the plans,
12
- // help and Log out. A SuperAdmin also gets All organizations (support mode).
11
+ // personal one. Then the balance with Add funds, Settings, Usage, API keys, the
12
+ // plans, help and Log out. A SuperAdmin also gets All organizations (support mode).
13
13
  //
14
14
  // IAM is the source of every fact here: names and roles from the token's `orgs`
15
15
  // claim, display names and which org is personal from `GET /v1/iam/organizations`,
@@ -18,7 +18,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
18
18
  // Orgs.tsx); the next load mints a token that carries it.
19
19
  import { useEffect, useRef, useState } from 'react';
20
20
  import { Popover, SizableText, View, XStack, YStack } from '@hanzo/gui';
21
- import { Building2, ChevronsUpDown, ExternalLink, Gauge, LifeBuoy, LogOut, Plus, Settings, Sparkles, Users, X } from 'lucide-react';
21
+ import { Building2, ChevronsUpDown, ExternalLink, Gauge, KeyRound, LifeBuoy, LogOut, Plus, Settings, Sparkles, Users, X } from 'lucide-react';
22
22
  import { path, useWho } from '@hanzo/build';
23
23
  import { useOrganizations } from '@hanzo/iam/react';
24
24
  import { Button, Dialog, DialogContent, DialogTitle, Input } from '@hanzo/ui';
@@ -39,6 +39,8 @@ import { BAD } from './lib/mix.js';
39
39
  const DOCS = 'https://docs.hanzo.ai/docs/dev';
40
40
  /** Where an organization's own settings live. */
41
41
  const ORG_SETTINGS = '/settings/organization';
42
+ /** The organization's API keys: many per person, each created and revoked on its own. */
43
+ const KEYS = `${ORG_SETTINGS}/keys`;
42
44
  /** An organization this tab created, until the token carries it. */
43
45
  const MADE = 'hanzo:org:made';
44
46
  /** A role as a person reads it: `owner` → Owner. */
@@ -166,7 +168,7 @@ export function Me({ host, narrow = false, onOrg, }) {
166
168
  menu.onOpenChange(false);
167
169
  if (o.name !== current)
168
170
  onOrg(o.name);
169
- } }, o.name))) }), _jsx(Item, { icon: _jsx(Plus, { size: 15, "aria-hidden": true }), label: "Create organization", onPress: () => (menu.onOpenChange(false), setCreating(true)) }), _jsx(Item, { icon: _jsx(Settings, { size: 15, "aria-hidden": true }), label: "Organization settings", onPress: () => (menu.onOpenChange(false), window.location.assign(ORG_SETTINGS)) }), where().team ? null : here && !here.personal ? (_jsx(Item, { icon: _jsx(Users, { size: 15, "aria-hidden": true }), label: "Open in Hanzo Team", trail: _jsx(ExternalLink, { size: 13, "aria-hidden": true, opacity: 0.6 }), onPress: () => (menu.onOpenChange(false), window.location.assign(`${ENTRY}&org=${encodeURIComponent(here.name)}`)) })) : (_jsx(Item, { icon: _jsx(Users, { size: 15, "aria-hidden": true }), label: "Create a team", onPress: () => (menu.onOpenChange(false), setCreating(true)) })), superAdmin() ? (_jsx(Item, { icon: _jsx(Building2, { size: 15, "aria-hidden": true }), label: "All organizations", onPress: () => (menu.onOpenChange(false), setSupporting(true)) })) : null, _jsx(Rule, {}), _jsx(Item, { icon: _jsx(Sparkles, { size: 15, "aria-hidden": true }), label: "Add funds", sub: typeof tier?.balance?.effectiveAvailable === 'number' ? `Credits ${formatCents(tier.balance.effectiveAvailable)}` : 'Credits', onPress: () => (menu.onOpenChange(false), window.open(payPage(), '_blank', 'noopener')), trail: _jsx(ExternalLink, { size: 13, "aria-hidden": true, opacity: 0.6 }) }), _jsx(Item, { icon: _jsx(Settings, { size: 15, "aria-hidden": true }), label: "Settings", onPress: () => go(path({ kind: 'settings', section: 'general' })) }), _jsx(Item, { icon: _jsx(Gauge, { size: 15, "aria-hidden": true }), label: "Usage", onPress: () => go(path({ kind: 'settings', section: 'usage' })) }), _jsx(Item, { icon: _jsx(Sparkles, { size: 15, "aria-hidden": true }), label: "View all plans", onPress: () => go(path({ kind: 'screen', screen: 'plans' })) }), _jsx(Item, { icon: _jsx(LifeBuoy, { size: 15, "aria-hidden": true }), label: "Get help", onPress: () => (menu.onOpenChange(false), window.open(DOCS, '_blank', 'noopener,noreferrer')) }), host.signOut ? (_jsxs(_Fragment, { children: [_jsx(Rule, {}), _jsx(Item, { icon: _jsx(LogOut, { size: 15, "aria-hidden": true }), label: "Log out", onPress: () => (menu.onOpenChange(false), host.signOut?.()) })] })) : null] }) }) })] }), _jsx(Create, { open: creating, onOpenChange: setCreating }), supporting ? _jsx(SupportPicker, { onClose: () => setSupporting(false) }) : null] }));
171
+ } }, o.name))) }), _jsx(Item, { icon: _jsx(Plus, { size: 15, "aria-hidden": true }), label: "Create organization", onPress: () => (menu.onOpenChange(false), setCreating(true)) }), _jsx(Item, { icon: _jsx(Settings, { size: 15, "aria-hidden": true }), label: "Organization settings", onPress: () => (menu.onOpenChange(false), window.location.assign(ORG_SETTINGS)) }), where().team ? null : here && !here.personal ? (_jsx(Item, { icon: _jsx(Users, { size: 15, "aria-hidden": true }), label: "Open in Hanzo Team", trail: _jsx(ExternalLink, { size: 13, "aria-hidden": true, opacity: 0.6 }), onPress: () => (menu.onOpenChange(false), window.location.assign(`${ENTRY}&org=${encodeURIComponent(here.name)}`)) })) : (_jsx(Item, { icon: _jsx(Users, { size: 15, "aria-hidden": true }), label: "Create a team", onPress: () => (menu.onOpenChange(false), setCreating(true)) })), superAdmin() ? (_jsx(Item, { icon: _jsx(Building2, { size: 15, "aria-hidden": true }), label: "All organizations", onPress: () => (menu.onOpenChange(false), setSupporting(true)) })) : null, _jsx(Rule, {}), _jsx(Item, { icon: _jsx(Sparkles, { size: 15, "aria-hidden": true }), label: "Add funds", sub: typeof tier?.balance?.effectiveAvailable === 'number' ? `Credits ${formatCents(tier.balance.effectiveAvailable)}` : 'Credits', onPress: () => (menu.onOpenChange(false), window.open(payPage(), '_blank', 'noopener')), trail: _jsx(ExternalLink, { size: 13, "aria-hidden": true, opacity: 0.6 }) }), _jsx(Item, { icon: _jsx(Settings, { size: 15, "aria-hidden": true }), label: "Settings", onPress: () => go(path({ kind: 'settings', section: 'general' })) }), _jsx(Item, { icon: _jsx(Gauge, { size: 15, "aria-hidden": true }), label: "Usage", onPress: () => go(path({ kind: 'settings', section: 'usage' })) }), _jsx(Item, { icon: _jsx(KeyRound, { size: 15, "aria-hidden": true }), label: "API keys", onPress: () => (menu.onOpenChange(false), window.location.assign(KEYS)) }), _jsx(Item, { icon: _jsx(Sparkles, { size: 15, "aria-hidden": true }), label: "View all plans", onPress: () => go(path({ kind: 'screen', screen: 'plans' })) }), _jsx(Item, { icon: _jsx(LifeBuoy, { size: 15, "aria-hidden": true }), label: "Get help", onPress: () => (menu.onOpenChange(false), window.open(DOCS, '_blank', 'noopener,noreferrer')) }), host.signOut ? (_jsxs(_Fragment, { children: [_jsx(Rule, {}), _jsx(Item, { icon: _jsx(LogOut, { size: 15, "aria-hidden": true }), label: "Log out", onPress: () => (menu.onOpenChange(false), host.signOut?.()) })] })) : null] }) }) })] }), _jsx(Create, { open: creating, onOpenChange: setCreating }), supporting ? _jsx(SupportPicker, { onClose: () => setSupporting(false) }) : null] }));
170
172
  }
171
173
  /**
172
174
  * The sheet that creates an organization: a name, then `POST /v1/account/orgs`,
@@ -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/Shell.js CHANGED
@@ -1223,7 +1223,7 @@ function Frame({ mode, children, navigate, back, forward, }) {
1223
1223
  placeholder: "Filter", "aria-label": "Filter sidebar" }), flex: 1, minW: 0, bg: "transparent", borderWidth: 0, outlineStyle: "none", color: "inherit", fontSize: "$2" })] }) }), settingsPane ? (_jsx(SidebarScroll, { children: _jsx(SettingsList, { active: settingsPane, filter: filter, onPick: pick, onBack: () => showSettings(null) }) })) : (_jsx(_Fragment, { children: _jsx(SidebarScroll, { children: _jsxs(_Fragment, { children: [settled ? (_jsx(Index, { mode: mode, owner: owner, filter: filter, onPick: onPick, navigate: navigate })) : null, products.length ? (_jsx(SidebarFolder, { name: "Cloud & Platform", open: filter ? true : undefined, children: products.map((prod) => (_jsx(SidebarItem, { icon: _jsx(prod.icon, { size: 13, "aria-hidden": true }), onPress: () => window.open(prod.href, "_blank", "noopener"), children: prod.label }, prod.id))) })) : null, more.length ? (_jsx(SidebarFolder, { name: "More", open: filter ? true : undefined, children: more.map((tool) => (_jsx(SidebarItem, { icon: _jsx(tool.icon, { size: 13, "aria-hidden": true }), onPress: tool.go, children: tool.label }, tool.id))) })) : null] }) }) })), settled && onApex ? _jsx(Leave, { href: signedIn ? ENTRY : WORKSPACE, children: "Workspace" }) : null, settled ? (signedIn ? (_jsxs(_Fragment, { children: [superAdmin() ? (_jsx(SidebarItem, { icon: _jsx(Building2, { size: 15, "aria-hidden": true }), onPress: () => setSupporting(true), children: "All organizations" })) : null, _jsx(SidebarItem, { icon: _jsx(UserPlus, { size: 15, "aria-hidden": true }), onPress: () => window.open(invite(), "_blank", "noopener"), children: "Invite Teammates" }), _jsx(SidebarItem, { icon: _jsx(Share2, { size: 15, "aria-hidden": true }), onPress: () => {
1224
1224
  navigate?.(site("/referral"));
1225
1225
  dismiss();
1226
- }, children: "Share Hanzo" }), _jsx(SidebarItem, { icon: _jsx(Sparkles, { size: 15, "aria-hidden": true }), onPress: () => window.open(BILLING, "_blank", "noopener"), children: "Upgrade" }), _jsx(Account, { onToggleCollapse: toggle, onProfile: () => setProfiling(true), onAppearance: () => showSettings("general"), onUsage: () => showSettings("usage") })] })) : null) : null] }));
1226
+ }, children: "Share Hanzo" }), _jsx(SidebarItem, { icon: _jsx(Sparkles, { size: 15, "aria-hidden": true }), onPress: () => window.open(BILLING, "_blank", "noopener"), children: "Upgrade" }), _jsx(Account, { onToggleCollapse: toggle, onProfile: () => setProfiling(true), onAppearance: () => showSettings("general"), onUsage: () => showSettings("usage"), onKeys: () => showSettings("keys") })] })) : null) : null] }));
1227
1227
  return (_jsxs(YStack, { "data-slot": "workspace", "data-hydrated": settled ? "true" : undefined, "data-sidebar": asked ? "open" : "shut", display: "grid", gridTemplateColumns: `${asked ? 0 : 64}px auto minmax(0, 1fr) auto`, gridTemplateRows: "44px minmax(0, 1fr)", gridTemplateAreas: '"rail bar bar bar" "rail list pane side"', "$max-md": {
1228
1228
  gridTemplateColumns: "minmax(0, 1fr)",
1229
1229
  gridTemplateRows: `44px minmax(0, 1fr) ${TABS_HEIGHT}`,
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.32",
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",