@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/Chat.js CHANGED
@@ -33,8 +33,9 @@ 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, images, partsOf } from "./lib/attach.js";
37
- import { channel, drop, hold, spend, take, usePane } from "./pane.js";
36
+ import { carriedIn, compose, partsOf, said as told, weigh } from "./lib/attach.js";
37
+ import { dropped, readable, refOf, retrieve } from "./lib/files.js";
38
+ import { channel, drop, hold, landed, spend, take, usePane, useWorkFile } from "./pane.js";
38
39
  import { Face, brief as roomBrief, join, roleOf, roomTurn, roster as rosterLine, rosterOf, speakers, speaksOf, voiceOf } from "./cast.js";
39
40
  import { SpeakButton, cleanForSpeech } from "./speech.js";
40
41
  import { Crew, HOUSE, useCrew } from "./crew.js";
@@ -48,8 +49,8 @@ import { Meters } from "./meters.js";
48
49
  import { useHydrated } from "./lib/hydrated.js";
49
50
  import { base, served } from "./lib/ai.js";
50
51
  import { onServed } from "./lib/served.js";
51
- import { speech, useVoice, Voice } from "@hanzo/voice";
52
- import { ArrowUp, Image as ImageMark, Mic, Paperclip, Square, Star, PanelRight, X } from "lucide-react";
52
+ import { REFUSED, speech, useDictation, useTalk, useVoice, Voice } from "@hanzo/voice";
53
+ import { ArrowUp, AudioLines, Image as ImageMark, Mic, Paperclip, Square, Star, PanelRight, X } from "lucide-react";
53
54
  import { ENSO, FREE } from "./lib/ai.js";
54
55
  import { openThread, useOpen } from "./open.js";
55
56
  import { Beside, Framed } from "./Shell.js";
