@hanzo/rooms 0.1.11 → 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 +145 -17
- package/dist/Contents.d.ts +23 -0
- package/dist/Contents.js +84 -0
- package/dist/Drive.js +131 -39
- package/dist/RightPane.d.ts +1 -1
- package/dist/RightPane.js +26 -5
- package/dist/lib/attach.d.ts +40 -33
- package/dist/lib/attach.js +93 -58
- package/dist/lib/files.d.ts +162 -0
- package/dist/lib/files.js +289 -0
- package/dist/pane.d.ts +39 -17
- package/dist/pane.js +125 -29
- package/package.json +1 -1
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 {
|
|
37
|
-
import {
|
|
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
|
-
|
|
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.
|
|
613
|
-
//
|
|
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
|
-
/**
|
|
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
|
-
|
|
803
|
-
outgoing.current = images(going);
|
|
890
|
+
}
|
|
804
891
|
setDraft("");
|
|
805
|
-
|
|
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
|
-
|
|
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) => (
|
|
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;
|
package/dist/Contents.js
ADDED
|
@@ -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
|
+
}
|