@hanzo/rooms 0.1.35 → 0.1.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/Chat.js CHANGED
@@ -52,7 +52,7 @@ import { useHydrated } from "./lib/hydrated.js";
52
52
  import { base, served } from "./lib/ai.js";
53
53
  import { onServed } from "./lib/served.js";
54
54
  import { refused as worded, speech, useDictation, useTalk, useVoice, Voice } from "@hanzo/voice";
55
- import { ArrowUp, AudioLines, FileText, Image as ImageMark, Mic, Paperclip, Square, Star, PanelRight, X } from "lucide-react";
55
+ import { ArrowUp, AudioLines, FileText, Image as ImageMark, Mic, Paperclip, Pin, Square, PanelRight, X } from "lucide-react";
56
56
  import { ENSO, FREE } from "./lib/ai.js";
57
57
  import { openThread, useOpen } from "./open.js";
58
58
  import { Beside, Framed } from "./Shell.js";
@@ -61,7 +61,7 @@ import { RightPane, addressOf, look, usePinned } from "./RightPane.js";
61
61
  import { useMedia } from "@hanzo/gui";
62
62
  import { Take } from "./copy.js";
63
63
  import { Share } from "./Share.js";
64
- import { useStarred } from "./stars.js";
64
+ import { useThreadActions, useThreadList } from "./thread.js";
65
65
  import { useModel } from "./model.js";
66
66
  import { Enso, nameOf, useEffort } from "./enso.js";
67
67
  import { checkoutUrl } from './lib/pay.js';
@@ -486,10 +486,6 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
486
486
  * that settles after mount. Picking from the menu still wins, permanently.
487
487
  */
488
488
  const [asking, setAsking] = useState(null);
489
- // STARRED HERE, READ IN THE COLUMN — the shape a room and a project already
490
- // have. hanzo.chat called it a bookmark; the word this site uses is starred,
491
- // and one word for one act is what keeps the three lists reading alike.
492
- const [starred, star] = useStarred("threads");
493
489
  // The setter is taken now, not just the value: the override moved from
494
490
  // Settings into the composer's own panel, so this is where it is written.
495
491
  const [preferred, setPreferred] = useModel();
@@ -499,6 +495,13 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
499
495
  const framed = useContext(Framed);
500
496
  const opened = useOpen();
501
497
  const { thread: open, open: reopen, agents: room, openAgent, seat, emptied, aside, showAside, showSettings } = opened;
498
+ // PINNED HERE, READ IN THE COLUMN, and kept by the server (thread.tsx): the
499
+ // pin this header sets is the one the row's menu sets, on every device the
500
+ // reader signs in on. A conversation not in the list yet (just begun, or
501
+ // archived) reads unpinned.
502
+ const listing = useThreadList(false, open);
503
+ const acts = useThreadActions();
504
+ const mine = open ? listing.threads.find((t) => t.id === open) : undefined;
502
505
  // The conversation's key in `pane.ts`, the one the column beside it reads.
503
506
  const at = channel(opened);
504
507
  // The first of them: what a surface that addresses one person reads.
@@ -1200,7 +1203,7 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
1200
1203
  e.preventDefault();
1201
1204
  // A dropped folder arrives as entries; each file under it goes up with its path.
1202
1205
  void dropped(e.dataTransfer).then((list) => hold(at, list, via));
