@hanzo/rooms 0.1.4 → 0.1.5

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
@@ -33,12 +33,13 @@ import { Prose } from "./Prose.js";
33
33
  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
- import { compose, hold, images, partsOf } from "./lib/attach.js";
36
+ import { compose, images, partsOf } from "./lib/attach.js";
37
+ import { channel, drop, hold, spend, take, usePane } from "./pane.js";
37
38
  import { Face, brief as roomBrief, join, roleOf, roomTurn, roster as rosterLine, rosterOf, speakers, speaksOf, voiceOf } from "./cast.js";
38
39
  import { SpeakButton, cleanForSpeech } from "./speech.js";
39
40
  import { Crew, HOUSE, useCrew } from "./crew.js";
40
41
  import { called } from "./team.js";
41
- import { Anchor, Box, Button, Text, View, XStack, YStack } from "@hanzo/ui";
42
+ import { Anchor, Box, Button, Text, Tooltip, TooltipContent, TooltipTrigger, View, XStack, YStack } from "@hanzo/ui";
42
43
  import { Control } from "@hanzo/composer";
43
44
  import { useIam, useIamToken } from "@hanzo/iam/react";
44
45
  import { hasSession, org } from "./lib/session.js";
@@ -48,7 +49,7 @@ import { useHydrated } from "./lib/hydrated.js";
48
49
  import { base, served } from "./lib/ai.js";
49
50
  import { onServed } from "./lib/served.js";
50
51
  import { speech, useVoice, Voice } from "@hanzo/voice";
51
- import { ArrowUp, Mic, Paperclip, Square, Star, PanelRight, X } from "lucide-react";
52
+ import { ArrowUp, Image as ImageMark, Mic, Paperclip, Square, Star, PanelRight, X } from "lucide-react";
52
53
  import { ENSO, FREE } from "./lib/ai.js";
53
54
  import { openThread, useOpen } from "./open.js";
54
55
  import { Beside, Framed } from "./Shell.js";
@@ -331,7 +332,10 @@ function Thread({ placeholder = MESSAGE, initial, anonymous = false, heading, })
331
332
  const ai = useAi();
332
333
  // In the app's frame the composer floats on the pane as a pane of its own.
333
334
  const framed = useContext(Framed);
334
- const { thread: open, open: reopen, agents: room, openAgent, seat, emptied, aside, showAside, showSettings } = useOpen();
335
+ const opened = useOpen();
336
+ const { thread: open, open: reopen, agents: room, openAgent, seat, emptied, aside, showAside, showSettings } = opened;
337
+ // The conversation's key in `pane.ts`, the one the column beside it reads.
338
+ const at = channel(opened);
335
339
  // The first of them: what a surface that addresses one person reads.
336
340
  const withWhom = room[0] ?? null;
337
341
  // Whether the column beside this conversation is showing its summary, and the
@@ -601,23 +605,15 @@ function Thread({ placeholder = MESSAGE, initial, anonymous = false, heading, })
601
605
  // passing value/onChange/onSend takes that ownership over rather than fighting
602
606
  // it.
603
607
  const [draft, setDraft] = useState("");
604
- // FILES WITH THE NEXT MESSAGE: pressed in through the paperclip, or dropped
605
- // anywhere on the room. An image rides as a picture the model reads, a text
606
- // file as its words (lib/attach); anything else is refused by name here.
607
- const [files, setFiles] = useState([]);
608
- const [fileSaid, setFileSaid] = useState(null);
608
+ // FILES WITH THE NEXT MESSAGE, held by `pane.ts` for this conversation: the
609
+ // paperclip, a drop anywhere on the room, a paste into the field and the
610
+ // column's `+` all go through its one `hold`, so the chips here and the rows
611
+ // under Sources are one list. An image rides as a picture the model reads, a
612
+ // text file as its words (lib/attach); anything else is refused by name.
613
+ const { held: files, refused: turnedAway } = usePane(at);
609
614
  const [dragging, setDragging] = useState(false);
610
- const picker = useRef(null);
611
615
  const outgoing = useRef([]);
612
616
  const depth = useRef(0);
