@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 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. A conversation's row
8
- // carries its own menu (thread.tsx): pinned ones list first, under Pinned, and
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 item = (_jsx(SidebarItem, { "data-kind": r.kind, active: open(r), rounded: "$4", minH: 32, pr: when ? '$8' : '$2', onKeyDown: (e) => {
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, onGone: onNew, children: item }, r.id));
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
- 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, 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.' }))] })] }));
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(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(Text, { id: heading, render: "h2", fontSize: "$4", fontWeight: "600", color: "$ink", children: "Share this chat" }), _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": `${heading}-said`, 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: `${heading}-said`, "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] }));
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
- const item = (_jsx(SidebarItem, { active: t.id === thread, onPress: () => {
429
- open(t.id);
430
- onPick?.();
431
- }, 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));
432
- // A stored conversation carries its menu; a call or a group is no conversation of the store's.
433
- return t.thread ? (_jsx(ThreadRow, { t: t.thread, active: t.id === thread, listed: false, children: item }, t.id)) : (item);
434
- }) }, band.label))), _jsx(SharedWithYou, { filter: filter, onPick: onPick })] }));
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
@@ -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>;
@@ -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
- import { useThreads } from '@hanzo/ai/react';
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. It reads again
7
- * whenever a conversation changes here, and once for `seek` when it does not
8
- * hold it: a conversation it has not seen is one just begun.
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): ReturnType<typeof useThreads>;
11
- /** What a conversation can be asked to do. Each reports a failure in a toast, in its own words. */
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
- * One conversation's row with its menu: a right-click anywhere on it, or its ⋯.
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; the rest
23
- * of the time the age reads there. `onGone` runs after the conversation the
24
- * pane holds is deleted — the column moves to a new chat — and defaults to
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, onGone, listed, children, }: {
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 — pin, rename, archive, delete — kept where the
4
- // conversation is: PATCH and DELETE /v1/agent/chat/conversations/{id}
5
- // (hanzoai/agent v1.0.12), the member's own only. One menu, two ways in: a
6
- // right-click on the row opens it at the cursor (gui's ContextMenu), and the
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
- // EVERY LIST ON THE PAGE READS AGAIN WHEN ONE CONVERSATION CHANGES, so the
13
- // column's Recents, the rooms' shell and the conversation's own header agree
14
- // without a second copy of anyone's pins. A pin is the server's, never this
15
- // browser's: it follows the person to every device they sign in on.
16
- import { useCallback, useEffect, useRef, useState } from 'react';
17
- import { useAi, useThreads } from '@hanzo/ai/react';
18
- import { Archive, ArchiveRestore, MoreHorizontal, Pencil, Pin, PinOff, Trash2 } from 'lucide-react';
19
- import { Button, ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, Dialog, DialogContent, DialogTitle, DropdownMenu, Input, SizableText, XStack, YStack, toast, } from '@hanzo/ui';
20
- import { empty, useOpen } from './open.js';
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
- const lists = new Set();
23
- /** Every list of conversations on the page reads again. */
24
- const changed = () => lists.forEach((reload) => reload());
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. It reads again
28
- * whenever a conversation changes here, and once for `seek` when it does not
29
- * hold it: a conversation it has not seen is one just begun.
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 store = useThreads({ archived });
33
- const { reload, loading, threads } = store;
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
- lists.add(reload);
36
- return () => {
37
- lists.delete(reload);
38
- };
39
- }, [reload]);
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 store;
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 reports a failure in a toast, in its own words. */
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
- const run = useCallback(async (work) => {
53
- try {
54
- await work();
55
- changed();
56
- return true;
57
- }
58
- catch (e) {
59
- toast.error(say(e));
60
- return false;
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
- return {
64
- pin: (t) => run(() => ai.threads.update(t.id, { pinned: !t.pinned })),
65
- archive: (t) => run(() => ai.threads.update(t.id, { archived: !t.archived })),
66
- rename: (t, title) => run(() => ai.threads.update(t.id, { title })),
67
- remove: (t) => run(() => ai.threads.remove(t.id)),
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 with its menu: a right-click anywhere on it, or its ⋯.
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; the rest
76
- * of the time the age reads there. `onGone` runs after the conversation the
77
- * pane holds is deleted — the column moves to a new chat — and defaults to
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, onGone = empty, listed = true, children, }) {
81
- const act = useThreadActions();
82
- const { thread } = useOpen();
83
- const [menu, setMenu] = useState(false);
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
- const [renaming, setRenaming] = useState(false);
86
- const [deleting, setDeleting] = useState(false);
87
- const items = [
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 || menu || focused;
106
- return (_jsxs(_Fragment, { children: [_jsxs(ContextMenu, { children: [_jsx(ContextMenuTrigger, { asChild: true, children: _jsxs(YStack, { ...(listed ? { role: 'listitem' } : null), position: "relative", group: true, "data-thread": t.id, "data-pinned": t.pinned ? '' : undefined, children: [children, aside ? (_jsx(XStack, { position: "absolute", r: "$2", t: 0, b: 0, items: "center", pointerEvents: "none", opacity: shown ? 0 : 1, "$group-hover": { opacity: 0 }, children: aside })) : null, _jsx(XStack, { position: "absolute", r: "$1", t: 0, b: 0, items: "center", children: _jsx(DropdownMenu, { open: menu, onOpenChange: setMenu, minWidth: 180, items: items, trigger: _jsx(XStack, { render: "button", "aria-label": "Chat options", "aria-haspopup": "menu", "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 }, hoverStyle: { bg: '$hover' }, onFocus: () => setFocused(true), onBlur: () => setFocused(false), children: _jsx(MoreHorizontal, { size: 15, "aria-hidden": true }) }) }) })] }) }), _jsx(ContextMenuContent, { children: items.map((one, i) => one.type === 'separator' ? (_jsx(ContextMenuSeparator, {}, one.key ?? i)) : one.type === 'label' ? null : (_jsxs(ContextMenuItem, { variant: one.destructive ? 'destructive' : 'default', onSelect: one.onSelect, children: [one.icon, one.label] }, one.key))) })] }), _jsx(Rename, { t: t, open: renaming, onOpenChange: setRenaming, save: (words) => act.rename(t, words) }), _jsx(Delete, { t: t, open: deleting, onOpenChange: setDeleting, remove: async () => {
107
- const ok = await act.remove(t);
108
- if (ok && thread === t.id)
109
- onGone();
110
- return ok;
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(titleOf(t));
116
- const [busy, setBusy] = useState(false);
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, t]);
121
- const submit = async () => {
384
+ }, [open, id]);
385
+ const submit = () => {
122
386
  const title = words.trim();
123
- if (!title || busy)
387
+ // Once: a second Enter while the dialog closes asks nothing.
388
+ if (!open || !title || !t)
124
389
  return;
125
- setBusy(true);
126
- if (await save(title))
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
- if (e.key === 'Enter')
132
- void submit();
133
- } }), _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: busy || !words.trim(), onPress: () => void submit(), children: busy ? 'Saving…' : 'Save' })] })] }) }));
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
- const [busy, setBusy] = useState(false);
138
- const submit = async () => {
139
- if (busy)
140
- return;
141
- setBusy(true);
142
- if (await remove())
143
- onOpenChange(false);
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 takes the space a short message needs and leaves the rest to the layout.
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 takes the space a short message needs and leaves the rest to the layout.
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: 360, px: "$4", py: "$10", children: [_jsx(Spinner, { size: "small", color: "$ink", width: 28, height: 28, 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
+ 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.36",
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",