1203
- }, children: [dragging ? (_jsx(YStack, { "aria-hidden": true, position: "absolute", t: 0, r: 0, b: 0, l: 0, z: 10, items: "center", justify: "center", bg: "var(--surface-scrim)", borderWidth: 2, borderStyle: "dashed", borderColor: "$borderColor", rounded: "var(--pane-round)", pointerEvents: "none", children: _jsxs(XStack, { items: "center", gap: "$2", px: "$4", py: "$2.5", rounded: "$10", bg: "$background", borderWidth: 1, borderColor: "$borderColor", children: [_jsx(Paperclip, { size: 15, "aria-hidden": true }), _jsx(Text, { fontSize: "$4", color: "$ink", children: "Drop files to attach" })] }) })) : null, _jsxs(XStack, { height: 48, items: "center", justify: "space-between", px: "$3", borderBottomWidth: 1, borderColor: "$borderColor", children: [_jsxs(XStack, { items: "center", gap: "$2", minW: 0, children: [_jsx(Box, { render: "button", onClick: () => open && star(open), "aria-label": open && starred.has(open) ? "Unstar this conversation" : "Star this conversation", "aria-pressed": !!open && starred.has(open), p: "$1.5", rounded: "$2", hoverStyle: { bg: '$hover' }, children: _jsx(Star, { size: 15, "aria-hidden": true, color: open && starred.has(open) ? "var(--foreground)" : "var(--muted-foreground)", fill: open && starred.has(open) ? "currentColor" : "none" }) }), room.length ? (_jsx(XStack, { role: "list", "aria-label": "Agents in this chat", items: "center", gap: "$1.5", minW: 0, overflowX: "auto", "$platform-web": { scrollbarWidth: "none" }, children: room.map((name) => (_jsxs(XStack, { role: "listitem", items: "center", gap: "$1.5", pl: "$1", pr: "$0.5", py: "$0.5", rounded: 999, borderWidth: 1, borderColor: "$borderColor", shrink: 0, children: [_jsx(Face, { src: faces[name]?.avatar ?? (name === withWhom ? portrait : undefined), emoji: faces[name]?.emoji ?? (name === withWhom ? persona?.emoji : undefined), name: name, size: 22 }), _jsx(Text, { fontSize: "$2", fontWeight: "600", color: "$color", numberOfLines: 1, display: "none", $sm: { display: "flex" }, children: name }), _jsx(Box, { render: "button", onClick: () => openAgent(name), "aria-label": `Remove ${name} from this chat`, title: `Remove ${name}`, width: 24, height: 24, rounded: 999, items: "center", justify: "center", hoverStyle: { bg: "$hover" }, children: _jsx(X, { size: 13, "aria-hidden": true, color: "var(--soft, var(--muted-foreground))" }) })] }, name))) })) : null] }), _jsxs(XStack, { items: "center", gap: "$1", children: [_jsx(Box, { render: "button", onClick: () => {
1206
+ }, children: [dragging ? (_jsx(YStack, { "aria-hidden": true, position: "absolute", t: 0, r: 0, b: 0, l: 0, z: 10, items: "center", justify: "center", bg: "var(--surface-scrim)", borderWidth: 2, borderStyle: "dashed", borderColor: "$borderColor", rounded: "var(--pane-round)", pointerEvents: "none", children: _jsxs(XStack, { items: "center", gap: "$2", px: "$4", py: "$2.5", rounded: "$10", bg: "$background", borderWidth: 1, borderColor: "$borderColor", children: [_jsx(Paperclip, { size: 15, "aria-hidden": true }), _jsx(Text, { fontSize: "$4", color: "$ink", children: "Drop files to attach" })] }) })) : null, _jsxs(XStack, { height: 48, items: "center", justify: "space-between", px: "$3", borderBottomWidth: 1, borderColor: "$borderColor", children: [_jsxs(XStack, { items: "center", gap: "$2", minW: 0, children: [_jsx(Box, { render: "button", onClick: () => mine && void acts.pin(mine), "aria-disabled": !mine, "aria-label": mine?.pinned ? "Unpin this conversation" : "Pin this conversation", "aria-pressed": !!mine?.pinned, p: "$1.5", rounded: "$2", opacity: mine ? 1 : 0.5, hoverStyle: { bg: '$hover' }, children: _jsx(Pin, { size: 15, "aria-hidden": true, color: mine?.pinned ? "var(--foreground)" : "var(--muted-foreground)", fill: mine?.pinned ? "currentColor" : "none" }) }), room.length ? (_jsx(XStack, { role: "list", "aria-label": "Agents in this chat", items: "center", gap: "$1.5", minW: 0, overflowX: "auto", "$platform-web": { scrollbarWidth: "none" }, children: room.map((name) => (_jsxs(XStack, { role: "listitem", items: "center", gap: "$1.5", pl: "$1", pr: "$0.5", py: "$0.5", rounded: 999, borderWidth: 1, borderColor: "$borderColor", shrink: 0, children: [_jsx(Face, { src: faces[name]?.avatar ?? (name === withWhom ? portrait : undefined), emoji: faces[name]?.emoji ?? (name === withWhom ? persona?.emoji : undefined), name: name, size: 22 }), _jsx(Text, { fontSize: "$2", fontWeight: "600", color: "$color", numberOfLines: 1, display: "none", $sm: { display: "flex" }, children: name }), _jsx(Box, { render: "button", onClick: () => openAgent(name), "aria-label": `Remove ${name} from this chat`, title: `Remove ${name}`, width: 24, height: 24, rounded: 999, items: "center", justify: "center", hoverStyle: { bg: "$hover" }, children: _jsx(X, { size: 13, "aria-hidden": true, color: "var(--soft, var(--muted-foreground))" }) })] }, name))) })) : null] }), _jsxs(XStack, { items: "center", gap: "$1", children: [_jsx(Box, { render: "button", onClick: () => {
1204
1207
  if (aside === null) {
1205
1208
  showAside("tasks");
1206
1209
  if (!pinned)
package/dist/Column.js CHANGED
@@ -82,7 +82,7 @@ export function Column({ host, mode, thread, onChat, onDev, go, drawer = false,
82
82
  setWords('');
83
83
  }, placeholder: "Filter", "aria-label": "Filter recents" }), flex: 1, minW: 0, bg: "transparent", borderWidth: 0, outlineStyle: "none", color: "inherit", fontSize: "$2" })] }), _jsxs(YStack, { role: "list", mt: "$2.5", gap: 2, shrink: 0, children: [_jsx(YStack, { role: "listitem", children: _jsx(SidebarItem, { "data-slot": "app-new", ...ROW, active: fresh, icon: _jsx(Plus, { size: 16, "aria-hidden": true }), onPress: create, ...keys(create), children: mode === 'chat' ? 'New chat' : 'New run' }) }), mode === 'dev'
84
84
  ? links.map((link) => (_jsx(YStack, { role: "listitem", children: _jsx(SidebarItem, { ...ROW, active: link.active, icon: link.icon, onPress: link.onPress, ...keys(() => link.onPress?.()), children: link.label }) }, link.id)))
85
- : null, mode === 'dev' ? (_jsxs(YStack, { role: "listitem", children: [_jsx(SidebarItem, { ...ROW, "aria-expanded": unfolded, icon: unfolded ? _jsx(ChevronUp, { size: 16, "aria-hidden": true }) : _jsx(ChevronDown, { size: 16, "aria-hidden": true }), onPress: () => setUnfolded((v) => !v), ...keys(() => setUnfolded((v) => !v)), children: "More" }), unfolded ? (_jsx(YStack, { role: "list", gap: 2, children: more.map((link) => (_jsx(YStack, { role: "listitem", children: _jsx(SidebarItem, { ...ROW, active: link.active, icon: link.icon, onPress: link.onPress, ...keys(() => link.onPress?.()), children: link.label }) }, link.id))) })) : null] })) : null] }), _jsx(XStack, { height: 1, bg: "$borderColor", mx: "$1.5", mt: "$3", shrink: 0 }), _jsx(Recents, { host: host, mode: mode, thread: thread, words: words, onOpen: open }), _jsxs(YStack, { gap: "$2", pt: "$2", shrink: 0, children: [_jsx(Made, {}), _jsx(Setup, {})] }), _jsxs(XStack, { "data-slot": "app-foot", items: "center", gap: "$1", pt: "$2", shrink: 0, children: [_jsx(YStack, { flex: 1, minW: 0, children: _jsx(Me, { host: host, onOrg: org }) }), drawer ? null : (_jsx(SidebarIconButton, { label: "Collapse sidebar", onPress: () => setCollapsed(true), width: 32, height: 32, rounded: 999, children: _jsx(PanelLeft, { size: 16 }) }))] })] }), drawer ? null : (_jsx(Grip, { side: "left", span: span.span, floor: FLOOR, ceil: CEIL, reset: WIDTH, onSpan: span.move, onKeep: span.keep, onShut: () => setCollapsed(true), label: "Resize sidebar",
85
+ : null, mode === 'dev' ? (_jsxs(YStack, { role: "listitem", children: [_jsx(SidebarItem, { ...ROW, "aria-expanded": unfolded, icon: unfolded ? _jsx(ChevronUp, { size: 16, "aria-hidden": true }) : _jsx(ChevronDown, { size: 16, "aria-hidden": true }), onPress: () => setUnfolded((v) => !v), ...keys(() => setUnfolded((v) => !v)), children: "More" }), unfolded ? (_jsx(YStack, { role: "list", gap: 2, children: more.map((link) => (_jsx(YStack, { role: "listitem", children: _jsx(SidebarItem, { ...ROW, active: link.active, icon: link.icon, onPress: link.onPress, ...keys(() => link.onPress?.()), children: link.label }) }, link.id))) })) : null] })) : null] }), _jsx(XStack, { height: 1, bg: "$borderColor", mx: "$1.5", mt: "$3", shrink: 0 }), _jsx(Recents, { host: host, mode: mode, thread: thread, words: words, onOpen: open, onNew: create }), _jsxs(YStack, { gap: "$2", pt: "$2", shrink: 0, children: [_jsx(Made, {}), _jsx(Setup, {})] }), _jsxs(XStack, { "data-slot": "app-foot", items: "center", gap: "$1", pt: "$2", shrink: 0, children: [_jsx(YStack, { flex: 1, minW: 0, children: _jsx(Me, { host: host, onOrg: org }) }), drawer ? null : (_jsx(SidebarIconButton, { label: "Collapse sidebar", onPress: () => setCollapsed(true), width: 32, height: 32, rounded: 999, children: _jsx(PanelLeft, { size: 16 }) }))] })] }), drawer ? null : (_jsx(Grip, { side: "left", span: span.span, floor: FLOOR, ceil: CEIL, reset: WIDTH, onSpan: span.move, onKeep: span.keep, onShut: () => setCollapsed(true), label: "Resize sidebar",
86
86
  // In the gutter between the column and the pane, not over the column's own rows.
87
87
  r: GUTTER_END, width: "var(--pane-gap)" }))] }));
88
88
  }
package/dist/Recents.d.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  import { type Host } from '@hanzo/build';
2
2
  import { type Mode, type Recent } from './recent.js';
3
- export declare function Recents({ host, mode, thread, words, onOpen, }: {
3
+ export declare function Recents({ host, mode, thread, words, onOpen, onNew, }: {
4
4
  host: Host;
5
5
  mode: Mode;
6
6
  /** The Chat conversation the pane holds, or null. */
7
7
  thread: string | null;
8
8
  words: string;
9
9
  onOpen: (r: Recent) => void;
10
+ /** Moves the pane to a new conversation: where it goes when the one it holds is deleted. */
11
+ onNew: () => void;
10
12
  }): import("react").JSX.Element;
package/dist/Recents.js CHANGED
@@ -1,20 +1,21 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  // The reader's Recents in the column's mode, and only that mode's: Chat lists its
4
- // conversations (`/v1/agents/chat/conversations`, @hanzo/ai `useThreads`), Dev its
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.
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.
8
10
  import { useEffect, useId, useMemo, useRef } from 'react';
9
11
  import { ScrollView, SizableText, XStack, YStack } from '@hanzo/gui';
10
12
  import { ListFilter, MessageSquare } from 'lucide-react';
11
- import { useThreads } from '@hanzo/ai/react';
12
13
  import { DOTS, route, useSessions } from '@hanzo/build';
13
14
  import { DropdownMenu } from '@hanzo/ui';
14
15
  import { SidebarItem, StatusDot } from '@hanzo/ui/chat';
15
16
  import { useKept } from './kept.js';
16
17
  import { ago, arrange, chats, MENUS, PLAIN, runs, SINCE, viewOf } from './recent.js';
17
- import { useStarred } from './stars.js';
18
+ import { ConversationMenu, ThreadRow, useThreadList } from './thread.js';
18
19
  /** The menu's items: each section a heading and its options, the one in force checked. */
19
20
  function items(mode, view, set) {
20
21
  const m = MENUS[mode];
@@ -34,26 +35,20 @@ function items(mode, view, set) {
34
35
  return parts.flatMap((p, i) => (i ? [{ type: 'separator', key: `sep-${i}` }, ...p] : p));
35
36
  }
36
37
  const Note = ({ children }) => (_jsx(SizableText, { size: "$1", color: "$soft", px: "$2", py: "$1.5", children: children }));
37
- export function Recents({ host, mode, thread, words, onOpen, }) {
38
- return mode === 'chat' ? _jsx(Chats, { thread: thread, words: words, onOpen: onOpen }) : _jsx(Runs, { host: host, words: words, onOpen: onOpen });
38
+ export function Recents({ host, mode, thread, words, onOpen, onNew, }) {
39
+ const [kept, keep] = useKept(`hanzo.recents.${mode}`, PLAIN);
40
+ const view = viewOf(mode, kept);
41
+ return mode === 'chat' ? (_jsx(Chats, { thread: thread, words: words, view: view, keep: keep, onOpen: onOpen, onNew: onNew })) : (_jsx(Runs, { host: host, words: words, view: view, keep: keep, onOpen: onOpen }));
39
42
  }
40
- /** Chat's Recents: the conversation store alone. */
41
- function Chats({ thread, words, onOpen }) {
42
- const store = useThreads();
43
- // A conversation this list has not seen is one just begun: read the store again, once for it.
44
- const asked = useRef(new Set());
45
- const reload = store.reload;
46
- useEffect(() => {
47
- if (!thread || store.loading || asked.current.has(thread) || store.threads.some((t) => t.id === thread))
48
- return;
49
- asked.current.add(thread);
50
- reload();
51
- }, [thread, store.loading, store.threads, reload]);
43
+ /** Chat's Recents: the conversation store alone, the archived ones when the view asks for them. */
44
+ function Chats({ thread, words, view, keep, onOpen, onNew, }) {
45
+ const store = useThreadList(view.status === 'archived', thread);
52
46
  const rows = useMemo(() => chats(store.threads), [store.threads]);
53
- return (_jsx(List, { mode: "chat", rows: rows, words: words, open: (r) => r.id === thread, reading: store.loading && !store.threads.length, error: store.error, onOpen: onOpen }));
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, held: thread, onNew: onNew }));
54
49
  }
55
50
  /** Dev's Recents: the org's coding runs alone. */
56
- function Runs({ host, words, onOpen }) {
51
+ function Runs({ host, words, view, keep, onOpen }) {
57
52
  const store = useSessions(host);
58
53
  const r = route(host.path);
59
54
  const run = r.kind === 'run' ? r.id : null;
@@ -67,25 +62,29 @@ function Runs({ host, words, onOpen }) {
67
62
  reload();
68
63
  }, [run, store.loading, store.value, reload]);
69
64
  const rows = useMemo(() => runs(store.value), [store.value]);
70
- return (_jsx(List, { mode: "dev", rows: rows, words: words, open: (row) => row.id === run, reading: store.loading && !store.value.length, error: store.error, onOpen: onOpen }));
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 }));
71
66
  }
72
- function List({ mode, rows, words, open, reading, error, onOpen, }) {
73
- const [kept, keep] = useKept(`hanzo.recents.${mode}`, PLAIN);
74
- const view = viewOf(mode, kept);
75
- const [starred] = useStarred('threads');
67
+ function List({ mode, rows, words, view, keep, open, reading, error, onOpen, threads, held = null, onNew, }) {
76
68
  const label = useId();
77
- const groups = arrange(rows, view, words, starred);
69
+ const groups = arrange(rows, view, words);
78
70
  const narrowed = view.status !== PLAIN.status || view.since !== PLAIN.since;
79
71
  const now = new Date();
80
72
  const running = mode === 'dev' ? rows.filter((r) => r.status === 'running').length : 0;
81
73
  const row = (r) => {
82
74
  const when = ago(r.at, now);
83
- return (_jsxs(YStack, { role: "listitem", position: "relative", children: [_jsx(SidebarItem, { "data-kind": r.kind, active: open(r), rounded: "$4", minH: 32, pr: when ? '$8' : '$2', onKeyDown: (e) => {
84
- if (e.key !== 'Enter' && e.key !== ' ')
85
- return;
86
- e.preventDefault?.();
87
- onOpen(r);
88
- }, 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 }), when ? (_jsx(SizableText, { position: "absolute", r: "$2", t: 0, b: 0, 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] }, r.id));
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) => {
77
+ if (e.key !== 'Enter' && e.key !== ' ')
78
+ return;
79
+ e.preventDefault?.();
80
+ onOpen(r);
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 }));
82
+ // How long ago, at the row's end; the full time is its title.
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;
84
+ if (t)
85
+ return (_jsx(ThreadRow, { t: t, active: open(r), aside: age, children: item }, r.id));
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));
89
87
  };
90
- 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()}”.` })) : 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)] })] }));
91
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
@@ -16,7 +16,9 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
16
16
  import { createContext, useContext, useEffect, useMemo, useRef, useState, } from "react";
17
17
  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
- import { AiProvider, useAgents, useInbox, useModels, usePeople, useThreads, } from "@hanzo/ai/react";
19
+ import { AiProvider, useAgents, useInbox, useModels, usePeople, } from "@hanzo/ai/react";
20
+ import { PINNED } from "./recent.js";
21
+ import { ConversationMenu, ThreadRow, useThreadList } from "./thread.js";
20
22
  import { Box, XStack, YStack, Text } from "@hanzo/ui";
21
23
  // THE PAPER LADDER. `sheet(n)` is a fill AND the light on it, published as a
22
24
  // pair by @hanzo/design, so a surface asks for a HEIGHT instead of composing a
@@ -373,7 +375,7 @@ function Chats({ filter = "", onPick, }) {
373
375
  // lease-agreement file search" — banded under Today and Yesterday like a
374
376
  // history somebody had. A visitor's first sight of the app was ten chats they
375
377
  // had never had, and every one of them opened nothing.
376
- const { threads, loading, error: wrong } = useThreads();
378
+ const { threads, loading, error: wrong } = useThreadList(false, thread);
377
379
  const [customThreads, setCustomThreads] = useState([]);
378
380
  useEffect(() => {
379
381
  setCustomThreads(getCustomThreads());
@@ -384,12 +386,12 @@ function Chats({ filter = "", onPick, }) {
384
386
  const { isAuthenticated, user } = useIam();
385
387
  const { user: account } = useAccount();
386
388
  const signedIn = isAuthenticated || Boolean(user) || Boolean(account) || hasSession();
387
- const [starred] = useStarred("threads");
388
389
  const mergedThreads = useMemo(() => {
389
390
  const list = threads.map((t) => ({
390
391
  id: t.id,
391
392
  title: t.title || "Untitled",
392
393
  updatedAt: t.updatedAt,
394
+ thread: t,
393
395
  }));
394
396
  for (const ct of customThreads) {
395
397
  if (!list.some((t) => t.id === ct.id)) {
@@ -404,16 +406,18 @@ function Chats({ filter = "", onPick, }) {
404
406
  return list;
405
407
  }, [threads, customThreads]);
406
408
  const found = mergedThreads.filter((t) => matches(t.title || "Untitled", filter));
407
- const shown = found.filter((t) => !starred.has(t.id));
408
- const kept = found.filter((t) => starred.has(t.id));
409
+ const byId = useMemo(() => new Map(threads.map((t) => [t.id, t])), [threads]);
410
+ // PINNED FIRST, by the server's pin (thread.tsx), then the days.
411
+ const shown = found.filter((t) => !t.thread?.pinned);
412
+ const kept = found.filter((t) => t.thread?.pinned);
409
413
  const bands = [];
410
414
  if (kept.length)
411
- bands.push({ label: "Starred", rows: kept });
415
+ bands.push({ label: PINNED, rows: kept });
412
416
  const now = new Date();
413
417
  for (const t of shown) {
414
418
  const label = when(t.updatedAt, now);
415
419
  const last = bands[bands.length - 1];
416
- if (last && last.label === label && last.label !== "Starred")
420
+ if (last && last.label === label && last.label !== PINNED)
417
421
  last.rows.push(t);
418
422
  else
419
423
  bands.push({ label, rows: [t] });
@@ -421,10 +425,14 @@ function Chats({ filter = "", onPick, }) {
421
425
  if (!signedIn)
422
426
  return _jsx(Note, { children: "Sign in to see your recent chats." });
423
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;
424
- return (_jsxs(_Fragment, { children: [mine, mine ? null : bands.map((band) => (_jsx(SidebarSection, { label: band.label, children: band.rows.map((t) => (_jsx(SidebarItem, { active: t.id === thread, onPress: () => {
425
- open(t.id);
426
- onPick?.();
427
- }, 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))) }, 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 })] }));
428
436
  }
429
437
  /**
430
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>;