613
- const take = useCallback(async (list) => {
614
- if (!list?.length)
615
- return;
616
- const out = await Promise.all(Array.from(list).map(hold));
617
- setFiles((now) => [...now, ...out.filter((one) => typeof one !== "string")]);
618
- const refused = out.filter((one) => typeof one === "string");
619
- setFileSaid(refused.length ? refused.join(" ") : null);
620
- }, []);
621
617
  useSeed(initial, setDraft);
622
618
  // DICTATION through Hanzo's own ear: the mic records and each pause is
623
619
  // posted to /v1/audio/transcriptions (`zen-scribe`). `speech()` defaults to
@@ -798,11 +794,10 @@ function Thread({ placeholder = MESSAGE, initial, anonymous = false, heading, })
798
794
  const text = draft.trim();
799
795
  if (!text && !files.length)
800
796
  return;
801
- outgoing.current = images(files);
797
+ const going = spend(at);
798
+ outgoing.current = images(going);
802
799
  setDraft("");
803
- setFiles([]);
804
- setFileSaid(null);
805
- guarded(compose(text, files));
800
+ guarded(compose(text, going));
806
801
  };
807
802
  /*
808
803
  WHAT AN UNANSWERED QUESTION LOOKS LIKE: a turn of its own directly under it,
@@ -912,8 +907,8 @@ function Thread({ placeholder = MESSAGE, initial, anonymous = false, heading, })
912
907
  if (!e.dataTransfer.files?.length)
913
908
  return;
914
909
  e.preventDefault();
915
- void take(e.dataTransfer.files);
916
- }, children: [dragging ? (_jsx(YStack, { "aria-hidden": true, position: "absolute", t: 0, r: 0, b: 0, l: 0, z: 10, items: "center", justify: "center", bg: "var(--surface-scrim)", borderWidth: 2, borderStyle: "dashed", borderColor: "$borderColor", rounded: "var(--pane-round)", pointerEvents: "none", children: _jsx(Text, { fontSize: "$4", color: "$ink", children: "Drop to attach" }) })) : null, _jsxs(XStack, { height: 48, items: "center", justify: "space-between", px: "$3", borderBottomWidth: 1, borderColor: "$borderColor", children: [_jsxs(XStack, { items: "center", gap: "$2", minW: 0, children: [_jsx(Box, { render: "button", onClick: () => open && star(open), "aria-label": open && starred.has(open) ? "Unstar this conversation" : "Star this conversation", "aria-pressed": !!open && starred.has(open), p: "$1.5", rounded: "$2", hoverStyle: { bg: '$hover' }, children: _jsx(Star, { size: 15, "aria-hidden": true, color: open && starred.has(open) ? "var(--foreground)" : "var(--muted-foreground)", fill: open && starred.has(open) ? "currentColor" : "none" }) }), room.length ? (_jsx(XStack, { role: "list", "aria-label": "Agents in this chat", items: "center", gap: "$1.5", minW: 0, overflowX: "auto", "$platform-web": { scrollbarWidth: "none" }, children: room.map((name) => (_jsxs(XStack, { role: "listitem", items: "center", gap: "$1.5", pl: "$1", pr: "$0.5", py: "$0.5", rounded: 999, borderWidth: 1, borderColor: "$borderColor", shrink: 0, children: [_jsx(Face, { src: faces[name]?.avatar ?? (name === withWhom ? portrait : undefined), emoji: faces[name]?.emoji ?? (name === withWhom ? persona?.emoji : undefined), name: name, size: 22 }), _jsx(Text, { fontSize: "$2", fontWeight: "600", color: "$color", numberOfLines: 1, display: "none", $sm: { display: "flex" }, children: name }), _jsx(Box, { render: "button", onClick: () => openAgent(name), "aria-label": `Remove ${name} from this chat`, title: `Remove ${name}`, width: 24, height: 24, rounded: 999, items: "center", justify: "center", hoverStyle: { bg: "$hover" }, children: _jsx(X, { size: 13, "aria-hidden": true, color: "var(--soft, var(--muted-foreground))" }) })] }, name))) })) : null] }), _jsxs(XStack, { items: "center", gap: "$1", children: [_jsx(Box, { render: "button", onClick: () => {
910
+ void hold(at, Array.from(e.dataTransfer.files));
911
+ }, children: [dragging ? (_jsx(YStack, { "aria-hidden": true, position: "absolute", t: 0, r: 0, b: 0, l: 0, z: 10, items: "center", justify: "center", bg: "var(--surface-scrim)", borderWidth: 2, borderStyle: "dashed", borderColor: "$borderColor", rounded: "var(--pane-round)", pointerEvents: "none", children: _jsxs(XStack, { items: "center", gap: "$2", px: "$4", py: "$2.5", rounded: "$10", bg: "$background", borderWidth: 1, borderColor: "$borderColor", children: [_jsx(Paperclip, { size: 15, "aria-hidden": true }), _jsx(Text, { fontSize: "$4", color: "$ink", children: "Drop files to attach" })] }) })) : null, _jsxs(XStack, { height: 48, items: "center", justify: "space-between", px: "$3", borderBottomWidth: 1, borderColor: "$borderColor", children: [_jsxs(XStack, { items: "center", gap: "$2", minW: 0, children: [_jsx(Box, { render: "button", onClick: () => open && star(open), "aria-label": open && starred.has(open) ? "Unstar this conversation" : "Star this conversation", "aria-pressed": !!open && starred.has(open), p: "$1.5", rounded: "$2", hoverStyle: { bg: '$hover' }, children: _jsx(Star, { size: 15, "aria-hidden": true, color: open && starred.has(open) ? "var(--foreground)" : "var(--muted-foreground)", fill: open && starred.has(open) ? "currentColor" : "none" }) }), room.length ? (_jsx(XStack, { role: "list", "aria-label": "Agents in this chat", items: "center", gap: "$1.5", minW: 0, overflowX: "auto", "$platform-web": { scrollbarWidth: "none" }, children: room.map((name) => (_jsxs(XStack, { role: "listitem", items: "center", gap: "$1.5", pl: "$1", pr: "$0.5", py: "$0.5", rounded: 999, borderWidth: 1, borderColor: "$borderColor", shrink: 0, children: [_jsx(Face, { src: faces[name]?.avatar ?? (name === withWhom ? portrait : undefined), emoji: faces[name]?.emoji ?? (name === withWhom ? persona?.emoji : undefined), name: name, size: 22 }), _jsx(Text, { fontSize: "$2", fontWeight: "600", color: "$color", numberOfLines: 1, display: "none", $sm: { display: "flex" }, children: name }), _jsx(Box, { render: "button", onClick: () => openAgent(name), "aria-label": `Remove ${name} from this chat`, title: `Remove ${name}`, width: 24, height: 24, rounded: 999, items: "center", justify: "center", hoverStyle: { bg: "$hover" }, children: _jsx(X, { size: 13, "aria-hidden": true, color: "var(--soft, var(--muted-foreground))" }) })] }, name))) })) : null] }), _jsxs(XStack, { items: "center", gap: "$1", children: [_jsx(Box, { render: "button", onClick: () => {
917
912
  if (aside === null) {
918
913
  showAside("tasks");
919
914
  if (!pinned)
@@ -1036,15 +1031,22 @@ function Thread({ placeholder = MESSAGE, initial, anonymous = false, heading, })
1036
1031
  // package's button and `streaming` still has to become stop; it keeps
1037
1032
  // `data-slot="composer-send"` so a driver finds it, and it is the same
1038
1033
  // `Control` circle as the mic, filled.
1039
- // THE PAPERCLIP opens the file picker; the same files can be dropped on
1040
- // the room.
1041
- children: (_jsxs(_Fragment, { children: [_jsx(Control, { type: "button", size: ROUND, "aria-label": "Attach files", onClick: () => picker.current?.click(), children: _jsx(Paperclip, { size: 15, "aria-hidden": true }) }), _jsx("input", { ref: picker, type: "file", multiple: true, hidden: true, "data-slot": "composer-files", onChange: (e) => {
1042
- void take(e.target.files);
1043
- e.target.value = "";
1044
- } })] })),
1034
+ // THE PAPERCLIP is the column's "Attach files", one press closer: the
1035
+ // same `take`, into the same `hold`.
1036
+ children: (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { children: _jsx(Control, { type: "button", size: ROUND, "aria-label": "Attach files", onClick: () => take(at), children: _jsx(Paperclip, { size: 15, "aria-hidden": true }) }) }), _jsx(TooltipContent, { children: _jsx(Text, { fontSize: "$2", children: "Attach files" }) })] })),
1037
+ // A file or an image pasted into the field is held like a picked one;
1038
+ // words paste as words.
1039
+ field: {
1040
+ onPaste: (e) => {
1041
+ if (!e.clipboardData?.files?.length)
1042
+ return;
1043
+ e.preventDefault();
1044
+ void hold(at, Array.from(e.clipboardData.files));
1045
+ },
1046
+ },
1045
1047
  // WHAT GOES WITH THE MESSAGE, above the frame: one chip a file, each
1046
1048
  // with its own remove, and the reason any file was refused.
1047
- head: files.length || fileSaid ? (_jsxs(YStack, { width: "calc(100% - 32px)", maxW: `calc(${MEASURE} - 3rem)`, mx: "auto", gap: "$1.5", children: [files.length ? (_jsx(XStack, { gap: "$1.5", flexWrap: "wrap", role: "list", "aria-label": "Attached files", "data-slot": "composer-attached", children: files.map((one) => (_jsxs(XStack, { role: "listitem", items: "center", gap: "$1.5", pl: "$2.5", pr: "$1", py: "$1", rounded: "$10", borderWidth: 1, borderColor: "$borderColor", bg: "$hover", maxW: 260, children: [_jsx(Paperclip, { size: 12, "aria-hidden": true, color: "var(--muted-foreground)" }), _jsx(Text, { fontSize: "$2", color: "$ink", numberOfLines: 1, children: one.name }), _jsx(Box, { render: "button", "aria-label": `Remove ${one.name}`, onClick: () => setFiles((now) => now.filter((f) => f.id !== one.id)), p: "$1", rounded: "$10", hoverStyle: { bg: "$raised" }, children: _jsx(X, { size: 12, "aria-hidden": true }) })] }, one.id))) })) : null, fileSaid ? (_jsx(Text, { role: "alert", fontSize: "$2", color: BAD, children: fileSaid })) : null] })) : undefined,
1049
+ head: files.length || turnedAway ? (_jsxs(YStack, { width: "calc(100% - 32px)", maxW: `calc(${MEASURE} - 3rem)`, mx: "auto", gap: "$1.5", children: [files.length ? (_jsx(XStack, { gap: "$1.5", flexWrap: "wrap", role: "list", "aria-label": "Attached files", "data-slot": "composer-attached", children: files.map((one) => (_jsxs(XStack, { role: "listitem", items: "center", gap: "$1.5", pl: "$2.5", pr: "$1", py: "$1", rounded: "$10", borderWidth: 1, borderColor: "$borderColor", bg: "$hover", maxW: 260, children: [one.kind === "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: one.name }), _jsx(Box, { render: "button", "aria-label": `Remove ${one.name}`, onClick: () => drop(at, one.id), p: "$1", rounded: "$10", hoverStyle: { bg: "$raised" }, children: _jsx(X, { size: 12, "aria-hidden": true }) })] }, one.id))) })) : null, turnedAway ? (_jsx(Text, { role: "alert", fontSize: "$2", color: BAD, children: turnedAway })) : null] })) : undefined,
1048
1050
  send: (_jsxs(XStack, { items: "center", gap: "$2", children: [_jsx(Enso, { effort: effort, onEffort: setEffort, model: wanted, onModel: choose, models: offered }), _jsx(Control, { asChild: true, size: ROUND, children: _jsx(Voice, { voice: voice, children: (state) => (_jsx(Mic, { size: 15, fill: state === "idle" ? "none" : "currentColor" })) }) }), _jsx(Control, { type: "button", size: ROUND, fill: true, "data-slot": "composer-send", "aria-label": chat.streaming ? "Stop" : "Send", opacity: chat.streaming || draft.trim() || files.length ? 1 : 0.4, onClick: () => (chat.streaming ? chat.stop() : submit()), children: chat.streaming ? (_jsx(Square, { size: 13, fill: "currentColor" })) : (_jsx(ArrowUp, { size: 15, strokeWidth: 2.5 })) })] })),
1049
1051
  // WHAT THE PLAN HAS LEFT, under the frame and at its measure, for a
1050
1052
  // plan holder only: Free keeps the free lane's own notice.
package/dist/RightPane.js CHANGED
@@ -14,10 +14,12 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
14
14
  // OUTPUTS AND SOURCES ARE DERIVED FROM THE TURNS, never from a second list kept
15
15
  // in step with them. `LLM.md` already states this rule for `Work` and it is the
16
16
  // same rule: the conversation is the record, so a fenced block in an answer IS
17
- // an output and an image in a question IS a source. There is nothing to sync,
18
- // nothing to invalidate, and no count that can disagree with the transcript —
19
- // and switching channels costs no request, because the turns are already in
20
- // hand. A conversation that has produced nothing says so.
17
+ // an output, and an image in a question or a block in it that names a file IS a
18
+ // source. There is nothing to sync, nothing to invalidate, and no count that
19
+ // can disagree with the transcript — and switching channels costs no request,
20
+ // because the turns are already in hand. The one thing listed that is not yet
21
+ // a turn is what is held for the next message (`hold` in `pane.ts`), which a
22
+ // send spends into one. A conversation that has produced nothing says so.
21
23
  //
22
24
  // THE COLUMN IS STACKED, NOT SPLIT. The reference this follows puts a ~300px
23
25
  // summary beside a wide browser, which needs about 900px of column; the frame
@@ -33,7 +35,7 @@ import { ExternalLink, FileCode, Image as ImageMark, Paperclip, PanelRightClose,
33
35
  import { Box, DropdownMenu, DropdownMenuContent, DropdownMenuCheckboxItem, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, Text, View, XStack, YStack, } from '@hanzo/ui';
34
36
  import { Card, Quiet, Row } from './card.js';
35
37
  import { useOpen } from './open.js';
36
- import { address, channel, hold, name as titleOf, openTab, pickTab, pin, shutTab, usePane, } from './pane.js';
38
+ import { address, channel, name as titleOf, openTab, pickTab, pin, shutTab, take, usePane, } from './pane.js';
37
39
  import { useProviders, useSkills } from './tools.js';
38
40
  import { activate, startDevice } from './connect.js';
39
41
  /** How many rows a card shows before it offers the rest. */
