@hanzo/rooms 0.1.11 → 0.1.13

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";
@@ -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,11 +677,15 @@ 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
@@ -794,15 +866,63 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
794
866
  }
795
867
  speak(text);
796
868
  }, [model, speak]);
797
- /** 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
+ */
798
880
  const submit = () => {
799
881
  const text = draft.trim();
800
- if (!text && !files.length)
882
+ if (preparing || (!text && !files.length))
883
+ return;
884
+ const holding = files.slice();
885
+ const earlier = carriedIn(chat.messages);
886
+ if (!holding.length && !earlier.length) {
887
+ setDraft("");
888
+ guarded(text);
801
889
  return;
802
- const going = spend(at);
803
- outgoing.current = images(going);
890
+ }
804
891
  setDraft("");
805
- 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
+ })();
806
926
  };
807
927
  /*
808
928
  WHAT AN UNANSWERED QUESTION LOOKS LIKE: a turn of its own directly under it,
@@ -912,7 +1032,8 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
912
1032
  if (!e.dataTransfer.files?.length)
913
1033
  return;
914
1034
  e.preventDefault();
915
- 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));
916
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: () => {
917
1038
  if (aside === null) {
918
1039
  showAside("tasks");
@@ -950,6 +1071,13 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
950
1071
  if (turn.role === "user" && Array.isArray(turn.content))
951
1072
  return _jsx(Parts, { parts: partsOf(turn.content), prose: (text) => _jsx(Prose, { text: text }) });
952
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
+ }
953
1081
  // WHO IS ANSWERING, while the answer has no words yet: the agents the
954
1082
  // question addresses, or the whole room when it names nobody.
955
1083
  if (turn.role === "assistant" && !said && chat.streaming && room.length) {
@@ -1038,7 +1166,7 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
1038
1166
  // `Control` circle as the mic, filled.
1039
1167
  // THE PAPERCLIP is the column's "Attach files", one press closer: the
1040
1168
  // same `take`, into the same `hold`.
1041
- 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" }) })] })),
1042
1170
  // A file or an image pasted into the field is held like a picked one;
1043
1171
  // words paste as words.
1044
1172
  field: {
@@ -1046,12 +1174,12 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
1046
1174
  if (!e.clipboardData?.files?.length)
1047
1175
  return;
1048
1176
  e.preventDefault();
1049
- void hold(at, Array.from(e.clipboardData.files));
1177
+ void hold(at, Array.from(e.clipboardData.files), via);
1050
1178
  },
1051
1179
  },
1052
1180
  // WHAT GOES WITH THE MESSAGE, above the frame: one chip a file, each
1053
1181
  // with its own remove, and the reason any file was refused.
1054
- head: files.length || turnedAway || 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) => (_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, 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
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
1055
1183
  ? REFUSED.lost
1056
1184
  : talk.reply
1057
1185
  ? `Hanzo: ${talk.reply}`
@@ -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
+ }