@@ -296,6 +297,75 @@ function Why({ text }) {
296
297
  * writes, which streams no words while it does.
297
298
  */
298
299
  const STALL = 300_000;
300
+ /**
301
+ * How long a send waits for a freshly attached file to have passages before it
302
+ * goes without them. Long enough for a page or a short document to be read; a
303
+ * large one keeps indexing, and the turn says it is not readable yet.
304
+ */
305
+ const READ_WAIT = 45_000;
306
+ /** Files named the way a sentence lists them. */
307
+ function names(list) {
308
+ const all = list.map((f) => f.name);
309
+ return all.length <= 2 ? all.join(" and ") : `${all.slice(0, 2).join(", ")} and ${all.length - 2} more`;
310
+ }
311
+ /** Why the files had no passages to give, from what the index last said of each. */
312
+ function unread(list) {
313
+ const parts = list.map((f) => f.status === "stored"
314
+ ? `${f.name} is kept but not indexed${f.error ? `: ${f.error}` : ""}`
315
+ : f.status === "failed"
316
+ ? `${f.name} could not be indexed${f.error ? `: ${f.error}` : ""}`
317
+ : f.status !== "ready"
318
+ ? `${f.name} is still being indexed`
319
+ : null);
320
+ const said = parts.filter(Boolean).join("; ");
321
+ return said || undefined;
322
+ }
323
+ /** How far a file has got, in a word or two. */
324
+ function progress(state, sent, f) {
325
+ if (state === "uploading")
326
+ return `${Math.floor(sent * 100)}%`;
327
+ if (state === "queued")
328
+ return "queued";
329
+ if (state === "indexing") {
330
+ switch (f?.stage) {
331
+ case "extract":
332
+ return "reading";
333
+ case "toc":
334
+ return "contents";
335
+ case "passages":
336
+ return "passages";
337
+ case "embed":
338
+ return f.passages ? `indexing ${Math.floor(((f.embedded ?? 0) / f.passages) * 100)}%` : "indexing";
339
+ case "graph":
340
+ return "linking";
341
+ default:
342
+ return "indexing";
343
+ }
344
+ }
345
+ return state;
346
+ }
347
+ /** One chip: the file's glyph, its name, and how far it has got. */
348
+ function Chip({ name, type, state, sent, file, onRemove, }) {
349
+ const bad = state === "failed";
350
+ const why = state === "failed" || state === "stored" ? file?.error : undefined;
351
+ 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] }));
352
+ }
353
+ /** A file held for the next message: uploading, then indexing, then ready. */
354
+ function HeldChip({ one, onRemove }) {
355
+ const live = useWorkFile(one.file?.id);
356
+ const file = live ?? one.file;
357
+ const state = one.state === "uploading" ? one.state : (file?.status ?? one.state);
358
+ return _jsx(Chip, { name: one.name, type: one.type, state: state, sent: one.sent, file: file ?? (one.error ? { error: one.error } : undefined), onRemove: onRemove });
359
+ }
360
+ /** A file a sent turn carried, as the index says of it now. */
361
+ function TurnChip({ f }) {
362
+ const live = useWorkFile(f.id);
363
+ return _jsx(Chip, { name: f.name, type: f.type, state: live?.status ?? "ready", sent: 1, file: live });
364
+ }
365
+ /** A question that carried files: its words, the files put into it, and those read again. */
366
+ function Carried({ text, attached, reused }) {
367
+ 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] }));
368
+ }
299
369
  /**
300
370
  * The conversation. One component for a visitor and for an account.
301
371
  *
@@ -598,9 +668,7 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
598
668
  // identity changes on every `setMessages` — `useChat` closes over `messages` —
599
669
  // so an effect depending on it re-runs after each streamed token.
600
670
  const latest = useRef(send);
601
- // The images held for a turn ride with it at the moment it is sent, queued or
602
- // not, and are spent once: a retry is the words again, not the pictures.
603
- latest.current = (text) => send(text, outgoing.current.splice(0));
671
+ latest.current = (text) => send(text);
604
672
  // THE COMPOSER'S DRAFT IS HELD HERE, so an arriving question can be typed into
605
673
  // it. `Chat` owns a draft of its own and spreads `composer` after it, so
606
674
  // passing value/onChange/onSend takes that ownership over rather than fighting
@@ -609,26 +677,33 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
609
677
  // FILES WITH THE NEXT MESSAGE, held by `pane.ts` for this conversation: the
610
678
  // paperclip, a drop anywhere on the room, a paste into the field and the
611
679
  // column's `+` all go through its one `hold`, so the chips here and the rows
612
- // under Sources are one list. An image rides as a picture the model reads, a
613
- // text file as its words (lib/attach); anything else is refused by name.
680
+ // under Sources are one list. Each goes to the org's workspace and is indexed
681
+ // there; the turn carries it by reference (lib/attach), with the passages
682
+ // read from it for the question.
614
683
  const { held: files, refused: turnedAway } = usePane(at);
684
+ const via = useMemo(() => ({ api: ai, org: anonymous ? null : org() }), [ai, anonymous]);
685
+ // What the send is waiting on — uploads landing, the files being read — said
686
+ // over the field, and the reason a second press does nothing meanwhile.
687
+ const [preparing, setPreparing] = useState(null);
615
688
  const [dragging, setDragging] = useState(false);
616
- const outgoing = useRef([]);
617
689
  const depth = useRef(0);
618
690
  useSeed(initial, setDraft);
619
691
  // DICTATION through Hanzo's own ear: the mic records and each pause is
620
- // posted to /v1/audio/transcriptions (`zen-scribe`). `speech()` defaults to
621
- // the Hanzo gateway and `base()` is the one place a host is read from, so
622
- // pointing a local cloud at one points both. The token is the reader's own —
623
- // the package never invents a credential — and a refusal is worn by
624
- // `<Voice/>`, never passed off as a transcript.
692
+ // posted to /v1/audio/transcriptions (`zen-scribe`), and what was said lands
693
+ // AFTER what the draft already held (`useDictation`) — a pause between two
694
+ // sentences never loses the first. `speech()` defaults to the Hanzo gateway
695
+ // and `base()` is the one place a host is read from, so pointing a local
696
+ // cloud at one points both. The token is the reader's own — the package never
697
+ // invents a credential — and a refusal is worn by `<Voice/>`, never passed off
698
+ // as a transcript.
625
699
  const { token } = useIamToken();
626
700
  const ear = useMemo(() => speech({ ...base(), ...(token ? { token } : {}) }), [token]);
627
- const voice = useVoice({
628
- speech: ear,
629
- onPartial: setDraft,
630
- onUtterance: setDraft,
631
- });
701
+ const voice = useVoice({ speech: ear, ...useDictation(draft, setDraft) });
702
+ // TALK MODE: a hands-free conversation on Hanzo's realtime socket, /v1/voice.
703
+ // The mic streams up, the answer streams back as speech with its words in
704
+ // `talk.reply`, and talking over it stops it. Its own conversation, so its
705
+ // turns are drawn above the composer rather than posted into the thread.
706
+ const talk = useTalk({ ...base(), ...(token ? { token } : {}), org: org() ?? undefined });
632
707
  // WHILE THE MIC IS OPEN, A FINISHED REPLY IS READ ALOUD by the platform's
633
708
  // voice, through the conversation's own mouth so speaking over it stops it.
634
709
  // A room's reply is read part by part, each in its speaker's cast voice.
@@ -639,11 +714,12 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
639
714
  const lastMsg = msgs[msgs.length - 1];
640
715
  if (lastMsg && lastMsg.role === "assistant" && typeof lastMsg.content === "string") {
641
716
  const parts = room.length > 1 ? speakers(lastMsg.content, room) : [{ who: withWhom, text: lastMsg.content }];
717
+ // A part spoken over ends the reading rather than handing on to the next.
642
718
  void (async () => {
643
719
  for (const part of parts) {
644
720
  const said = cleanForSpeech(part.text);
645
- if (said)
646
- await voice.say(said, voiceOf(part.who ?? withWhom ?? undefined));
721
+ if (said && !(await voice.say(said, voiceOf(part.who ?? withWhom ?? undefined))))
722
+ break;
647
723
  }
648
724
  })();
649
725
  }
@@ -790,15 +866,63 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
790
866
  }
791
867
  speak(text);
792
868
  }, [model, speak]);
793
- /** The draft and the held files, sent as one turn. */
869
+ /**
870
+ * The draft and the files, sent as one turn.
871
+ *
872
+ * A file goes by REFERENCE: the send waits for each held file's upload to
873
+ * land, then asks the index for the passages that answer the question —
874
+ * across the files put into this turn and every file earlier turns carried —
875
+ * and the turn carries their names and those passages, each citing
876
+ * file › section › ¶n. A file still indexing is waited on for a bounded
877
+ * while; past it the turn says the file is not readable yet rather than
878
+ * answering as though it had been read.
879
+ */
794
880
  const submit = () => {
795
881
  const text = draft.trim();
796
- if (!text && !files.length)
882
+ if (preparing || (!text && !files.length))
797
883
  return;
798
- const going = spend(at);
799
- outgoing.current = images(going);
884
+ const holding = files.slice();
885
+ const earlier = carriedIn(chat.messages);
886
+ if (!holding.length && !earlier.length) {
887
+ setDraft("");
888
+ guarded(text);
889
+ return;
890
+ }
800
891
  setDraft("");
801
- guarded(compose(text, going));
892
+ void (async () => {
893
+ try {
894
+ if (holding.some((h) => !h.file))
895
+ setPreparing(`Uploading ${names(holding)}`);
896
+ const landedFiles = (await Promise.all(holding.map(landed))).filter((f) => !!f);
897
+ spend(at);
898
+ const attached = landedFiles.map(refOf);
899
+ const reused = earlier.filter((f) => !attached.some((a) => a.id === f.id));
900
+ const all = [...attached, ...reused];
901
+ if (!all.length) {
902
+ if (text)
903
+ guarded(text);
904
+ else
905
+ setDraft(text);
906
+ return;
907
+ }
908
+ setPreparing(`Reading ${names(all)}`);
909
+ const now = await readable(ai, landedFiles, READ_WAIT);
910
+ let grounds = null;
911
+ let why;
912
+ try {
913
+ grounds = await retrieve(ai, text || `What is in ${names(all)}?`, all.map((f) => f.id), 8);
914
+ }
915
+ catch {
916
+ why = "the workspace could not be read just now";
917
+ }
918
+ if (!grounds?.passages.length)
919
+ why = why ?? unread(now);
920
+ guarded(compose(text, { attached, reused }, grounds, why));
921
+ }
922
+ finally {
923
+ setPreparing(null);
924
+ }
925
+ })();
802
926
  };