@@ -115,7 +117,11 @@ function outputs(said) {
115
117
  });
116
118
  return out;
117
119
  }
118
- /** What went into this conversation: pictures asked about, and files brought in. */
120
+ /**
121
+ * What went into this conversation: pictures asked about, files sent fenced
122
+ * under their names (`compose` in `lib/attach`), and files held for the next
123
+ * message.
124
+ */
119
125
  function sources(said, held) {
120
126
  const out = [];
121
127
  said.forEach((m, turn) => {
@@ -124,15 +130,17 @@ function sources(said, held) {
124
130
  pictures(m).forEach((href, i) => {
125
131
  out.push({ id: `s${turn}.${i}`, name: 'Attached image', kind: 'image', href });
126
132
  });
133
+ let n = 0;
134
+ for (const [, info, body] of words(m).matchAll(FENCE)) {
135
+ n += 1;
136
+ const named = info.trim();
137
+ if (!suffix(named))
138
+ continue;
139
+ out.push({ id: `sf${turn}.${n}`, name: named, kind: 'file', body, mime: MIME[suffix(named)] ?? 'text/plain' });
140
+ }
127
141
  });
128
142
  for (const h of held) {
129
- out.push({
130
- id: h.id,
131
- name: h.name,
132
- kind: h.type.startsWith('image/') ? 'image' : 'file',
133
- href: h.href,
134
- mime: h.type,
135
- });
143
+ out.push({ id: h.id, name: h.name, kind: h.kind === 'image' ? 'image' : 'file', href: h.href, mime: h.type });
136
144
  }
137
145
  return out;
138
146
  }
@@ -238,23 +246,6 @@ const Note = ({ children }) => (_jsx(DropdownMenuLabel, { children: _jsx(Text, {
238
246
  function Add({ at }) {
239
247
  return (_jsxs(DropdownMenu, { placement: "bottom-end", maxHeight: 420, children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(Box, { render: "button", "aria-label": "Add to this conversation", p: "$1", rounded: "$2", hoverStyle: { bg: '$hover' }, children: _jsx(Plus, { size: 16, "aria-hidden": true }) }) }), _jsxs(DropdownMenuContent, { align: "end", children: [_jsx(DropdownMenuItem, { onClick: () => take(at), children: _jsx(Word, { children: "Attach files" }) }), _jsx(DropdownMenuSeparator, {}), _jsx(Skills, {}), _jsx(DropdownMenuSeparator, {}), _jsx(Services, {})] })] }));
240
248
  }
241
- /**
242
- * Asks for files and takes what is given.
243
- *
244
- * The picker is the browser's, minted and dropped per press rather than kept as
245
- * a hidden input in the markup: a menu row is not a form control, and an
246
- * `<input type=file>` parked in the tree is one more thing for a screen reader
247
- * to walk past.
248
- */
249
- function take(at) {
250
- if (typeof document === 'undefined')
251
- return;
252
- const ask = document.createElement('input');
253
- ask.type = 'file';
254
- ask.multiple = true;
255
- ask.onchange = () => hold(at, Array.from(ask.files ?? []));
256
- ask.click();
257
- }
258
249
  /**
259
250
  * What this org's agents can reach for, as a section of this menu.
260
251
  *
@@ -5,34 +5,39 @@
5
5
  * model reads, and nothing else rides that wire, so a file goes one of two
6
6
  * ways: an image as a `data:` URI beside the words, a text file as its own
7
7
  * words after the message, fenced under its name. Any other kind is refused by
8
- * name, before anything is sent.
8
+ * name, before anything is held.
9
+ *
10
+ * This module judges and reads a file; `pane.ts` holds what it admits, per
11
+ * conversation, for the composer's chips and the column's Sources alike.
9
12
  */
10
13
  import type { MessagePart } from '@hanzo/ui/chat';
11
- /** A file held for the next message. */
12
- export interface Held {
13
- id: string;
14
- name: string;
14
+ /** What a file contributes to a message once admitted. */
15
+ export interface Admitted {
15
16
  kind: 'image' | 'text';
16
17
  /** A `data:` URI for an image, the words for a text file. */
17
18
  data: string;
18
- size: number;
19
19
  }
20
+ /** A file as it goes with a message. */
21
+ export type Going = Admitted & {
22
+ name: string;
23
+ };
20
24
  export declare const IMAGE_MAX: number;
21
25
  export declare const TEXT_MAX: number;
22
26
  /** Which way a file goes with a message, or null when it cannot go. */
23
27
  export declare function kindOf(file: {
24
28
  name: string;
25
29
  type: string;
26
- }): Held['kind'] | null;
27
- /** A file read and held, or the sentence that says why it cannot go. */
28
- export declare function hold(file: File): Promise<Held | string>;
30
+ }): Admitted['kind'] | null;
31
+ /** A file read for a message, or the sentence that says why it cannot go. */
32
+ export declare function admit(file: File): Promise<Admitted | string>;
29
33
  /**
30
- * The message as sent: the words, then each text file fenced under its name.
31
- * With no words and no text file, the images' names, so the turn says what it
32
- * carries.
34
+ * The message as sent: the words, then each text file fenced with its name as
35
+ * the info string — the shape a model writes a file in, and the one the
36
+ * column's Sources reads back out of the turn. With no words and no text file,
37
+ * the images' names, so the turn says what it carries.
33
38
  */
34
- export declare function compose(text: string, files: readonly Held[]): string;
39
+ export declare function compose(text: string, files: readonly Going[]): string;
35
40
  /** The images of a message, as the URIs @hanzo/ai's `send` takes. */
36
- export declare const images: (files: readonly Held[]) => string[];
41
+ export declare const images: (files: readonly Going[]) => string[];
37
42
  /** A wire turn's parts — text and `image_url` — as the parts `@hanzo/ui/chat` draws. */
38
43
  export declare function partsOf(content: readonly unknown[]): MessagePart[];
@@ -5,7 +5,10 @@
5
5
  * model reads, and nothing else rides that wire, so a file goes one of two
6
6
  * ways: an image as a `data:` URI beside the words, a text file as its own
7
7
  * words after the message, fenced under its name. Any other kind is refused by
8
- * name, before anything is sent.
8
+ * name, before anything is held.
9
+ *
10
+ * This module judges and reads a file; `pane.ts` holds what it admits, per
11
+ * conversation, for the composer's chips and the column's Sources alike.
9
12
  */
10
13
  export const IMAGE_MAX = 8 * 1024 * 1024;
11
14
  export const TEXT_MAX = 256 * 1024;
@@ -30,8 +33,8 @@ function read(file, as) {
30
33
  reader.readAsText(file);
31
34
  });
32
35
  }
