@hanzo/rooms 0.1.36 → 0.1.38
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/Recents.js +11 -10
- package/dist/Share.d.ts +10 -0
- package/dist/Share.js +37 -22
- package/dist/Shell.js +10 -9
- package/dist/chord.d.ts +21 -0
- package/dist/chord.js +51 -0
- package/dist/pending.d.ts +41 -0
- package/dist/pending.js +181 -0
- package/dist/stars.d.ts +8 -0
- package/dist/stars.js +32 -0
- package/dist/thread.d.ts +35 -13
- package/dist/thread.js +370 -109
- package/dist/waiting.d.ts +2 -1
- package/dist/waiting.js +3 -2
- package/package.json +1 -1
package/dist/Recents.js
CHANGED
|
@@ -4,9 +4,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
4
4
|
// conversations (`/v1/agents/chat/conversations`, `useThreadList`), Dev its
|
|
5
5
|
// coding runs (`/v1/agent/sessions?kind=coding`, @hanzo/build `useSessions`). Each
|
|
6
6
|
// mode reads its own store. The menu beside the heading narrows, groups and sorts
|
|
7
|
-
// the list; the choice is kept per mode in this browser.
|
|
8
|
-
//
|
|
9
|
-
// Chat's Status shows the archived ones apart.
|
|
7
|
+
// the list; the choice is kept per mode in this browser. Chat's rows share one
|
|
8
|
+
// menu (thread.tsx `ConversationMenu`): pinned ones list first, under Pinned,
|
|
9
|
+
// and Chat's Status shows the archived ones apart.
|
|
10
10
|
import { useEffect, useId, useMemo, useRef } from 'react';
|
|
11
11
|
import { ScrollView, SizableText, XStack, YStack } from '@hanzo/gui';
|
|
12
12
|
import { ListFilter, MessageSquare } from 'lucide-react';
|
|
@@ -15,7 +15,7 @@ import { DropdownMenu } from '@hanzo/ui';
|
|
|
15
15
|
import { SidebarItem, StatusDot } from '@hanzo/ui/chat';
|
|
16
16
|
import { useKept } from './kept.js';
|
|
17
17
|
import { ago, arrange, chats, MENUS, PLAIN, runs, SINCE, viewOf } from './recent.js';
|
|
18
|
-
import { ThreadRow, useThreadList } from './thread.js';
|
|
18
|
+
import { ConversationMenu, ThreadRow, useThreadList } from './thread.js';
|
|
19
19
|
/** The menu's items: each section a heading and its options, the one in force checked. */
|
|
20
20
|
function items(mode, view, set) {
|
|
21
21
|
const m = MENUS[mode];
|
|
@@ -45,7 +45,7 @@ function Chats({ thread, words, view, keep, onOpen, onNew, }) {
|
|
|
45
45
|
const store = useThreadList(view.status === 'archived', thread);
|
|
46
46
|
const rows = useMemo(() => chats(store.threads), [store.threads]);
|
|
47
47
|
const byId = useMemo(() => new Map(store.threads.map((t) => [t.id, t])), [store.threads]);
|
|
48
|
-
return (_jsx(List, { mode: "chat", rows: rows, words: words, view: view, keep: keep, open: (r) => r.id === thread, reading: store.loading && !store.threads.length, error: store.error, onOpen: onOpen, threads: byId, onNew: onNew }));
|
|
48
|
+
return (_jsx(List, { mode: "chat", rows: rows, words: words, view: view, keep: keep, open: (r) => r.id === thread, reading: store.loading && !store.threads.length, error: store.error, onOpen: onOpen, threads: byId, held: thread, onNew: onNew }));
|
|
49
49
|
}
|
|
50
50
|
/** Dev's Recents: the org's coding runs alone. */
|
|
51
51
|
function Runs({ host, words, view, keep, onOpen }) {
|
|
@@ -64,7 +64,7 @@ function Runs({ host, words, view, keep, onOpen }) {
|
|
|
64
64
|
const rows = useMemo(() => runs(store.value), [store.value]);
|
|
65
65
|
return (_jsx(List, { mode: "dev", rows: rows, words: words, view: view, keep: keep, open: (row) => row.id === run, reading: store.loading && !store.value.length, error: store.error, onOpen: onOpen }));
|
|
66
66
|
}
|
|
67
|
-
function List({ mode, rows, words, view, keep, open, reading, error, onOpen, threads, onNew, }) {
|
|
67
|
+
function List({ mode, rows, words, view, keep, open, reading, error, onOpen, threads, held = null, onNew, }) {
|
|
68
68
|
const label = useId();
|
|
69
69
|
const groups = arrange(rows, view, words);
|
|
70
70
|
const narrowed = view.status !== PLAIN.status || view.since !== PLAIN.since;
|
|
@@ -72,7 +72,8 @@ function List({ mode, rows, words, view, keep, open, reading, error, onOpen, thr
|
|
|
72
72
|
const running = mode === 'dev' ? rows.filter((r) => r.status === 'running').length : 0;
|
|
73
73
|
const row = (r) => {
|
|
74
74
|
const when = ago(r.at, now);
|
|
75
|
-
const
|
|
75
|
+
const t = threads?.get(r.id);
|
|
76
|
+
const item = (_jsx(SidebarItem, { "data-kind": r.kind, active: open(r), rounded: "$4", minH: 32, pr: when || t ? '$8' : '$2', ...(t ? { $touchable: { pr: 52 } } : null), onKeyDown: (e) => {
|
|
76
77
|
if (e.key !== 'Enter' && e.key !== ' ')
|
|
77
78
|
return;
|
|
78
79
|
e.preventDefault?.();
|
|
@@ -80,10 +81,10 @@ function List({ mode, rows, words, view, keep, open, reading, error, onOpen, thr
|
|
|
80
81
|
}, icon: r.kind === 'chat' ? (_jsx(MessageSquare, { size: 14, "aria-hidden": true })) : (_jsx(XStack, { width: 14, justify: "center", children: _jsx(StatusDot, { status: DOTS[r.status] ?? 'idle' }) })), onPress: () => onOpen(r), children: r.title }));
|
|
81
82
|
// How long ago, at the row's end; the full time is its title.
|
|
82
83
|
const age = when ? (_jsx(SizableText, { size: "$1", color: "$soft", pointerEvents: "none", display: "flex", items: "center", fontVariant: ['tabular-nums'], children: _jsx("time", { dateTime: r.at, title: new Date(r.at).toLocaleString(), children: when }) })) : null;
|
|
83
|
-
const t = threads?.get(r.id);
|
|
84
84
|
if (t)
|
|
85
|
-
return (_jsx(ThreadRow, { t: t, active: open(r), aside: age,
|
|
85
|
+
return (_jsx(ThreadRow, { t: t, active: open(r), aside: age, children: item }, r.id));
|
|
86
86
|
return (_jsxs(YStack, { role: "listitem", position: "relative", children: [item, age ? (_jsx(XStack, { position: "absolute", r: "$2", t: 0, b: 0, items: "center", pointerEvents: "none", children: age })) : null] }, r.id));
|
|
87
87
|
};
|
|
88
|
-
|
|
88
|
+
const body = groups.length ? (_jsx(YStack, { role: "list", "aria-labelledby": label, gap: 1, children: groups.map((g) => g.title ? (_jsxs(YStack, { role: "listitem", gap: 1, pt: "$2", children: [_jsx(SizableText, { size: "$1", color: "$soft", px: "$2", pb: "$1", children: g.title }), _jsx(YStack, { role: "list", "aria-label": g.title, gap: 1, children: g.rows.map(row) })] }, g.title)) : (g.rows.map(row))) })) : reading ? (_jsx(Note, { children: mode === 'chat' ? 'Reading your chats…' : 'Reading your runs…' })) : error ? null : words.trim() ? (_jsx(Note, { children: `Nothing matches “${words.trim()}”.` })) : view.status === 'archived' ? (_jsx(Note, { children: "No archived chats." })) : narrowed ? (_jsx(Note, { children: "Nothing in this view." })) : (_jsx(Note, { children: mode === 'chat' ? 'No chats yet. Say something and it lands here.' : 'No runs yet.' }));
|
|
89
|
+
return (_jsxs(YStack, { "data-slot": "recents", flex: 1, minH: 0, mt: "$2", children: [_jsxs(XStack, { items: "center", justify: "space-between", height: 30, pl: "$2", pr: "$1", shrink: 0, children: [_jsxs(XStack, { items: "center", gap: "$2", children: [_jsx(SizableText, { id: label, size: "$1", color: "$soft", fontWeight: "600", textTransform: "uppercase", letterSpacing: 0.6, children: "Recents" }), running ? (_jsx(XStack, { "aria-label": `${running} running`, minW: 18, height: 18, px: "$1.5", rounded: 999, items: "center", justify: "center", bg: "$edge", borderWidth: 1, borderColor: "$borderColor", children: _jsx(SizableText, { size: "$1", fontWeight: "600", color: "$ink", "aria-hidden": true, children: String(running) }) })) : null] }), _jsx(DropdownMenu, { minWidth: 200, trigger: _jsx(XStack, { render: "button", "aria-label": "Recents view", width: 26, height: 26, rounded: "$2", items: "center", justify: "center", cursor: "pointer", hoverStyle: { bg: '$hover' }, bg: narrowed ? '$hover' : 'transparent', children: _jsx(ListFilter, { size: 14, "aria-hidden": true }) }), items: items(mode, view, keep) })] }), _jsxs(ScrollView, { flex: 1, showsVerticalScrollIndicator: false, children: [error ? _jsx(Note, { children: error.message }) : null, threads ? (_jsx(ConversationMenu, { threads: threads, active: held, onGone: onNew, children: _jsx(YStack, { children: body }) })) : (body)] })] }));
|
|
89
90
|
}
|
package/dist/Share.d.ts
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
1
|
export declare function Share({ thread }: {
|
|
2
2
|
thread: string;
|
|
3
3
|
}): import("react").JSX.Element;
|
|
4
|
+
/**
|
|
5
|
+
* The same panel as a dialog, for a conversation the pane need not hold: a row
|
|
6
|
+
* menu's Share…. `onCloseAutoFocus` says where focus goes when it shuts.
|
|
7
|
+
*/
|
|
8
|
+
export declare function ShareDialog({ thread, open, onOpenChange, onCloseAutoFocus, }: {
|
|
9
|
+
thread: string | null;
|
|
10
|
+
open: boolean;
|
|
11
|
+
onOpenChange: (open: boolean) => void;
|
|
12
|
+
onCloseAutoFocus?: (e: Event) => void;
|
|
13
|
+
}): import("react").JSX.Element;
|
package/dist/Share.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
// The conversation header's Share control and the panel it opens
|
|
2
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
// The conversation header's Share control and the panel it opens, and the same
|
|
4
|
+
// panel as a dialog for a conversation's row menu (thread.tsx): one `Sharing`,
|
|
5
|
+
// placed two ways.
|
|
4
6
|
//
|
|
5
7
|
// A link opens this ONE conversation, read only, as it stands when the link is
|
|
6
8
|
// made (lib/share.ts), to the people who open it signed in. The panel copies
|
|
@@ -9,7 +11,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
9
11
|
// do not reply: a conversation belongs to the member who started it.
|
|
10
12
|
import { useCallback, useEffect, useId, useRef, useState } from 'react';
|
|
11
13
|
import { Check, Link2, Share2 } from 'lucide-react';
|
|
12
|
-
import { Box, Button, Text, XStack, YStack } from '@hanzo/ui';
|
|
14
|
+
import { Box, Button, Dialog, DialogContent, DialogTitle, Text, XStack, YStack } from '@hanzo/ui';
|
|
13
15
|
import { hold, heldToken, link, Refused, shares } from './lib/share.js';
|
|
14
16
|
import { BAD } from './lib/mix.js';
|
|
15
17
|
/** How long "Link copied" stays. The same 1400 ms `Take` confirms for. */
|
|
@@ -29,9 +31,40 @@ export function Share({ thread }) {
|
|
|
29
31
|
}, []);
|
|
30
32
|
return (_jsxs(Box, { position: "relative", children: [_jsx(Box, { ref: button, render: "button", onClick: () => setOpen((o) => !o), "aria-haspopup": "dialog", "aria-expanded": open, "aria-label": "Share this conversation", borderWidth: 0, bg: "transparent", p: "$1", rounded: "$2", hoverStyle: { bg: '$hover' }, children: _jsxs(XStack, { items: "center", gap: "$1", children: [_jsx(Share2, { size: 13, "aria-hidden": true }), _jsx(Text, { fontSize: "$2", color: "$ink", children: "Share" })] }) }), open ? _jsx(Panel, { thread: thread, onClose: close }) : null] }));
|
|
31
33
|
}
|
|
34
|
+
/** The header's panel: hung under its button, shut by Escape or a press outside, focus starting inside. */
|
|
32
35
|
function Panel({ thread, onClose }) {
|
|
33
36
|
const heading = useId();
|
|
34
37
|
const box = useRef(null);
|
|
38
|
+
// Escape closes; a press outside closes; focus starts inside.
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
box.current?.querySelector('button')?.focus();
|
|
41
|
+
const key = (e) => {
|
|
42
|
+
if (e.key === 'Escape')
|
|
43
|
+
onClose();
|
|
44
|
+
};
|
|
45
|
+
const press = (e) => {
|
|
46
|
+
if (box.current && !box.current.contains(e.target) && !e.target?.closest?.('[aria-haspopup="dialog"]'))
|
|
47
|
+
onClose();
|
|
48
|
+
};
|
|
49
|
+
document.addEventListener('keydown', key);
|
|
50
|
+
document.addEventListener('mousedown', press);
|
|
51
|
+
return () => {
|
|
52
|
+
document.removeEventListener('keydown', key);
|
|
53
|
+
document.removeEventListener('mousedown', press);
|
|
54
|
+
};
|
|
55
|
+
}, [onClose]);
|
|
56
|
+
return (_jsx(YStack, { ref: box, role: "dialog", "aria-labelledby": heading, position: "absolute", r: 0, t: "100%", mt: "$2", z: "var(--z-dropdown)", width: 360, maxW: "calc(100vw - 32px)", p: "$4", gap: "$3", rounded: "$4", borderWidth: 1, borderColor: "$borderColor", bg: "$background", boxShadow: "0 16px 36px color-mix(in srgb, var(--pure-black) 30%, transparent)", children: _jsx(Sharing, { thread: thread, note: `${heading}-said`, title: _jsx(Text, { id: heading, render: "h2", fontSize: "$4", fontWeight: "600", color: "$ink", children: "Share this chat" }) }) }));
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The same panel as a dialog, for a conversation the pane need not hold: a row
|
|
60
|
+
* menu's Share…. `onCloseAutoFocus` says where focus goes when it shuts.
|
|
61
|
+
*/
|
|
62
|
+
export function ShareDialog({ thread, open, onOpenChange, onCloseAutoFocus, }) {
|
|
63
|
+
const note = useId();
|
|
64
|
+
return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsx(DialogContent, { maxW: 400, rounded: "$6", gap: "$3", onCloseAutoFocus: onCloseAutoFocus, children: thread ? _jsx(Sharing, { thread: thread, note: note, title: _jsx(DialogTitle, { children: "Share this chat" }) }) : null }) }));
|
|
65
|
+
}
|
|
66
|
+
/** What a link to one conversation is, who opened each, and the controls over them. */
|
|
67
|
+
function Sharing({ thread, title, note }) {
|
|
35
68
|
const [live, setLive] = useState(null);
|
|
36
69
|
const [busy, setBusy] = useState(null);
|
|
37
70
|
const [wrong, setWrong] = useState(null);
|
|
@@ -51,24 +84,6 @@ function Panel({ thread, onClose }) {
|
|
|
51
84
|
on = false;
|
|
52
85
|
};
|
|
53
86
|
}, [thread]);
|
|
54
|
-
// Escape closes; a press outside closes; focus starts inside.
|
|
55
|
-
useEffect(() => {
|
|
56
|
-
box.current?.querySelector('button')?.focus();
|
|
57
|
-
const key = (e) => {
|
|
58
|
-
if (e.key === 'Escape')
|
|
59
|
-
onClose();
|
|
60
|
-
};
|
|
61
|
-
const press = (e) => {
|
|
62
|
-
if (box.current && !box.current.contains(e.target) && !e.target?.closest?.('[aria-haspopup="dialog"]'))
|
|
63
|
-
onClose();
|
|
64
|
-
};
|
|
65
|
-
document.addEventListener('keydown', key);
|
|
66
|
-
document.addEventListener('mousedown', press);
|
|
67
|
-
return () => {
|
|
68
|
-
document.removeEventListener('keydown', key);
|
|
69
|
-
document.removeEventListener('mousedown', press);
|
|
70
|
-
};
|
|
71
|
-
}, [onClose]);
|
|
72
87
|
useEffect(() => {
|
|
73
88
|
if (!copied)
|
|
74
89
|
return;
|
|
@@ -126,5 +141,5 @@ function Panel({ thread, onClose }) {
|
|
|
126
141
|
setBusy(null);
|
|
127
142
|
}
|
|
128
143
|
};
|
|
129
|
-
return (_jsxs(
|
|
144
|
+
return (_jsxs(_Fragment, { children: [title, _jsx(Text, { fontSize: "$2", color: "$soft", lineHeight: "$3", children: "People who open the link signed in can read this chat as it is now, and you see who they are below. They cannot reply, and messages you send later are not shared." }), _jsxs(XStack, { items: "center", gap: "$2", children: [_jsx(Button, { size: "sm", onClick: () => void copy(), disabled: busy !== null || live === null, "aria-describedby": note, children: _jsxs(XStack, { items: "center", gap: "$1.5", children: [copied ? _jsx(Check, { size: 14, "aria-hidden": true }) : _jsx(Link2, { size: 14, "aria-hidden": true }), _jsx(Text, { fontSize: "$2", color: "inherit", children: copied ? 'Link copied' : busy === 'copy' ? 'Making a link…' : 'Copy link' })] }) }), _jsx(Text, { fontSize: "$1", color: "$soft", children: "Read only" })] }), _jsx(Text, { id: note, "aria-live": "polite", fontSize: "$1", color: "$soft", minH: 16, children: copied ? 'The link is on your clipboard.' : '' }), _jsxs(YStack, { gap: "$2", children: [_jsx(Text, { fontSize: "$1", color: "$soft", textTransform: "uppercase", children: "Who has access" }), _jsxs(XStack, { items: "center", justify: "space-between", children: [_jsx(Text, { fontSize: "$2", color: "$ink", children: "You" }), _jsx(Text, { fontSize: "$1", color: "$soft", children: "Owner" })] }), live === null ? (_jsx(Text, { fontSize: "$2", color: "$soft", children: "Reading links." })) : live.length === 0 ? (_jsx(Text, { fontSize: "$2", color: "$soft", children: "No links yet. Copy link makes one." })) : (_jsx(YStack, { render: "ul", gap: "$2", m: 0, p: 0, "aria-label": "Live links", children: live.map((row) => (_jsxs(YStack, { render: "li", gap: "$1.5", children: [_jsxs(XStack, { items: "center", justify: "space-between", gap: "$2", children: [_jsxs(YStack, { minW: 0, children: [_jsx(Text, { fontSize: "$2", color: "$ink", numberOfLines: 1, children: "Link" }), _jsxs(Text, { fontSize: "$1", color: "$soft", children: ["Read only", day(row.createdAt) ? ` · made ${day(row.createdAt)}` : ''] })] }), _jsx(Button, { size: "sm", variant: "outline", onClick: () => void revoke(row.id), disabled: busy !== null, "aria-label": `Revoke the link made ${day(row.createdAt) || 'earlier'}`, children: busy === row.id ? 'Revoking…' : 'Revoke' })] }), (row.viewers ?? []).length === 0 ? (_jsx(Text, { fontSize: "$1", color: "$soft", pl: "$3", children: "Nobody has opened it yet." })) : (_jsx(YStack, { render: "ul", gap: "$1", m: 0, pl: "$3", "aria-label": "Opened by", children: row.viewers.map((v) => (_jsxs(XStack, { render: "li", items: "center", justify: "space-between", gap: "$2", children: [_jsx(Text, { fontSize: "$2", color: "$ink", numberOfLines: 1, children: v.name || 'Someone signed in' }), _jsx(Button, { size: "sm", variant: "outline", onClick: () => void unview(row, v), disabled: busy !== null, "aria-label": `Remove ${v.name || 'this reader'}`, children: busy === v.id ? 'Removing…' : 'Remove' })] }, v.id))) }))] }, row.id))) }))] }), wrong ? (_jsx(Text, { role: "alert", fontSize: "$2", color: BAD, children: wrong })) : null] }));
|
|
130
145
|
}
|
package/dist/Shell.js
CHANGED
|
@@ -18,7 +18,7 @@ import { createPortal } from "react-dom";
|
|
|
18
18
|
import { MessageSquare, Code2, Inbox, Bot, ListChecks, HardDrive, Settings2, UserPlus, Building2, Search, Hash, AtSign, Lock, Workflow, Plus, ArrowLeft, ArrowRight, Send, Minus, Blocks, Star, FolderOpen, Activity, BookOpen, SquareTerminal, Share2, Sparkles, PanelLeft, PanelRight, Users, Globe, Cpu, Boxes, Wallet, Video, Calendar, LayoutGrid, ArrowUpLeft, } from "lucide-react";
|
|
19
19
|
import { AiProvider, useAgents, useInbox, useModels, usePeople, } from "@hanzo/ai/react";
|
|
20
20
|
import { PINNED } from "./recent.js";
|
|
21
|
-
import { ThreadRow, useThreadList } from "./thread.js";
|
|
21
|
+
import { ConversationMenu, ThreadRow, useThreadList } from "./thread.js";
|
|
22
22
|
import { Box, XStack, YStack, Text } from "@hanzo/ui";
|
|
23
23
|
// THE PAPER LADDER. `sheet(n)` is a fill AND the light on it, published as a
|
|
24
24
|
// pair by @hanzo/design, so a surface asks for a HEIGHT instead of composing a
|
|
@@ -406,6 +406,7 @@ function Chats({ filter = "", onPick, }) {
|
|
|
406
406
|
return list;
|
|
407
407
|
}, [threads, customThreads]);
|
|
408
408
|
const found = mergedThreads.filter((t) => matches(t.title || "Untitled", filter));
|
|
409
|
+
const byId = useMemo(() => new Map(threads.map((t) => [t.id, t])), [threads]);
|
|
409
410
|
// PINNED FIRST, by the server's pin (thread.tsx), then the days.
|
|
410
411
|
const shown = found.filter((t) => !t.thread?.pinned);
|
|
411
412
|
const kept = found.filter((t) => t.thread?.pinned);
|
|
@@ -424,14 +425,14 @@ function Chats({ filter = "", onPick, }) {
|
|
|
424
425
|
if (!signedIn)
|
|
425
426
|
return _jsx(Note, { children: "Sign in to see your recent chats." });
|
|
426
427
|
const mine = wrong ? (_jsx(Note, { children: say(wrong, "your chats") })) : loading && threads.length === 0 ? (_jsx(Note, { children: "Reading your chats." })) : found.length === 0 && filter ? (_jsxs(Note, { children: ["No chat matches \u201C", filter, "\u201D."] })) : threads.length === 0 ? (_jsx(Note, { children: "No chats yet. Say something and it lands here." })) : null;
|
|
427
|
-
return (_jsxs(_Fragment, { children: [mine, mine ? null : bands.map((band) => (_jsx(SidebarSection, { label: band.label, children: band.rows.map((t) => {
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
428
|
+
return (_jsxs(_Fragment, { children: [_jsx(ConversationMenu, { threads: byId, active: thread, children: _jsxs(YStack, { children: [mine, mine ? null : bands.map((band) => (_jsx(SidebarSection, { label: band.label, children: band.rows.map((t) => {
|
|
429
|
+
const item = (_jsx(SidebarItem, { active: t.id === thread, ...(t.thread ? { pr: "$8", $touchable: { pr: 52 } } : null), onPress: () => {
|
|
430
|
+
open(t.id);
|
|
431
|
+
onPick?.();
|
|
432
|
+
}, children: _jsxs(YStack, { minW: 0, children: [_jsxs(XStack, { items: "center", gap: "$1.5", minW: 0, children: [t.kind === "call" ? _jsx(Video, { size: 13, color: "var(--muted-foreground)", "aria-label": "Call" }) : null, t.kind === "group" ? _jsx(Users, { size: 13, color: "var(--muted-foreground)", "aria-label": "Group" }) : null, _jsx(Text, { fontSize: "$2", color: "$ink", numberOfLines: 1, children: t.title || "Untitled" })] }), _jsx(Span, { id: t.id, updatedAt: t.updatedAt })] }) }, t.id));
|
|
433
|
+
// A stored conversation carries its menu; a call or a group is no conversation of the store's.
|
|
434
|
+
return t.thread ? (_jsx(ThreadRow, { t: t.thread, active: t.id === thread, listed: false, children: item }, t.id)) : (item);
|
|
435
|
+
}) }, band.label)))] }) }), _jsx(SharedWithYou, { filter: filter, onPick: onPick })] }));
|
|
435
436
|
}
|
|
436
437
|
/**
|
|
437
438
|
* The chats other people shared with this reader, and who shared each: every
|
package/dist/chord.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type Act = 'rename' | 'pin';
|
|
2
|
+
/** The part of a keydown this reads. */
|
|
3
|
+
export interface Press {
|
|
4
|
+
key?: string;
|
|
5
|
+
code?: string;
|
|
6
|
+
ctrlKey: boolean;
|
|
7
|
+
altKey: boolean;
|
|
8
|
+
metaKey: boolean;
|
|
9
|
+
shiftKey: boolean;
|
|
10
|
+
repeat?: boolean;
|
|
11
|
+
isComposing?: boolean;
|
|
12
|
+
target: EventTarget | null;
|
|
13
|
+
}
|
|
14
|
+
/** Whether a key pressed on `target` is typing: a field, a picker, or text that edits itself. */
|
|
15
|
+
export declare function typing(target: EventTarget | null): boolean;
|
|
16
|
+
/** What a keydown asks of a conversation, or null when it asks nothing of one. */
|
|
17
|
+
export declare function chord(e: Press): Act | null;
|
|
18
|
+
/** A chord as a menu prints it: ⌃⌥R on a Mac, Ctrl+Alt+R elsewhere. */
|
|
19
|
+
export declare function said(act: Act, mac: boolean): string;
|
|
20
|
+
/** A chord as `aria-keyshortcuts` says it, the same on every platform. */
|
|
21
|
+
export declare const named: (act: Act) => string;
|
package/dist/chord.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// The keys that act on a conversation: Ctrl+Alt+R renames it, Ctrl+Alt+P pins
|
|
2
|
+
// or unpins it — the one whose menu is open, or else the one the pane holds.
|
|
3
|
+
// Never while the reader is typing or a dialog holds the keys, and once a press:
|
|
4
|
+
// a held key repeats, and a held Ctrl+Alt+P would toggle the pin thirty times a
|
|
5
|
+
// second. No React, so a spec reads it as it is.
|
|
6
|
+
const LETTERS = { r: 'rename', p: 'pin' };
|
|
7
|
+
/** Whether `target` sits inside something matching `selector`. */
|
|
8
|
+
const within = (target, selector) => {
|
|
9
|
+
const el = target;
|
|
10
|
+
return Boolean(el && typeof el === 'object' && typeof el.closest === 'function' && el.closest(selector) !== null);
|
|
11
|
+
};
|
|
12
|
+
/** Whether a key pressed on `target` is typing: a field, a picker, or text that edits itself. */
|
|
13
|
+
export function typing(target) {
|
|
14
|
+
const el = target;
|
|
15
|
+
if (!el || typeof el !== 'object')
|
|
16
|
+
return false;
|
|
17
|
+
if (el.isContentEditable)
|
|
18
|
+
return true;
|
|
19
|
+
const tag = (el.tagName ?? '').toUpperCase();
|
|
20
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT')
|
|
21
|
+
return true;
|
|
22
|
+
return within(target, '[contenteditable=""],[contenteditable="true"],[role="textbox"]');
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The letter a key press names: the one on the key, whatever the layout (the
|
|
26
|
+
* labelled P is where QWERTY's R is on Dvorak), or — where Option or AltGr made
|
|
27
|
+
* it another character, ⌥R being "®" on a Mac — the key's place.
|
|
28
|
+
*/
|
|
29
|
+
function letter(e) {
|
|
30
|
+
if (e.key && /^[a-z]$/i.test(e.key))
|
|
31
|
+
return e.key.toLowerCase();
|
|
32
|
+
const place = /^Key([A-Z])$/.exec(e.code ?? '');
|
|
33
|
+
return place ? place[1].toLowerCase() : null;
|
|
34
|
+
}
|
|
35
|
+
/** What a keydown asks of a conversation, or null when it asks nothing of one. */
|
|
36
|
+
export function chord(e) {
|
|
37
|
+
if (!e.ctrlKey || !e.altKey || e.metaKey || e.shiftKey || e.repeat || e.isComposing)
|
|
38
|
+
return null;
|
|
39
|
+
const l = letter(e);
|
|
40
|
+
const act = l ? LETTERS[l] : undefined;
|
|
41
|
+
if (!act || typing(e.target) || within(e.target, '[role="dialog"],[role="alertdialog"]'))
|
|
42
|
+
return null;
|
|
43
|
+
return act;
|
|
44
|
+
}
|
|
45
|
+
/** A chord as a menu prints it: ⌃⌥R on a Mac, Ctrl+Alt+R elsewhere. */
|
|
46
|
+
export function said(act, mac) {
|
|
47
|
+
const key = act === 'rename' ? 'R' : 'P';
|
|
48
|
+
return mac ? `⌃⌥${key}` : `Ctrl+Alt+${key}`;
|
|
49
|
+
}
|
|
50
|
+
/** A chord as `aria-keyshortcuts` says it, the same on every platform. */
|
|
51
|
+
export const named = (act) => `Control+Alt+${act === 'rename' ? 'R' : 'P'}`;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Thread } from '@hanzo/ai';
|
|
2
|
+
/** What a change sets: the fields it names. */
|
|
3
|
+
export type Patch = Partial<Pick<Thread, 'title' | 'pinned' | 'archived'>>;
|
|
4
|
+
/** A list of conversations on the page: the moment its rows were asked for, and how to ask again. */
|
|
5
|
+
export interface Reader {
|
|
6
|
+
at: number;
|
|
7
|
+
reload: () => void;
|
|
8
|
+
}
|
|
9
|
+
/** The next moment. A read takes one as it is asked for, an answer as it lands. */
|
|
10
|
+
export declare const tick: () => number;
|
|
11
|
+
/** For `useSyncExternalStore`: told whenever a change is made, settled, taken back or dropped. */
|
|
12
|
+
export declare function subscribe(listener: () => void): () => void;
|
|
13
|
+
/** For `useSyncExternalStore`: a number that moves whenever the changes do. */
|
|
14
|
+
export declare const snapshot: () => number;
|
|
15
|
+
/**
|
|
16
|
+
* Whose conversations these are: the client the lists read through. Another
|
|
17
|
+
* client is another reader or organization, and nothing asked under the last
|
|
18
|
+
* one is drawn over its rows.
|
|
19
|
+
*/
|
|
20
|
+
export declare function bind(next: unknown): void;
|
|
21
|
+
/** A list joins the page; the returned function is its leaving. */
|
|
22
|
+
export declare function enlist(reader: Reader): () => void;
|
|
23
|
+
/** Every list on the page reads the store again. */
|
|
24
|
+
export declare function reread(): void;
|
|
25
|
+
/** Folds each settled change every list on the page has read after into the thread it was laid over. */
|
|
26
|
+
export declare function sweep(): void;
|
|
27
|
+
/**
|
|
28
|
+
* A list as it reads with every change it has not read yet: `threads` read at
|
|
29
|
+
* `at`, from the archived shelf or the active one. A deleted conversation
|
|
30
|
+
* leaves; one archived or brought back leaves the shelf it is no longer on and
|
|
31
|
+
* joins the other; any other change shows in place.
|
|
32
|
+
*/
|
|
33
|
+
export declare function view(threads: Thread[], at: number, archived: boolean): Thread[];
|
|
34
|
+
/**
|
|
35
|
+
* Asks the store for one change to `t`, shown at once: `patch` the fields it
|
|
36
|
+
* sets, or null for a delete, and `send` the request, made once every earlier
|
|
37
|
+
* change to the same conversation has been answered. What the store answers
|
|
38
|
+
* for those fields is what shows. On a refusal this change alone is taken back
|
|
39
|
+
* and the refusal thrown.
|
|
40
|
+
*/
|
|
41
|
+
export declare function attempt(t: Thread, patch: Patch | null, send: () => Promise<Thread | void>): Promise<void>;
|
package/dist/pending.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// Changes asked of the conversation store that every list already shows.
|
|
2
|
+
//
|
|
3
|
+
// A pin, a rename, an archive or a delete shows at once: each list draws the
|
|
4
|
+
// changes held here over what it last read (`view`). No React, so a spec reads
|
|
5
|
+
// it as it is.
|
|
6
|
+
//
|
|
7
|
+
// ONE CONVERSATION'S CHANGES GO TO THE STORE IN ORDER. Each waits for the one
|
|
8
|
+
// before it to be answered, so two toggles are never answered out of order and
|
|
9
|
+
// the last one asked is the one the store keeps. A change is a PATCH of the
|
|
10
|
+
// fields it names, laid over the row; a refusal takes that one change back and
|
|
11
|
+
// leaves the others standing, so a refused rename does not unpin a pin still
|
|
12
|
+
// in flight.
|
|
13
|
+
//
|
|
14
|
+
// A SETTLED CHANGE IS KEPT UNTIL EVERY LIST HAS READ AFTER IT. Dropping it the
|
|
15
|
+
// moment the store answers would draw the list's older read, the row back where
|
|
16
|
+
// it was, until the list read again. So every read and every answer takes a
|
|
17
|
+
// moment from one clock (`tick`): a list read before a change settled still
|
|
18
|
+
// shows the change, one read after shows its own read, and once every list on
|
|
19
|
+
// the page has read after it the change is folded away (`sweep`).
|
|
20
|
+
let clock = 0;
|
|
21
|
+
/** The next moment. A read takes one as it is asked for, an answer as it lands. */
|
|
22
|
+
export const tick = () => ++clock;
|
|
23
|
+
const entries = new Map();
|
|
24
|
+
const readers = new Set();
|
|
25
|
+
const listeners = new Set();
|
|
26
|
+
let version = 0;
|
|
27
|
+
let owner = null;
|
|
28
|
+
const publish = () => {
|
|
29
|
+
version++;
|
|
30
|
+
for (const listener of listeners)
|
|
31
|
+
listener();
|
|
32
|
+
};
|
|
33
|
+
/** For `useSyncExternalStore`: told whenever a change is made, settled, taken back or dropped. */
|
|
34
|
+
export function subscribe(listener) {
|
|
35
|
+
listeners.add(listener);
|
|
36
|
+
return () => {
|
|
37
|
+
listeners.delete(listener);
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** For `useSyncExternalStore`: a number that moves whenever the changes do. */
|
|
41
|
+
export const snapshot = () => version;
|
|
42
|
+
/**
|
|
43
|
+
* Whose conversations these are: the client the lists read through. Another
|
|
44
|
+
* client is another reader or organization, and nothing asked under the last
|
|
45
|
+
* one is drawn over its rows.
|
|
46
|
+
*/
|
|
47
|
+
export function bind(next) {
|
|
48
|
+
if (owner !== null && owner !== next && entries.size) {
|
|
49
|
+
entries.clear();
|
|
50
|
+
publish();
|
|
51
|
+
}
|
|
52
|
+
owner = next;
|
|
53
|
+
}
|
|
54
|
+
/** A list joins the page; the returned function is its leaving. */
|
|
55
|
+
export function enlist(reader) {
|
|
56
|
+
readers.add(reader);
|
|
57
|
+
return () => {
|
|
58
|
+
readers.delete(reader);
|
|
59
|
+
sweep();
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/** Every list on the page reads the store again. */
|
|
63
|
+
export function reread() {
|
|
64
|
+
for (const reader of readers)
|
|
65
|
+
reader.reload();
|
|
66
|
+
}
|
|
67
|
+
/** Folds each settled change every list on the page has read after into the thread it was laid over. */
|
|
68
|
+
export function sweep() {
|
|
69
|
+
let moved = false;
|
|
70
|
+
for (const [id, entry] of entries) {
|
|
71
|
+
while (entry.layers.length) {
|
|
72
|
+
const layer = entry.layers[0];
|
|
73
|
+
if (!layer.settled)
|
|
74
|
+
break;
|
|
75
|
+
let read = true;
|
|
76
|
+
for (const reader of readers)
|
|
77
|
+
if (reader.at <= layer.settled)
|
|
78
|
+
read = false;
|
|
79
|
+
if (!read)
|
|
80
|
+
break;
|
|
81
|
+
entry.layers.shift();
|
|
82
|
+
moved = true;
|
|
83
|
+
if (!layer.patch) {
|
|
84
|
+
entry.layers.length = 0;
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
entry.seed = { ...entry.seed, ...layer.patch };
|
|
88
|
+
}
|
|
89
|
+
if (!entry.layers.length)
|
|
90
|
+
entries.delete(id);
|
|
91
|
+
}
|
|
92
|
+
if (moved)
|
|
93
|
+
publish();
|
|
94
|
+
}
|
|
95
|
+
/** Pinned first, then most recently spoken in: the store's own order. */
|
|
96
|
+
const first = (a, b) => Number(Boolean(b.pinned)) - Number(Boolean(a.pinned)) || (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '');
|
|
97
|
+
/** One conversation added to a list in the store's order. */
|
|
98
|
+
const place = (threads, t) => {
|
|
99
|
+
const i = threads.findIndex((one) => first(t, one) < 0);
|
|
100
|
+
return i < 0 ? [...threads, t] : [...threads.slice(0, i), t, ...threads.slice(i)];
|
|
101
|
+
};
|
|
102
|
+
/** A thread with changes laid over it in order; null once one of them deleted it. */
|
|
103
|
+
const lay = (t, layers) => {
|
|
104
|
+
let out = t;
|
|
105
|
+
for (const layer of layers) {
|
|
106
|
+
if (!layer.patch)
|
|
107
|
+
return null;
|
|
108
|
+
out = { ...out, ...layer.patch };
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
};
|
|
112
|
+
/**
|
|
113
|
+
* A list as it reads with every change it has not read yet: `threads` read at
|
|
114
|
+
* `at`, from the archived shelf or the active one. A deleted conversation
|
|
115
|
+
* leaves; one archived or brought back leaves the shelf it is no longer on and
|
|
116
|
+
* joins the other; any other change shows in place.
|
|
117
|
+
*/
|
|
118
|
+
export function view(threads, at, archived) {
|
|
119
|
+
let out = threads;
|
|
120
|
+
for (const [id, entry] of entries) {
|
|
121
|
+
const unread = entry.layers.filter((layer) => !layer.settled || at < layer.settled);
|
|
122
|
+
if (!unread.length)
|
|
123
|
+
continue;
|
|
124
|
+
const i = out.findIndex((t) => t.id === id);
|
|
125
|
+
// A row this list read carries every change it read after; one it lacks is the thread before them all.
|
|
126
|
+
const want = i >= 0 ? lay(out[i], unread) : lay(entry.seed, entry.layers);
|
|
127
|
+
if (!want || Boolean(want.archived) !== archived) {
|
|
128
|
+
if (i >= 0)
|
|
129
|
+
out = out.filter((t) => t.id !== id);
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
out = i >= 0 ? out.map((t) => (t.id === id ? want : t)) : place(out, want);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Asks the store for one change to `t`, shown at once: `patch` the fields it
|
|
139
|
+
* sets, or null for a delete, and `send` the request, made once every earlier
|
|
140
|
+
* change to the same conversation has been answered. What the store answers
|
|
141
|
+
* for those fields is what shows. On a refusal this change alone is taken back
|
|
142
|
+
* and the refusal thrown.
|
|
143
|
+
*/
|
|
144
|
+
export function attempt(t, patch, send) {
|
|
145
|
+
let entry = entries.get(t.id);
|
|
146
|
+
if (!entry) {
|
|
147
|
+
entry = { seed: t, layers: [], queue: Promise.resolve() };
|
|
148
|
+
entries.set(t.id, entry);
|
|
149
|
+
}
|
|
150
|
+
const held = entry;
|
|
151
|
+
const mine = { patch: patch ? { ...patch } : null, settled: 0 };
|
|
152
|
+
held.layers.push(mine);
|
|
153
|
+
publish();
|
|
154
|
+
const run = held.queue.then(async () => {
|
|
155
|
+
try {
|
|
156
|
+
const answer = await send();
|
|
157
|
+
if (answer && mine.patch) {
|
|
158
|
+
const said = answer;
|
|
159
|
+
for (const key of Object.keys(mine.patch))
|
|
160
|
+
if (said[key] !== undefined)
|
|
161
|
+
Object.assign(mine.patch, { [key]: said[key] });
|
|
162
|
+
}
|
|
163
|
+
mine.settled = tick();
|
|
164
|
+
publish();
|
|
165
|
+
}
|
|
166
|
+
catch (e) {
|
|
167
|
+
const i = held.layers.indexOf(mine);
|
|
168
|
+
if (i >= 0) {
|
|
169
|
+
held.layers.splice(i, 1);
|
|
170
|
+
if (!held.layers.length && entries.get(t.id) === held)
|
|
171
|
+
entries.delete(t.id);
|
|
172
|
+
publish();
|
|
173
|
+
}
|
|
174
|
+
throw e;
|
|
175
|
+
}
|
|
176
|
+
reread();
|
|
177
|
+
sweep();
|
|
178
|
+
});
|
|
179
|
+
held.queue = run.catch(() => { });
|
|
180
|
+
return run;
|
|
181
|
+
}
|
package/dist/stars.d.ts
CHANGED
|
@@ -14,3 +14,11 @@ export declare const titleOf: (name: string, direct?: boolean) => string;
|
|
|
14
14
|
* between them through the four components that separate them.
|
|
15
15
|
*/
|
|
16
16
|
export declare function useStarred(kind?: string): [Set<string>, (id: string) => void];
|
|
17
|
+
/**
|
|
18
|
+
* The conversations starred in this browser before a pin was the server's
|
|
19
|
+
* (@hanzo/rooms 0.1.36 stopped reading the set), made pins once. Each is asked
|
|
20
|
+
* for as a pin through `pin`; once the store has taken any of them the stars
|
|
21
|
+
* are forgotten, and a store that takes none — one without the route yet —
|
|
22
|
+
* leaves them for a later visit. Answers whether they were carried.
|
|
23
|
+
*/
|
|
24
|
+
export declare function carry(pin: (id: string) => Promise<unknown>): Promise<boolean>;
|
package/dist/stars.js
CHANGED
|
@@ -73,3 +73,35 @@ export function useStarred(kind = 'rooms') {
|
|
|
73
73
|
}, [kind]);
|
|
74
74
|
return [starred, toggle];
|
|
75
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* The conversations starred in this browser before a pin was the server's
|
|
78
|
+
* (@hanzo/rooms 0.1.36 stopped reading the set), made pins once. Each is asked
|
|
79
|
+
* for as a pin through `pin`; once the store has taken any of them the stars
|
|
80
|
+
* are forgotten, and a store that takes none — one without the route yet —
|
|
81
|
+
* leaves them for a later visit. Answers whether they were carried.
|
|
82
|
+
*/
|
|
83
|
+
export async function carry(pin) {
|
|
84
|
+
const key = keyOf('threads');
|
|
85
|
+
let ids;
|
|
86
|
+
try {
|
|
87
|
+
ids = JSON.parse(localStorage.getItem(key) || '[]');
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
if (!Array.isArray(ids))
|
|
93
|
+
return false;
|
|
94
|
+
const named = ids.filter((id) => typeof id === 'string' && id !== '');
|
|
95
|
+
if (!named.length)
|
|
96
|
+
return false;
|
|
97
|
+
const said = await Promise.allSettled(named.map((id) => pin(id)));
|
|
98
|
+
if (!said.some((one) => one.status === 'fulfilled'))
|
|
99
|
+
return false;
|
|
100
|
+
try {
|
|
101
|
+
localStorage.removeItem(key);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
// A browser that refuses storage asks again next visit; a pin asked twice is still one pin.
|
|
105
|
+
}
|
|
106
|
+
return true;
|
|
107
|
+
}
|
package/dist/thread.d.ts
CHANGED
|
@@ -1,34 +1,56 @@
|
|
|
1
1
|
import { type ReactElement, type ReactNode } from 'react';
|
|
2
2
|
import type { Thread } from '@hanzo/ai';
|
|
3
|
-
|
|
3
|
+
export interface ThreadList {
|
|
4
|
+
threads: Thread[];
|
|
5
|
+
loading: boolean;
|
|
6
|
+
error: Error | null;
|
|
7
|
+
reload: () => void;
|
|
8
|
+
}
|
|
4
9
|
/**
|
|
5
10
|
* The member's conversations: pinned first, then most recently spoken in, the
|
|
6
|
-
* archived ones left out — or, with `archived`, those alone
|
|
7
|
-
*
|
|
8
|
-
* hold it: a conversation it has not
|
|
11
|
+
* archived ones left out — or, with `archived`, those alone — with every change
|
|
12
|
+
* in flight drawn over them. It reads again whenever a conversation changes
|
|
13
|
+
* here, and once for `seek` when it does not hold it: a conversation it has not
|
|
14
|
+
* seen is one just begun.
|
|
9
15
|
*/
|
|
10
|
-
export declare function useThreadList(archived?: boolean, seek?: string | null):
|
|
11
|
-
/** What a conversation can be asked to do. Each
|
|
16
|
+
export declare function useThreadList(archived?: boolean, seek?: string | null): ThreadList;
|
|
17
|
+
/** What a conversation can be asked to do. Each shows at once; a refusal takes it back and says so in a toast. */
|
|
12
18
|
export declare function useThreadActions(): {
|
|
13
19
|
pin: (t: Thread) => Promise<boolean>;
|
|
14
20
|
archive: (t: Thread) => Promise<boolean>;
|
|
15
21
|
rename: (t: Thread, title: string) => Promise<boolean>;
|
|
16
22
|
remove: (t: Thread) => Promise<boolean>;
|
|
17
23
|
};
|
|
24
|
+
/** A conversation's own address, whole: the app at `/?chat=<id>` (lib/host `talk`), wherever the rooms are drawn. */
|
|
25
|
+
export declare const href: (id: string) => string;
|
|
18
26
|
/**
|
|
19
|
-
*
|
|
27
|
+
* A list of conversations with their menu: a right-click on a row or a
|
|
28
|
+
* long-press opens it at the point, a row's ⋯ hangs it from itself. Its one
|
|
29
|
+
* child holds the rows (`ThreadRow`). `threads` are the list's conversations by
|
|
30
|
+
* id, `active` the one the pane holds (what the keys act on with no menu open),
|
|
31
|
+
* and `onGone` runs after that one is deleted — the room moves to a new chat —
|
|
32
|
+
* defaulting to emptying the room.
|
|
33
|
+
*/
|
|
34
|
+
export declare function ConversationMenu({ threads, active, onGone, children, }: {
|
|
35
|
+
threads: Map<string, Thread>;
|
|
36
|
+
active?: string | null;
|
|
37
|
+
onGone?: () => void;
|
|
38
|
+
children: ReactElement;
|
|
39
|
+
}): import("react").JSX.Element;
|
|
40
|
+
/**
|
|
41
|
+
* One conversation's row, inside a `ConversationMenu`: its menu opens by a
|
|
42
|
+
* right-click anywhere on it, a long-press, or its ⋯; a middle-click or a
|
|
43
|
+
* ⌘/Ctrl-click opens it in a new tab.
|
|
20
44
|
*
|
|
21
45
|
* The ⋯ stands where `aside` (the row's age) stands, and takes its place while
|
|
22
|
-
* the row is hovered, focused, open in the pane or has its menu open
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* emptying the room.
|
|
46
|
+
* the row is hovered, focused, open in the pane or has its menu open, and
|
|
47
|
+
* always on a touch screen, which has no hover to find it by; the rest of the
|
|
48
|
+
* time the age reads there.
|
|
26
49
|
*/
|
|
27
|
-
export declare function ThreadRow({ t, active, aside,
|
|
50
|
+
export declare function ThreadRow({ t, active, aside, listed, children, }: {
|
|
28
51
|
t: Thread;
|
|
29
52
|
active?: boolean;
|
|
30
53
|
aside?: ReactNode;
|
|
31
|
-
onGone?: () => void;
|
|
32
54
|
/** A row of a list (`role="listitem"`); false where it stands among other rows. */
|
|
33
55
|
listed?: boolean;
|
|
34
56
|
children: ReactElement;
|
package/dist/thread.js
CHANGED
|
@@ -1,42 +1,80 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
// A conversation's own actions
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// row's ⋯ hangs it from itself (gui's DropdownMenu), which a keyboard reaches.
|
|
8
|
-
// One list of items feeds both. Not @hanzo/ui/product/menu's ContextMenu: it
|
|
9
|
-
// portals its panel under gui's `pointer-events: none` host, so it drew and took
|
|
10
|
-
// no click (measured: every ancestor of its panel reads `none`).
|
|
3
|
+
// A conversation's own actions, kept where the conversation is: PATCH and DELETE
|
|
4
|
+
// /v1/agents/chat/conversations/{id} (hanzoai/agent v1.0.12), the member's own
|
|
5
|
+
// only — and the ones that need nothing of the store: its link, a new tab, the
|
|
6
|
+
// Share panel.
|
|
11
7
|
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
8
|
+
// ONE MENU A LIST. `ConversationMenu` wraps a list of rows and mounts, once for
|
|
9
|
+
// all of them, gui's ContextMenu (a right-click at the cursor, a long-press on
|
|
10
|
+
// touch), gui's Menu hung from whichever row's ⋯ was pressed (a gui Menu takes
|
|
11
|
+
// many triggers and anchors to the one pressed), and the Rename, Delete and
|
|
12
|
+
// Share dialogs. A row (`ThreadRow`) is its markup and a trigger. The column's
|
|
13
|
+
// Recents and the shell's /chat list both wrap theirs in it. Not
|
|
14
|
+
// @hanzo/ui/product/menu's ContextMenu: it portals its panel under gui's
|
|
15
|
+
// `pointer-events: none` host, so it drew and took no click (measured: every
|
|
16
|
+
// ancestor of its panel reads `none`).
|
|
17
|
+
//
|
|
18
|
+
// A CHANGE SHOWS AT ONCE, everywhere, and is taken back with a toast if the
|
|
19
|
+
// store refuses it (pending.ts): every list on the page draws the changes in
|
|
20
|
+
// flight over what it last read, and reads the store again once it answers.
|
|
21
|
+
// A pin is the server's, never this browser's: it follows the person to every
|
|
22
|
+
// device they sign in on.
|
|
23
|
+
import { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore, } from 'react';
|
|
24
|
+
import { useAi } from '@hanzo/ai/react';
|
|
25
|
+
import { Archive, ArchiveRestore, ExternalLink, Link2, MoreHorizontal, Pencil, Pin, PinOff, Share2, Trash2 } from 'lucide-react';
|
|
26
|
+
import { Button, ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuShortcut, ContextMenuTrigger, Dialog, DialogContent, DialogTitle, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuTrigger, Input, SizableText, Text, XStack, YStack, toast, } from '@hanzo/ui';
|
|
27
|
+
import { empty } from './open.js';
|
|
21
28
|
import { say } from './lib/org.js';
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
29
|
+
import { talk } from './lib/host.js';
|
|
30
|
+
import { BAD } from './lib/mix.js';
|
|
31
|
+
import { site } from './where.js';
|
|
32
|
+
import { attempt, bind, enlist, reread, snapshot, subscribe, sweep, tick, view } from './pending.js';
|
|
33
|
+
import { chord, named, said } from './chord.js';
|
|
34
|
+
import { carry } from './stars.js';
|
|
35
|
+
import { ShareDialog } from './Share.js';
|
|
25
36
|
/**
|
|
26
37
|
* The member's conversations: pinned first, then most recently spoken in, the
|
|
27
|
-
* archived ones left out — or, with `archived`, those alone
|
|
28
|
-
*
|
|
29
|
-
* hold it: a conversation it has not
|
|
38
|
+
* archived ones left out — or, with `archived`, those alone — with every change
|
|
39
|
+
* in flight drawn over them. It reads again whenever a conversation changes
|
|
40
|
+
* here, and once for `seek` when it does not hold it: a conversation it has not
|
|
41
|
+
* seen is one just begun.
|
|
30
42
|
*/
|
|
31
43
|
export function useThreadList(archived = false, seek = null) {
|
|
32
|
-
const
|
|
33
|
-
|
|
44
|
+
const ai = useAi();
|
|
45
|
+
// What was read, when, and from which shelf: the other shelf's rows are never drawn as this one's.
|
|
46
|
+
const [read, setRead] = useState({ threads: [], at: 0, archived });
|
|
47
|
+
const [loading, setLoading] = useState(true);
|
|
48
|
+
const [error, setError] = useState(null);
|
|
49
|
+
const [asks, ask] = useState(0);
|
|
50
|
+
const reload = useCallback(() => ask((n) => n + 1), []);
|
|
51
|
+
const me = useRef({ at: 0, reload });
|
|
52
|
+
useEffect(() => enlist(me.current), []);
|
|
53
|
+
useEffect(() => bind(ai), [ai]);
|
|
34
54
|
useEffect(() => {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
39
|
-
|
|
55
|
+
const control = new AbortController();
|
|
56
|
+
const at = tick();
|
|
57
|
+
setLoading(true);
|
|
58
|
+
ai.threads.list({ archived, signal: control.signal }).then((threads) => {
|
|
59
|
+
if (control.signal.aborted)
|
|
60
|
+
return;
|
|
61
|
+
me.current.at = at;
|
|
62
|
+
setRead({ threads, at, archived });
|
|
63
|
+
setError(null);
|
|
64
|
+
setLoading(false);
|
|
65
|
+
sweep();
|
|
66
|
+
// A read that answers is a client the store takes: the browser's old stars can become pins.
|
|
67
|
+
stars(ai);
|
|
68
|
+
}, (e) => {
|
|
69
|
+
if (control.signal.aborted)
|
|
70
|
+
return;
|
|
71
|
+
setError(e instanceof Error ? e : new Error(String(e)));
|
|
72
|
+
setLoading(false);
|
|
73
|
+
});
|
|
74
|
+
return () => control.abort();
|
|
75
|
+
}, [ai, archived, asks]);
|
|
76
|
+
const changes = useSyncExternalStore(subscribe, snapshot, snapshot);
|
|
77
|
+
const threads = useMemo(() => (read.archived === archived ? view(read.threads, read.at, archived) : view([], 0, archived)), [read, archived, changes]);
|
|
40
78
|
const asked = useRef(new Set());
|
|
41
79
|
useEffect(() => {
|
|
42
80
|
if (archived || !seek || loading || asked.current.has(seek) || threads.some((t) => t.id === seek))
|
|
@@ -44,104 +82,327 @@ export function useThreadList(archived = false, seek = null) {
|
|
|
44
82
|
asked.current.add(seek);
|
|
45
83
|
reload();
|
|
46
84
|
}, [archived, seek, loading, threads, reload]);
|
|
47
|
-
return
|
|
85
|
+
return { threads, loading, error, reload };
|
|
86
|
+
}
|
|
87
|
+
/** How long a change waits for the store before it is taken back. */
|
|
88
|
+
const WAIT = 15_000;
|
|
89
|
+
/** A refusal in the store's words, or that the store did not answer at all. */
|
|
90
|
+
const refused = (e) => e instanceof DOMException && e.name === 'TimeoutError' ? 'The conversation store did not answer. Try again.' : say(e);
|
|
91
|
+
let carried = false;
|
|
92
|
+
/** The browser's stars from before pins were the server's, made pins once a page (stars.ts `carry`). */
|
|
93
|
+
function stars(ai) {
|
|
94
|
+
if (carried)
|
|
95
|
+
return;
|
|
96
|
+
carried = true;
|
|
97
|
+
void carry((id) => ai.threads.update(id, { pinned: true }, { signal: AbortSignal.timeout(WAIT) })).then((done) => {
|
|
98
|
+
if (done)
|
|
99
|
+
reread();
|
|
100
|
+
});
|
|
48
101
|
}
|
|
49
|
-
/** What a conversation can be asked to do. Each
|
|
102
|
+
/** What a conversation can be asked to do. Each shows at once; a refusal takes it back and says so in a toast. */
|
|
50
103
|
export function useThreadActions() {
|
|
51
104
|
const ai = useAi();
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
105
|
+
return useMemo(() => {
|
|
106
|
+
const run = async (t, change, send) => {
|
|
107
|
+
try {
|
|
108
|
+
// The clock starts when the change is asked, after the ones before it are answered.
|
|
109
|
+
await attempt(t, change, () => send(AbortSignal.timeout(WAIT)));
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
catch (e) {
|
|
113
|
+
toast.error(refused(e));
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
const change = (t, to) => run(t, to, (signal) => ai.threads.update(t.id, to, { signal }));
|
|
118
|
+
return {
|
|
119
|
+
pin: (t) => change(t, { pinned: !t.pinned }),
|
|
120
|
+
archive: (t) => change(t, { archived: !t.archived }),
|
|
121
|
+
rename: (t, title) => change(t, { title }),
|
|
122
|
+
remove: (t) => run(t, null, (signal) => ai.threads.remove(t.id, { signal })),
|
|
123
|
+
};
|
|
124
|
+
}, [ai]);
|
|
125
|
+
}
|
|
126
|
+
const titleOf = (t) => t.title || 'Untitled';
|
|
127
|
+
/** A conversation's own address, whole: the app at `/?chat=<id>` (lib/host `talk`), wherever the rooms are drawn. */
|
|
128
|
+
export const href = (id) => new URL(site(talk(id)), window.location.href).href;
|
|
129
|
+
/** Opens a conversation in a tab of its own. */
|
|
130
|
+
const away = (id) => {
|
|
131
|
+
window.open(href(id), '_blank', 'noopener');
|
|
132
|
+
};
|
|
133
|
+
/** Puts a conversation's address on the clipboard. */
|
|
134
|
+
async function copy(id) {
|
|
135
|
+
try {
|
|
136
|
+
await navigator.clipboard.writeText(href(id));
|
|
137
|
+
toast.success('Link copied');
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
toast.error('The browser would not copy the link.');
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/** The one colour in the menu: Delete's — design's destructive mixed toward the theme's ink, 4.5:1 on either theme's panel. */
|
|
144
|
+
const DANGER = BAD;
|
|
145
|
+
const mac = () => typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent);
|
|
146
|
+
const Rows = createContext(null);
|
|
147
|
+
const keyed = new Set();
|
|
148
|
+
/**
|
|
149
|
+
* A keydown anywhere on the page, before anything inside it handles it (an
|
|
150
|
+
* open menu takes its keys): the list whose menu is open, or else the first
|
|
151
|
+
* that holds the open conversation, acts.
|
|
152
|
+
*/
|
|
153
|
+
function hear(e) {
|
|
154
|
+
const act = chord(e);
|
|
155
|
+
if (!act)
|
|
156
|
+
return;
|
|
157
|
+
const all = [...keyed].map((one) => one.current).filter((one) => one !== null);
|
|
158
|
+
if (all.some((one) => one.held))
|
|
159
|
+
return;
|
|
160
|
+
const list = all.find((one) => one.open) ?? all.find((one) => one.target() !== null);
|
|
161
|
+
const t = list?.target();
|
|
162
|
+
if (!list || !t)
|
|
163
|
+
return;
|
|
164
|
+
e.preventDefault();
|
|
165
|
+
list.act(act, t);
|
|
166
|
+
}
|
|
167
|
+
function listen(keys) {
|
|
168
|
+
if (!keyed.size)
|
|
169
|
+
window.addEventListener('keydown', hear, true);
|
|
170
|
+
keyed.add(keys);
|
|
171
|
+
return () => {
|
|
172
|
+
keyed.delete(keys);
|
|
173
|
+
if (!keyed.size)
|
|
174
|
+
window.removeEventListener('keydown', hear, true);
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
/** The element of a row that takes focus: the row itself, a button. */
|
|
178
|
+
const own = (row) => row?.querySelector('[data-slot="sidebar-item"], [role="button"]') ?? null;
|
|
179
|
+
/**
|
|
180
|
+
* A list of conversations with their menu: a right-click on a row or a
|
|
181
|
+
* long-press opens it at the point, a row's ⋯ hangs it from itself. Its one
|
|
182
|
+
* child holds the rows (`ThreadRow`). `threads` are the list's conversations by
|
|
183
|
+
* id, `active` the one the pane holds (what the keys act on with no menu open),
|
|
184
|
+
* and `onGone` runs after that one is deleted — the room moves to a new chat —
|
|
185
|
+
* defaulting to emptying the room.
|
|
186
|
+
*/
|
|
187
|
+
export function ConversationMenu({ threads, active = null, onGone = empty, children, }) {
|
|
188
|
+
const act = useThreadActions();
|
|
189
|
+
// The conversation the pane holds NOW: a delete answered after the reader moved on leaves the room they moved to.
|
|
190
|
+
const holds = useRef(active);
|
|
191
|
+
holds.current = active;
|
|
192
|
+
// The list's own element, for the row after one deleted.
|
|
193
|
+
const list = useId();
|
|
194
|
+
const [menu, setMenu] = useState(null);
|
|
195
|
+
const [dialogue, setDialogue] = useState(null);
|
|
196
|
+
// The conversation the menu or a dialog acts on, by id, read from the list so
|
|
197
|
+
// it is current; the last one read stands in once the list lets it go (a
|
|
198
|
+
// closing Delete dialog still names it).
|
|
199
|
+
const [aimed, setAimed] = useState(null);
|
|
200
|
+
const last = useRef(null);
|
|
201
|
+
const listed = aimed ? (threads.get(aimed) ?? null) : null;
|
|
202
|
+
if (listed)
|
|
203
|
+
last.current = listed;
|
|
204
|
+
const subject = aimed ? (listed ?? (last.current?.id === aimed ? last.current : null)) : null;
|
|
205
|
+
// Where focus goes back to: the ⋯ pressed, or the row right-clicked.
|
|
206
|
+
const from = useRef(null);
|
|
207
|
+
// What a right-click or a long-press landed on, before the menu decides to open.
|
|
208
|
+
const pointed = useRef(null);
|
|
209
|
+
const ask = useCallback((d, t) => {
|
|
210
|
+
setAimed(t.id);
|
|
211
|
+
setMenu(null);
|
|
212
|
+
setDialogue(d);
|
|
62
213
|
}, []);
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
214
|
+
const aim = useCallback((t, el) => {
|
|
215
|
+
from.current = el;
|
|
216
|
+
setAimed(t.id);
|
|
217
|
+
}, []);
|
|
218
|
+
const point = (e) => {
|
|
219
|
+
const row = e.target?.closest?.('[data-thread]') ?? null;
|
|
220
|
+
pointed.current = row ? (threads.get(row.getAttribute('data-thread') ?? '') ?? null) : null;
|
|
221
|
+
if (pointed.current)
|
|
222
|
+
from.current = own(row);
|
|
223
|
+
};
|
|
224
|
+
// A menu shut by Escape, a choice or a press outside gives focus back where it
|
|
225
|
+
// was opened from — unless what was chosen opened a dialog, which takes it.
|
|
226
|
+
const back = (e) => {
|
|
227
|
+
e.preventDefault();
|
|
228
|
+
requestAnimationFrame(() => {
|
|
229
|
+
const now = document.activeElement;
|
|
230
|
+
if (!now || now === document.body)
|
|
231
|
+
from.current?.focus();
|
|
232
|
+
});
|
|
233
|
+
};
|
|
234
|
+
const shut = (open) => {
|
|
235
|
+
if (!open)
|
|
236
|
+
setDialogue(null);
|
|
237
|
+
};
|
|
238
|
+
const after = (e) => {
|
|
239
|
+
e.preventDefault();
|
|
240
|
+
if (from.current?.isConnected)
|
|
241
|
+
from.current.focus();
|
|
242
|
+
};
|
|
243
|
+
const keys = useRef(null);
|
|
244
|
+
keys.current = {
|
|
245
|
+
open: menu !== null,
|
|
246
|
+
held: dialogue !== null,
|
|
247
|
+
target: () => (menu !== null ? subject : active ? (threads.get(active) ?? null) : null),
|
|
248
|
+
act: (a, t) => {
|
|
249
|
+
// With no menu open, focus goes back to wherever the keys were pressed.
|
|
250
|
+
if (menu === null && document.activeElement instanceof HTMLElement)
|
|
251
|
+
from.current = document.activeElement;
|
|
252
|
+
if (a === 'rename')
|
|
253
|
+
return ask('rename', t);
|
|
254
|
+
setMenu(null);
|
|
255
|
+
void act.pin(t);
|
|
256
|
+
},
|
|
68
257
|
};
|
|
258
|
+
useEffect(() => listen(keys), []);
|
|
259
|
+
const rows = useMemo(() => ({ aim, open: menu ? aimed : null }), [aim, menu, aimed]);
|
|
260
|
+
// The menu is named by its conversation: gui would name it by its trigger, the
|
|
261
|
+
// list itself for a right-click, and the ⋯ for the other.
|
|
262
|
+
const title = subject ? titleOf(subject) : undefined;
|
|
263
|
+
const apple = mac();
|
|
264
|
+
const entries = subject
|
|
265
|
+
? [
|
|
266
|
+
{
|
|
267
|
+
key: 'rename',
|
|
268
|
+
label: 'Rename',
|
|
269
|
+
icon: _jsx(Pencil, { size: 16, "aria-hidden": true }),
|
|
270
|
+
shortcut: said('rename', apple),
|
|
271
|
+
keys: named('rename'),
|
|
272
|
+
run: () => ask('rename', subject),
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
key: 'pin',
|
|
276
|
+
label: subject.pinned ? 'Unpin' : 'Pin',
|
|
277
|
+
icon: subject.pinned ? _jsx(PinOff, { size: 16, "aria-hidden": true }) : _jsx(Pin, { size: 16, "aria-hidden": true }),
|
|
278
|
+
shortcut: said('pin', apple),
|
|
279
|
+
keys: named('pin'),
|
|
280
|
+
run: () => void act.pin(subject),
|
|
281
|
+
},
|
|
282
|
+
{ key: 'share', label: 'Share…', icon: _jsx(Share2, { size: 16, "aria-hidden": true }), run: () => ask('share', subject) },
|
|
283
|
+
{ key: 'copy', label: 'Copy link', icon: _jsx(Link2, { size: 16, "aria-hidden": true }), run: () => void copy(subject.id) },
|
|
284
|
+
{ key: 'tab', label: 'Open in new tab', icon: _jsx(ExternalLink, { size: 16, "aria-hidden": true }), run: () => away(subject.id) },
|
|
285
|
+
{
|
|
286
|
+
key: 'archive',
|
|
287
|
+
label: subject.archived ? 'Unarchive' : 'Archive',
|
|
288
|
+
icon: subject.archived ? _jsx(ArchiveRestore, { size: 16, "aria-hidden": true }) : _jsx(Archive, { size: 16, "aria-hidden": true }),
|
|
289
|
+
run: () => void act.archive(subject),
|
|
290
|
+
},
|
|
291
|
+
{ key: 'gone', line: true },
|
|
292
|
+
{ key: 'delete', label: 'Delete', icon: _jsx(Trash2, { size: 16, color: DANGER, "aria-hidden": true }), destructive: true, run: () => ask('delete', subject) },
|
|
293
|
+
]
|
|
294
|
+
: [];
|
|
295
|
+
return (_jsxs(Rows.Provider, { value: rows, children: [_jsxs(DropdownMenu, { open: menu === 'dots', onOpenChange: (open) => setMenu(open ? 'dots' : null), children: [_jsxs(ContextMenu
|
|
296
|
+
// Held here as well as inside gui's ContextMenu, so the keys can shut it.
|
|
297
|
+
, {
|
|
298
|
+
// Held here as well as inside gui's ContextMenu, so the keys can shut it.
|
|
299
|
+
open: menu === 'point', onOpenChange: (open, e) => {
|
|
300
|
+
if (!open)
|
|
301
|
+
return setMenu((m) => (m === 'point' ? null : m));
|
|
302
|
+
// Not on a conversation: the browser's own menu answers.
|
|
303
|
+
if (!pointed.current)
|
|
304
|
+
return e?.preventDefault();
|
|
305
|
+
setAimed(pointed.current.id);
|
|
306
|
+
setMenu('point');
|
|
307
|
+
}, children: [_jsx(ContextMenuTrigger, { asChild: true, "data-menu": list, onContextMenu: point, onPointerDown: (e) => {
|
|
308
|
+
if (e.pointerType !== 'mouse')
|
|
309
|
+
point(e);
|
|
310
|
+
}, children: children }), _jsx(ContextMenuContent, { minW: 220, onCloseAutoFocus: back, "aria-labelledby": undefined, "aria-label": title, children: entries.map((one) => 'line' in one ? (_jsx(ContextMenuSeparator, {}, one.key)) : (_jsx(ContextMenuItem, { variant: one.destructive ? 'destructive' : 'default', "aria-keyshortcuts": one.keys, onSelect: one.run, children: _jsx(Face, { entry: one, Shortcut: ContextMenuShortcut }) }, one.key))) })] }), _jsx(DropdownMenuContent, { align: "end", minW: 220, onCloseAutoFocus: back, "aria-labelledby": undefined, "aria-label": title, children: entries.map((one) => 'line' in one ? (_jsx(DropdownMenuSeparator, {}, one.key)) : (_jsx(DropdownMenuItem, { variant: one.destructive ? 'destructive' : 'default', "aria-keyshortcuts": one.keys, onSelect: one.run, children: _jsx(Face, { entry: one, Shortcut: DropdownMenuShortcut }) }, one.key))) })] }), _jsx(Rename, { t: subject, open: dialogue === 'rename', onOpenChange: shut, onCloseAutoFocus: after, save: (t, words) => {
|
|
311
|
+
if (words !== titleOf(t))
|
|
312
|
+
void act.rename(t, words);
|
|
313
|
+
} }), _jsx(Delete, { t: subject, open: dialogue === 'delete', onOpenChange: shut, onCloseAutoFocus: after, remove: (t) => {
|
|
314
|
+
// Focus goes on to the row after it, or the one before, not to the page.
|
|
315
|
+
const rows = [...(document.querySelector(`[data-menu="${list}"]`)?.querySelectorAll('[data-thread]') ?? [])];
|
|
316
|
+
const at = rows.findIndex((row) => row.getAttribute('data-thread') === t.id);
|
|
317
|
+
if (at >= 0)
|
|
318
|
+
from.current = own(rows[at + 1] ?? rows[at - 1] ?? null);
|
|
319
|
+
void act.remove(t).then((ok) => {
|
|
320
|
+
if (ok && holds.current === t.id)
|
|
321
|
+
onGone();
|
|
322
|
+
});
|
|
323
|
+
} }), _jsx(ShareDialog, { thread: subject?.id ?? null, open: dialogue === 'share', onOpenChange: shut, onCloseAutoFocus: after })] }));
|
|
324
|
+
}
|
|
325
|
+
/** One entry's line: its icon in a 16px well, its name, and its keys at the end. */
|
|
326
|
+
function Face({ entry, Shortcut, }) {
|
|
327
|
+
return (_jsxs(_Fragment, { children: [_jsx(XStack, { width: 16, height: 16, items: "center", justify: "center", shrink: 0, children: entry.icon }), _jsx(Text, { flex: 1, minW: 0, fontSize: "$2", color: entry.destructive ? DANGER : '$ink', numberOfLines: 1, children: entry.label }), entry.shortcut ? _jsx(Shortcut, { "aria-hidden": true, children: entry.shortcut }) : null] }));
|
|
69
328
|
}
|
|
70
|
-
const titleOf = (t) => t.title || 'Untitled';
|
|
71
329
|
/**
|
|
72
|
-
* One conversation's row
|
|
330
|
+
* One conversation's row, inside a `ConversationMenu`: its menu opens by a
|
|
331
|
+
* right-click anywhere on it, a long-press, or its ⋯; a middle-click or a
|
|
332
|
+
* ⌘/Ctrl-click opens it in a new tab.
|
|
73
333
|
*
|
|
74
334
|
* The ⋯ stands where `aside` (the row's age) stands, and takes its place while
|
|
75
|
-
* the row is hovered, focused, open in the pane or has its menu open
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
* emptying the room.
|
|
335
|
+
* the row is hovered, focused, open in the pane or has its menu open, and
|
|
336
|
+
* always on a touch screen, which has no hover to find it by; the rest of the
|
|
337
|
+
* time the age reads there.
|
|
79
338
|
*/
|
|
80
|
-
export function ThreadRow({ t, active = false, aside,
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
339
|
+
export function ThreadRow({ t, active = false, aside, listed = true, children, }) {
|
|
340
|
+
const menus = useContext(Rows);
|
|
341
|
+
if (!menus)
|
|
342
|
+
throw new Error('ThreadRow is drawn inside a ConversationMenu, which holds its menu.');
|
|
84
343
|
const [focused, setFocused] = useState(false);
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
key: 'pin',
|
|
90
|
-
label: t.pinned ? 'Unpin' : 'Pin',
|
|
91
|
-
icon: t.pinned ? _jsx(PinOff, { size: 16, "aria-hidden": true }) : _jsx(Pin, { size: 16, "aria-hidden": true }),
|
|
92
|
-
onSelect: () => void act.pin(t),
|
|
93
|
-
},
|
|
94
|
-
{ key: 'rename', label: 'Rename', icon: _jsx(Pencil, { size: 16, "aria-hidden": true }), onSelect: () => setRenaming(true) },
|
|
95
|
-
{
|
|
96
|
-
key: 'archive',
|
|
97
|
-
label: t.archived ? 'Unarchive' : 'Archive',
|
|
98
|
-
icon: t.archived ? _jsx(ArchiveRestore, { size: 16, "aria-hidden": true }) : _jsx(Archive, { size: 16, "aria-hidden": true }),
|
|
99
|
-
onSelect: () => void act.archive(t),
|
|
100
|
-
},
|
|
101
|
-
{ type: 'separator', key: 'gone' },
|
|
102
|
-
{ key: 'delete', label: 'Delete', icon: _jsx(Trash2, { size: 16, "aria-hidden": true }), destructive: true, onSelect: () => setDeleting(true) },
|
|
103
|
-
];
|
|
344
|
+
// Its own id: gui gives every trigger of one menu the menu's one id.
|
|
345
|
+
const id = useId();
|
|
346
|
+
// What the ⋯ is for, said after its name: the row's title.
|
|
347
|
+
const about = useId();
|
|
104
348
|
// The ⋯ and the age share one place: whichever shows, the other gives way.
|
|
105
|
-
const shown = active ||
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
349
|
+
const shown = active || menus.open === t.id || focused;
|
|
350
|
+
// gui types these handlers by React Native's event; on the web they carry the DOM's.
|
|
351
|
+
const aim = (e) => menus.aim(t, e.currentTarget);
|
|
352
|
+
// A new tab for the row itself, never for its ⋯.
|
|
353
|
+
const onDots = (target) => Boolean(target?.closest?.('[data-slot="thread-options"]'));
|
|
354
|
+
return (_jsxs(YStack, { ...(listed ? { role: 'listitem' } : null), position: "relative", group: true, "data-thread": t.id, "data-pinned": t.pinned ? '' : undefined, onClickCapture: (press) => {
|
|
355
|
+
const e = press;
|
|
356
|
+
if (!(e.metaKey || e.ctrlKey) || onDots(e.target))
|
|
357
|
+
return;
|
|
358
|
+
e.preventDefault();
|
|
359
|
+
e.stopPropagation();
|
|
360
|
+
away(t.id);
|
|
361
|
+
}, onMouseDown: (e) => {
|
|
362
|
+
// The middle button's own autoscroll would start here.
|
|
363
|
+
if (e.button === 1)
|
|
364
|
+
e.preventDefault();
|
|
365
|
+
}, onMouseUp: (e) => {
|
|
366
|
+
if (e.button !== 1 || onDots(e.target))
|
|
367
|
+
return;
|
|
368
|
+
e.preventDefault();
|
|
369
|
+
away(t.id);
|
|
370
|
+
}, children: [children, _jsx(Text, { id: about, display: "none", children: titleOf(t) }), aside ? (_jsx(XStack, { position: "absolute", r: "$2", t: 0, b: 0, items: "center", pointerEvents: "none", opacity: shown ? 0 : 1, "$group-hover": { opacity: 0 }, $touchable: { opacity: 0 }, children: aside })) : null, _jsx(XStack, { position: "absolute", r: "$1", t: 0, b: 0, items: "center", children: _jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(XStack, { render: "button", "aria-label": "Chat options", "aria-describedby": about, id: id, "data-slot": "thread-options", width: 26, height: 26, rounded: "$2", items: "center", justify: "center", cursor: "pointer", bg: "transparent", opacity: shown ? 1 : 0, "$group-hover": { opacity: 1 },
|
|
371
|
+
// A thumb's 44px on a touch screen (lib/tap.ts), centred on the row and over its edges.
|
|
372
|
+
$touchable: { opacity: 1, minW: 44, minH: 44 }, hoverStyle: { bg: '$hover' },
|
|
373
|
+
// Before gui's own press handler, which opens the menu and stops there.
|
|
374
|
+
onPointerDownCapture: aim, onKeyDownCapture: aim, onFocus: () => setFocused(true), onBlur: () => setFocused(false), children: _jsx(MoreHorizontal, { size: 15, "aria-hidden": true }) }) }) })] }));
|
|
112
375
|
}
|
|
113
376
|
/** A conversation's title, written again: one line, never empty. */
|
|
114
|
-
function Rename({ t, open, onOpenChange, save, }) {
|
|
115
|
-
const [words, setWords] = useState(
|
|
116
|
-
|
|
377
|
+
function Rename({ t, open, onOpenChange, onCloseAutoFocus, save, }) {
|
|
378
|
+
const [words, setWords] = useState('');
|
|
379
|
+
// The title as it stood when the dialog opened; a list read again meanwhile does not overwrite the typing.
|
|
380
|
+
const id = t?.id;
|
|
117
381
|
useEffect(() => {
|
|
118
|
-
if (open)
|
|
382
|
+
if (open && t)
|
|
119
383
|
setWords(titleOf(t));
|
|
120
|
-
}, [open,
|
|
121
|
-
const submit =
|
|
384
|
+
}, [open, id]);
|
|
385
|
+
const submit = () => {
|
|
122
386
|
const title = words.trim();
|
|
123
|
-
|
|
387
|
+
// Once: a second Enter while the dialog closes asks nothing.
|
|
388
|
+
if (!open || !title || !t)
|
|
124
389
|
return;
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
onOpenChange(false);
|
|
128
|
-
setBusy(false);
|
|
390
|
+
onOpenChange(false);
|
|
391
|
+
save(t, title);
|
|
129
392
|
};
|
|
130
|
-
return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { maxW: 440, rounded: "$6", gap: "$3", children: [_jsx(DialogTitle, { children: "Rename chat" }), _jsx(Input, { autoFocus: true, value: words, onChangeText: setWords, "aria-label": "Chat title", maxLength: 80, onKeyDown: (e) => {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
393
|
+
return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { maxW: 440, rounded: "$6", gap: "$3", onCloseAutoFocus: onCloseAutoFocus, children: [_jsx(DialogTitle, { children: "Rename chat" }), _jsx(Input, { autoFocus: true, value: words, onChangeText: setWords, "aria-label": "Chat title", maxLength: 80, onKeyDown: (e) => {
|
|
394
|
+
// An Enter that ends an IME composition writes the word; it does not save.
|
|
395
|
+
if (e.key === 'Enter' && !e.nativeEvent?.isComposing && e.keyCode !== 229)
|
|
396
|
+
submit();
|
|
397
|
+
} }), _jsxs(XStack, { gap: "$2", justify: "flex-end", children: [_jsx(Button, { size: "sm", variant: "outline", rounded: 999, onPress: () => onOpenChange(false), children: "Cancel" }), _jsx(Button, { size: "sm", rounded: 999, disabled: !words.trim(), onPress: submit, children: "Save" })] })] }) }));
|
|
134
398
|
}
|
|
135
399
|
/** Deleting asks first, and says what goes with it: every turn, and every link it was shared by. */
|
|
136
|
-
function Delete({ t, open, onOpenChange, remove, }) {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
setBusy(false);
|
|
145
|
-
};
|
|
146
|
-
return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { maxW: 440, rounded: "$6", gap: "$3", children: [_jsx(DialogTitle, { children: "Delete chat?" }), _jsx(SizableText, { size: "$2", color: "$soft", children: `“${titleOf(t)}” is deleted for good, with every link it was shared by. To keep it out of the list instead, archive it.` }), _jsxs(XStack, { gap: "$2", justify: "flex-end", children: [_jsx(Button, { size: "sm", variant: "outline", rounded: 999, onPress: () => onOpenChange(false), children: "Cancel" }), _jsx(Button, { size: "sm", variant: "destructive", rounded: 999, disabled: busy, onPress: () => void submit(), children: busy ? 'Deleting…' : 'Delete' })] })] }) }));
|
|
400
|
+
function Delete({ t, open, onOpenChange, onCloseAutoFocus, remove, }) {
|
|
401
|
+
return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { maxW: 440, rounded: "$6", gap: "$3", onCloseAutoFocus: onCloseAutoFocus, children: [_jsx(DialogTitle, { children: "Delete chat?" }), _jsx(SizableText, { size: "$2", color: "$soft", children: `“${t ? titleOf(t) : ''}” is deleted for good, with every link it was shared by. To keep it out of the list instead, archive it.` }), _jsxs(XStack, { gap: "$2", justify: "flex-end", children: [_jsx(Button, { size: "sm", variant: "outline", rounded: 999, onPress: () => onOpenChange(false), children: "Cancel" }), _jsx(Button, { size: "sm", variant: "destructive", rounded: 999, disabled: !t, onPress: () => {
|
|
402
|
+
// Once: a second press while the dialog closes would ask the store to delete it again.
|
|
403
|
+
if (!open || !t)
|
|
404
|
+
return;
|
|
405
|
+
onOpenChange(false);
|
|
406
|
+
remove(t);
|
|
407
|
+
}, children: "Delete" })] })] }) }));
|
|
147
408
|
}
|
package/dist/waiting.d.ts
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* a layout that already supplies a header and a footer — so a page carrying one
|
|
8
8
|
* line of text scrolled, and the line sat below the middle of it.
|
|
9
9
|
*
|
|
10
|
-
* It
|
|
10
|
+
* It fills the viewport below the bar (`--header`, which the bar publishes) and
|
|
11
|
+
* centres one spinner and one line in it, the same on every route that waits.
|
|
11
12
|
*/
|
|
12
13
|
export declare function Waiting({ title, lede }: {
|
|
13
14
|
title: string;
|
package/dist/waiting.js
CHANGED
|
@@ -10,8 +10,9 @@ import { Spinner, Text, YStack } from '@hanzo/gui';
|
|
|
10
10
|
* a layout that already supplies a header and a footer — so a page carrying one
|
|
11
11
|
* line of text scrolled, and the line sat below the middle of it.
|
|
12
12
|
*
|
|
13
|
-
* It
|
|
13
|
+
* It fills the viewport below the bar (`--header`, which the bar publishes) and
|
|
14
|
+
* centres one spinner and one line in it, the same on every route that waits.
|
|
14
15
|
*/
|
|
15
16
|
export function Waiting({ title, lede }) {
|
|
16
|
-
return (_jsxs(YStack, { items: "center", justify: "center", gap: "$5", minH:
|
|
17
|
+
return (_jsxs(YStack, { items: "center", justify: "center", gap: "$5", minH: "calc(100dvh - var(--header, 64px))", px: "$4", py: "$6", children: [_jsx(Spinner, { size: "large", color: "$ink", width: 44, height: 44, shrink: 0 }), _jsx(Text, { render: "h1", fontSize: "$7", fontWeight: "500", color: "$ink", text: "center", children: title }), lede ? (_jsx(Text, { fontSize: "$4", color: "$soft", text: "center", children: lede })) : null] }));
|
|
17
18
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hanzo/rooms",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.38",
|
|
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",
|