803
927
  /*
804
928
  WHAT AN UNANSWERED QUESTION LOOKS LIKE: a turn of its own directly under it,
@@ -908,7 +1032,8 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
908
1032
  if (!e.dataTransfer.files?.length)
909
1033
  return;
910
1034
  e.preventDefault();
911
- void hold(at, Array.from(e.dataTransfer.files));
1035
+ // A dropped folder arrives as entries; each file under it goes up with its path.
1036
+ void dropped(e.dataTransfer).then((list) => hold(at, list, via));
912
1037
  }, 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: () => {
913
1038
  if (aside === null) {
914
1039
  showAside("tasks");
@@ -946,6 +1071,13 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
946
1071
  if (turn.role === "user" && Array.isArray(turn.content))
947
1072
  return _jsx(Parts, { parts: partsOf(turn.content), prose: (text) => _jsx(Prose, { text: text }) });
948
1073
  const said = typeof turn.content === "string" ? turn.content : "";
1074
+ // A QUESTION THAT CARRIED FILES draws its words and the files' chips;
1075
+ // the passages it handed the model are the model's to read, not ours.
1076
+ if (turn.role === "user" && said) {
1077
+ const read = told(said);
1078
+ if (read.carried)
1079
+ return _jsx(Carried, { text: read.text, attached: read.carried.attached, reused: read.carried.reused });
1080
+ }
949
1081
  // WHO IS ANSWERING, while the answer has no words yet: the agents the
950
1082
  // question addresses, or the whole room when it names nobody.
951
1083
  if (turn.role === "assistant" && !said && chat.streaming && room.length) {
@@ -1034,7 +1166,7 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
1034
1166
  // `Control` circle as the mic, filled.
1035
1167
  // THE PAPERCLIP is the column's "Attach files", one press closer: the
1036
1168
  // same `take`, into the same `hold`.
1037
- 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" }) })] })),
1169
+ children: (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { children: _jsx(Control, { type: "button", size: ROUND, "aria-label": "Attach files", onClick: () => take(at, via), children: _jsx(Paperclip, { size: 15, "aria-hidden": true }) }) }), _jsx(TooltipContent, { children: _jsx(Text, { fontSize: "$2", children: "Attach files" }) })] })),
1038
1170
  // A file or an image pasted into the field is held like a picked one;
1039
1171
  // words paste as words.
1040
1172
  field: {
@@ -1042,13 +1174,23 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
1042
1174
  if (!e.clipboardData?.files?.length)
1043
1175
  return;
1044
1176
  e.preventDefault();
1045
- void hold(at, Array.from(e.clipboardData.files));
1177
+ void hold(at, Array.from(e.clipboardData.files), via);
1046
1178
  },
1047
1179
  },
1048
1180
  // WHAT GOES WITH THE MESSAGE, above the frame: one chip a file, each
1049
1181
  // with its own remove, and the reason any file was refused.
1050
- 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,
1051
- 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 })) })] })),
1182
+ head: files.length || turnedAway || preparing || talk.open || talk.refusal ? (_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) => (_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
1183
+ ? REFUSED.lost
1184
+ : talk.reply
1185
+ ? `Hanzo: ${talk.reply}`
1186
+ : talk.state === "listening"
1187
+ ? "Listening — speak, and Hanzo answers aloud."
1188
+ : "Connecting…" })) : null] })) : undefined,
1189
+ 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, says: { idle: "Dictate", listening: "Dictating — click to stop" }, children: (state) => (_jsx(Mic, { size: 15, fill: state === "idle" ? "none" : "currentColor" })) }) }), _jsx(Control, { asChild: true, size: ROUND, children: _jsx(Voice, { voice: talk, "data-slot": "composer-talk", says: {
1190
+ idle: "Talk with Hanzo",
1191
+ listening: "In conversation — click to hang up",
1192
+ speaking: "Hanzo is speaking — talk to interrupt",
1193
+ }, children: () => _jsx(AudioLines, { size: 15 }) }) }), _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 })) })] })),
1052
1194
  // WHAT THE PLAN HAS LEFT, under the frame and at its measure, for a
1053
1195
  // plan holder only: Free keeps the free lane's own notice.
1054
1196
  foot: held && limits && (limits.session || limits.day || limits.month) ? (_jsx(YStack, { width: "calc(100% - 32px)", maxW: `calc(${MEASURE} - 3rem)`, mx: "auto", children: _jsx(Meters, { limits: limits }) })) : undefined,
@@ -0,0 +1,23 @@
1
+ import { type Api, type Passage, type WorkFile } from './lib/files.js';
2
+ /** How far a file's ingest has got, as Drive's badge and this pane's head say it. */
3
+ export declare function standing(f: WorkFile): string;
4
+ /** The badge a Drive row wears for its index state. */
5
+ export declare function Badge({ f }: {
6
+ f: WorkFile;
7
+ }): import("react").JSX.Element;
8
+ /** Passages of the workspace that match a search, each naming where it is. */
9
+ export declare function Hits({ hits, onOpen }: {
10
+ hits: Passage[];
11
+ onOpen: (p: Passage) => void;
12
+ }): import("react").JSX.Element;
13
+ /** The pane: a file's tree, and one section opened from it. */
14
+ export declare function Contents({ api, file, at, onClose, onAsk, onFollow, }: {
15
+ api: Api;
16
+ file: WorkFile;
17
+ /** The section to open on arrival, when a search hit brought the reader here. */
18
+ at?: number;
19
+ onClose: () => void;
20
+ onAsk: (f: WorkFile) => void;
21
+ /** Follows a link into another file. */
22
+ onFollow: (file: string, section: number) => void;
23
+ }): import("react").JSX.Element;
@@ -0,0 +1,84 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // A workspace file's table of contents, opened beside Drive.
4
+ //
5
+ // The index reads every file into sections — a page's headings, a PDF's outline,
6
+ // a document's heading styles, a sheet's rows, a long text cut into parts — each
7
+ // with a one-line summary. This pane draws that tree, opens a section's own
8
+ // text, and follows the graph from it: the sections it links to, the files that
9
+ // cite it, the entities it shares with the rest of the workspace. It is the same
10
+ // tree `retrieve` reads before it picks where to look, so what a person browses
11
+ // here is what an answer cites.
12
+ import { useEffect, useState } from 'react';
13
+ import { ArrowLeft, MessageSquare, X } from 'lucide-react';
14
+ import { Box, Button, Text, XStack, YStack } from '@hanzo/ui';
15
+ import { section as openSection, toc } from './lib/files.js';
16
+ import { BAD } from './lib/mix.js';
17
+ /** How far a file's ingest has got, as Drive's badge and this pane's head say it. */
18
+ export function standing(f) {
19
+ switch (f.status) {
20
+ case 'ready':
21
+ return `Indexed · ${f.sections ?? 0} sections`;
22
+ case 'stored':
23
+ return 'Kept, not indexed';
24
+ case 'failed':
25
+ return 'Indexing failed';
26
+ case 'queued':
27
+ return 'Queued';
28
+ default:
29
+ if (f.stage === 'embed' && f.passages)
30
+ return `Indexing ${Math.floor(((f.embedded ?? 0) / f.passages) * 100)}%`;
31
+ return `Indexing · ${f.stage ?? 'starting'}`;
32
+ }
33
+ }
34
+ /** The badge a Drive row wears for its index state. */
35
+ export function Badge({ f }) {
36
+ const bad = f.status === 'failed';
37
+ return (_jsx(Text, { fontSize: "$1", color: bad ? BAD : f.status === 'ready' ? '$soft' : '$faint', numberOfLines: 1, "data-slot": "drive-index", "data-state": f.status, "data-stage": f.stage ?? '', ...(f.error ? { title: f.error } : null), children: standing(f) }));
38
+ }
39
+ /** Passages of the workspace that match a search, each naming where it is. */
40
+ export function Hits({ hits, onOpen }) {
41
+ return (_jsxs(YStack, { gap: "$2", "data-slot": "drive-passages", children: [_jsx(Text, { fontSize: 12, fontWeight: "600", color: "$soft", children: "Passages" }), _jsx(YStack, { borderWidth: 1, borderColor: "$borderColor", rounded: 8, overflow: "hidden", children: hits.map((p, i) => (_jsxs(YStack, { px: 16, py: 10, gap: "$1", cursor: "pointer", borderBottomWidth: i === hits.length - 1 ? 0 : 1, borderColor: "$borderColor", hoverStyle: { bg: '$hover' }, onPress: () => onOpen(p), "data-slot": "drive-passage", children: [_jsx(Text, { fontSize: "$2", fontWeight: "600", color: "$ink", numberOfLines: 1, children: p.cite }), _jsx(Text, { fontSize: "$2", color: "$soft", numberOfLines: 3, children: p.text })] }, `${p.file.id}#${p.section.id}.${p.part}.${i}`))) })] }));
42
+ }
43
+ /** The pane: a file's tree, and one section opened from it. */
44
+ export function Contents({ api, file, at, onClose, onAsk, onFollow, }) {
45
+ const [tree, setTree] = useState(null);
46
+ const [failed, setFailed] = useState(null);
47
+ const [open, setOpen] = useState(null);
48
+ const [reading, setReading] = useState(at ?? null);
49
+ useEffect(() => {
50
+ let live = true;
51
+ setTree(null);
52
+ setFailed(null);
53
+ toc(api, file.id)
54
+ .then((t) => live && setTree(t))
55
+ .catch((e) => live && setFailed(e?.message || 'The contents could not be read.'));
56
+ return () => {
57
+ live = false;
58
+ };
59
+ }, [api, file.id]);
60
+ useEffect(() => {
61
+ setReading(at ?? null);
62
+ }, [at, file.id]);
63
+ useEffect(() => {
64
+ if (reading === null) {
65
+ setOpen(null);
66
+ return;
67
+ }
68
+ let live = true;
69
+ openSection(api, file.id, reading)
70
+ .then((o) => live && setOpen(o))
71
+ .catch((e) => live && setFailed(e?.message || 'The section could not be opened.'));
72
+ return () => {
73
+ live = false;
74
+ };
75
+ }, [api, file.id, reading]);
76
+ const shown = tree?.file ?? file;
77
+ return (_jsxs(YStack, { position: "absolute", t: 0, r: 0, b: 0, width: 440, maxW: "100%", bg: "$background", borderLeftWidth: 1, borderColor: "$borderColor", boxShadow: "var(--shadow-lg)", z: "var(--z-drawer)", "data-slot": "drive-contents", children: [_jsxs(XStack, { items: "center", justify: "space-between", gap: "$2", px: "$4", py: "$3", borderBottomWidth: 1, borderColor: "$borderColor", children: [_jsxs(YStack, { minW: 0, flex: 1, children: [_jsx(Text, { fontSize: "$3", fontWeight: "600", color: "$ink", numberOfLines: 1, children: shown.name }), _jsx(Text, { fontSize: "$1", color: "$soft", numberOfLines: 1, children: standing(shown) })] }), _jsxs(Button, { size: "sm", onClick: () => onAsk(shown), "aria-label": "Ask in chat", children: [_jsx(MessageSquare, { size: 14 }), "Ask in chat"] }), _jsx(Button, { variant: "ghost", size: "icon-sm", onClick: onClose, "aria-label": "Close contents", title: "Close", children: _jsx(X, { size: 15 }) })] }), _jsxs(YStack, { flex: 1, minH: 0, overflowY: "auto", px: "$4", py: "$3", gap: "$2", children: [failed ? (_jsx(Text, { fontSize: "$2", color: BAD, children: failed })) : null, open ? (_jsxs(YStack, { gap: "$3", "data-slot": "drive-section", children: [_jsxs(Button, { variant: "ghost", size: "sm", justify: "flex-start", px: 0, onClick: () => setReading(null), children: [_jsx(ArrowLeft, { size: 13 }), "Contents"] }), _jsx(Text, { fontSize: "$1", color: "$soft", children: open.section.path }), _jsx(Text, { fontSize: "$4", fontWeight: "600", color: "$ink", children: open.section.title }), open.summary ? (_jsx(Text, { fontSize: "$2", color: "$soft", children: open.summary })) : null, _jsx(Text, { fontSize: "$2", color: "$ink", whiteSpace: "pre-wrap", children: open.text }), open.children.length ? (_jsxs(YStack, { gap: "$1", children: [_jsx(Text, { fontSize: 11, fontWeight: "600", color: "$faint", letterSpacing: 0.6, children: "Subsections" }), open.children.map((c) => (_jsx(Entry, { e: c, onOpen: () => setReading(c.id) }, c.id)))] })) : null, open.links.length ? (_jsxs(YStack, { gap: "$1", "data-slot": "drive-links", children: [_jsx(Text, { fontSize: 11, fontWeight: "600", color: "$faint", letterSpacing: 0.6, children: "Linked" }), open.links.map((l, i) => (_jsxs(Box, { render: "button", items: "flex-start", py: "$1", onClick: () => (l.file.id === file.id ? setReading(l.section.id) : onFollow(l.file.id, l.section.id)), children: [_jsx(Text, { fontSize: "$2", color: "$ink", numberOfLines: 1, children: l.file.id === file.id ? l.section.path || l.section.title : `${l.file.name} › ${l.section.path || l.section.title}` }), _jsxs(Text, { fontSize: "$1", color: "$soft", numberOfLines: 1, children: [l.kind, l.label ? ` · ${l.label}` : ''] })] }, `${l.file.id}#${l.section.id}.${i}`)))] })) : null, open.entities.length ? (_jsxs(Text, { fontSize: "$1", color: "$soft", children: ["Names: ", open.entities.map((e) => e.name).join(', ')] })) : null] })) : !tree ? (failed ? null : (_jsx(Text, { fontSize: "$2", color: "$soft", children: "Reading the contents\u2026" }))) : tree.sections.length <= 1 ? (_jsx(Text, { fontSize: "$2", color: "$soft", children: shown.status === 'ready' ? 'This file has no sections.' : 'The contents appear once the file is indexed.' })) : (tree.sections
78
+ .filter((e) => e.level > 0)
79
+ .map((e) => _jsx(Entry, { e: e, onOpen: () => setReading(e.id) }, e.id)))] })] }));
80
+ }
81
+ /** One line of the tree, indented by its depth. */
82
+ function Entry({ e, onOpen }) {
83
+ return (_jsxs(Box, { render: "button", items: "flex-start", pl: (Math.max(1, e.level) - 1) * 14, py: "$1", onClick: onOpen, "data-slot": "drive-toc-entry", children: [_jsx(Text, { fontSize: "$2", color: "$ink", numberOfLines: 1, children: e.title }), e.summary ? (_jsx(Text, { fontSize: "$1", color: "$soft", numberOfLines: 2, children: e.summary })) : null] }));
84
+ }