33
- /** A file read and held, or the sentence that says why it cannot go. */
34
- export async function hold(file) {
36
+ /** A file read for a message, or the sentence that says why it cannot go. */
37
+ export async function admit(file) {
35
38
  const kind = kindOf(file);
36
39
  if (!kind)
37
40
  return `${file.name}: only images and text files go with a message.`;
@@ -39,8 +42,7 @@ export async function hold(file) {
39
42
  if (file.size > max)
40
43
  return `${file.name} is larger than ${kind === 'image' ? '8 MB' : '256 KB'}.`;
41
44
  try {
42
- const data = await read(file, kind === 'image' ? 'url' : 'text');
43
- return { id: crypto.randomUUID(), name: file.name, kind, data, size: file.size };
45
+ return { kind, data: await read(file, kind === 'image' ? 'url' : 'text') };
44
46
  }
45
47
  catch {
46
48
  return `${file.name} could not be read.`;
@@ -52,16 +54,17 @@ function fence(words) {
52
54
  return '`'.repeat(Math.max(3, longest + 1));
53
55
  }
54
56
  /**
55
- * The message as sent: the words, then each text file fenced under its name.
56
- * With no words and no text file, the images' names, so the turn says what it
57
- * carries.
57
+ * The message as sent: the words, then each text file fenced with its name as
58
+ * the info string — the shape a model writes a file in, and the one the
59
+ * column's Sources reads back out of the turn. With no words and no text file,
60
+ * the images' names, so the turn says what it carries.
58
61
  */
59
62
  export function compose(text, files) {
60
63
  const blocks = files
61
64
  .filter((f) => f.kind === 'text')
62
65
  .map((f) => {
63
66
  const f3 = fence(f.data);
64
- return `${f.name}\n${f3}\n${f.data.replace(/\n$/, '')}\n${f3}`;
67
+ return `${f3}${f.name}\n${f.data.replace(/\n$/, '')}\n${f3}`;
65
68
  });
66
69
  const out = [text.trim(), ...blocks].filter(Boolean).join('\n\n');
67
70
  return out || files.map((f) => f.name).join(', ');
package/dist/pane.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type Admitted } from './lib/attach.js';
1
2
  /** One open tab in the pane's browser. */
2
3
  export interface Leaf {
3
4
  id: string;
@@ -7,13 +8,14 @@ export interface Leaf {
7
8
  title: string;
8
9
  }
9
10
  /**
10
- * A file the reader put into this conversation from their own disk.
11
+ * A file the reader put into this conversation from their own disk, read and
12
+ * waiting to go with the next message.
11
13
  *
12
14
  * `href` is an object URL and is the reason this is never written down — see
13
15
  * the note at the top. Everything else is read off the `File` itself, so a name
14
16
  * and a size here are the file system's, never a guess.
15
17
  */
16
- export interface Held {
18
+ export interface Held extends Admitted {
17
19
  id: string;
18
20
  name: string;
19
21
  size: number;
@@ -29,6 +31,8 @@ export interface Pane {
29
31
  /** Whether the summary column stands beside the browser. */
30
32
  pinned: boolean;
31
33
  held: Held[];
34
+ /** Why the last files offered were refused, in the reader's words, or null. */
35
+ refused: string | null;
32
36
  }
33
37
  /**
34
38
  * The conversation a pane belongs to.
@@ -83,8 +87,31 @@ export declare function pickTab(key: string, id: string): void;
83
87
  export declare function shutTab(key: string, id: string): void;
84
88
  /** Shows or hides the summary column beside this conversation's browser. */
85
89
  export declare function pin(key: string, on: boolean): void;
86
- /** Takes files from the reader's disk into this conversation. */
87
- export declare function hold(key: string, files: File[]): void;
90
+ /**
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.
96
+ */
97
+ export declare function hold(key: string, files: readonly File[]): Promise<void>;
98
+ /**
99
+ * Asks for files and holds what is given.
100
+ *
101
+ * The picker is the browser's, minted and dropped per press rather than kept as
102
+ * a hidden input in the markup: a menu row is not a form control, and an
103
+ * `<input type=file>` parked in the tree is one more thing for a screen reader
104
+ * to walk past.
105
+ */
106
+ export declare function take(key: string): void;
107
+ /**
108
+ * Hands over everything held to go with a message, and lets go of it.
109
+ *
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.
113
+ */
114
+ export declare function spend(key: string): Held[];
88
115
  /** Lets go of a file, and of the handle the browser minted for it. */
89
116
  export declare function drop(key: string, id: string): void;
90
117
  /** This conversation's pane, and it alone — a change to another redraws nothing
package/dist/pane.js CHANGED
@@ -20,10 +20,17 @@
20
20
  // with the document that minted it, so a persisted row would come back as a
21
21
  // name pointing at nothing. A row that looks like a file and opens nothing is
22
22
  // worse than no row, so they are held for the session and no longer.
23
+ //
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.
23
29
  import { useSyncExternalStore } from 'react';
30
+ import { admit } from './lib/attach.js';
24
31
  /** A pane nobody has touched. Shared, and never mutated — see `EMPTY` in
25
32
  * `open.ts` for why a fresh object per read is an infinite render. */
26
- const BARE = { tabs: [], at: null, pinned: true, held: [] };
33
+ const BARE = { tabs: [], at: null, pinned: true, held: [], refused: null };
27
34
  const where = (key) => `hanzo.pane.${key}`;
28
35
  /**
29
36
  * The conversation a pane belongs to.
@@ -90,6 +97,7 @@ function read(key) {
90
97
  at: tabs.some((t) => t.id === kept.at) ? kept.at : (tabs[0]?.id ?? null),
91
98
  pinned: kept.pinned !== false,
92
99
  held: [],
100
+ refused: null,
93
101
  };
94
102
  }
95
103
  }
@@ -201,18 +209,58 @@ export function shutTab(key, id) {
201
209
  export function pin(key, on) {
202
210
  edit(key, (was) => (was.pinned === on ? was : { ...was, pinned: on }));
203
211
  }
204
- /** Takes files from the reader's disk into this conversation. */
205
- export function hold(key, files) {
212
+ /**
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.
218
+ */
219
+ export async function hold(key, files) {
206
220
  if (!files.length)
207
221
  return;
208
- const taken = files.map((f) => ({
209
- id: mint('h'),
210
- name: f.name,
211
- size: f.size,
212
- type: f.type,
213
- href: URL.createObjectURL(f),
214
- }));
215
- edit(key, (was) => ({ ...was, held: [...was.held, ...taken] }));
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 });
231
+ });
232
+ edit(key, (was) => ({ ...was, held: [...was.held, ...taken], refused: refused.length ? refused.join(' ') : null }));
233
+ }
234
+ /**
235
+ * Asks for files and holds what is given.
236
+ *
237
+ * The picker is the browser's, minted and dropped per press rather than kept as
238
+ * a hidden input in the markup: a menu row is not a form control, and an
239
+ * `<input type=file>` parked in the tree is one more thing for a screen reader
240
+ * to walk past.
241
+ */
242
+ export function take(key) {
243
+ if (typeof document === 'undefined')
244
+ return;
245
+ const ask = document.createElement('input');
246
+ ask.type = 'file';
247
+ ask.multiple = true;
248
+ ask.onchange = () => void hold(key, Array.from(ask.files ?? []));
249
+ ask.click();
250
+ }
251
+ /**
252
+ * Hands over everything held to go with a message, and lets go of it.
253
+ *
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.
257
+ */
258
+ export function spend(key) {
259
+ const was = read(key);
260
+ if (!was.held.length && !was.refused)
261
+ return [];
262
+ write(key, { ...was, held: [], refused: null });
263
+ return was.held;
216
264
  }
217
265
  /** Lets go of a file, and of the handle the browser minted for it. */
218
266
  export function drop(key, id) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanzo/rooms",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
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",