@hanzo/rooms 0.1.35 → 0.1.36

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. 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.
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 { 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, 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,28 @@ 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, 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 item = (_jsx(SidebarItem, { "data-kind": r.kind, active: open(r), rounded: "$4", minH: 32, pr: when ? '$8' : '$2', onKeyDown: (e) => {
76
+ if (e.key !== 'Enter' && e.key !== ' ')
77
+ return;
78
+ e.preventDefault?.();
79
+ onOpen(r);
80
+ }, 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
+ // How long ago, at the row's end; the full time is its title.
82
+ 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
+ if (t)
85
+ return (_jsx(ThreadRow, { t: t, active: open(r), aside: age, onGone: onNew, 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
+ 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.' }))] })] }));
91
89
  }
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 { 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,17 @@ 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
+ // PINNED FIRST, by the server's pin (thread.tsx), then the days.
410
+ const shown = found.filter((t) => !t.thread?.pinned);
411
+ const kept = found.filter((t) => t.thread?.pinned);
409
412
  const bands = [];
410
413
  if (kept.length)
411
- bands.push({ label: "Starred", rows: kept });
414
+ bands.push({ label: PINNED, rows: kept });
412
415
  const now = new Date();
413
416
  for (const t of shown) {
414
417
  const label = when(t.updatedAt, now);
415
418
  const last = bands[bands.length - 1];
416
- if (last && last.label === label && last.label !== "Starred")
419
+ if (last && last.label === label && last.label !== PINNED)
417
420
  last.rows.push(t);
418
421
  else
419
422
  bands.push({ label, rows: [t] });
@@ -421,10 +424,14 @@ function Chats({ filter = "", onPick, }) {
421
424
  if (!signedIn)
422
425
  return _jsx(Note, { children: "Sign in to see your recent chats." });
423
426
  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 })] }));
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
435
  }
429
436
  /**
430
437
  * The chats other people shared with this reader, and who shared each: every
package/dist/recent.d.ts CHANGED
@@ -9,10 +9,12 @@ export interface Recent {
9
9
  began: string;
10
10
  status?: string;
11
11
  project?: string;
12
+ /** A conversation its person pinned: listed first, under Pinned. */
13
+ pinned?: boolean;
12
14
  }
13
- export type Status = 'all' | 'running' | 'paused' | 'done' | 'error' | 'stopped';
15
+ export type Status = 'all' | 'running' | 'paused' | 'done' | 'error' | 'stopped' | 'archived';
14
16
  export type Since = 'any' | 'day' | 'week' | 'month';
15
- export type Group = 'date' | 'state' | 'project' | 'starred' | 'none';
17
+ export type Group = 'date' | 'state' | 'project' | 'none';
16
18
  export type Sort = 'activity' | 'created' | 'title';
17
19
  /** How the list is narrowed, grouped and sorted. */
18
20
  export interface View {
@@ -35,11 +37,13 @@ export declare const MENUS: Record<Mode, {
35
37
  }>;
36
38
  /** A kept view as this mode may hold it: anything a menu does not offer reads as the plain view's. */
37
39
  export declare function viewOf(mode: Mode, kept: unknown): View;
38
- /** Chat's conversations as rows. */
40
+ /** Chat's conversations as rows: an archived one's status says so. */
39
41
  export declare function chats(threads: {
40
42
  id: string;
41
43
  title?: string;
42
44
  updatedAt?: string;
45
+ pinned?: boolean;
46
+ archived?: boolean;
43
47
  }[]): Recent[];
44
48
  /** Dev's runs as rows. */
45
49
  export declare function runs(sessions: {
@@ -53,13 +57,16 @@ export declare function runs(sessions: {
53
57
  }[]): Recent[];
54
58
  /**
55
59
  * The list as drawn: the rows the view and the words admit, sorted, and cut into
56
- * headed groups in the order they read. A view grouped by None is one group with
60
+ * headed groups in the order they read. Pinned rows come first, under Pinned,
61
+ * whatever the grouping; a view grouped by None holds the rest in one group with
57
62
  * no heading.
58
63
  */
59
- export declare function arrange(rows: Recent[], view: View, words: string, starred: Set<string>, now?: Date): {
64
+ export declare function arrange(rows: Recent[], view: View, words: string, now?: Date): {
60
65
  title: string;
61
66
  rows: Recent[];
62
67
  }[];
68
+ /** The heading pinned rows read under, first in every view. */
69
+ export declare const PINNED = "Pinned";
63
70
  /**
64
71
  * How long ago a row was last active, as a list reads it at a glance: `now`,
65
72
  * `12m`, `3h`, `2d`, `5w`, `4mo`, `2y`. '' for an instant that is not one.
package/dist/recent.js CHANGED
@@ -17,11 +17,14 @@ export const SINCE = [
17
17
  ];
18
18
  /** What each mode's menu offers, section by section, as Claude's Recents menu does. */
19
19
  export const MENUS = {
20
+ // A chat's status is whether it is archived: the store lists the two apart.
20
21
  chat: {
21
- status: [],
22
+ status: [
23
+ { id: 'all', label: 'Active' },
24
+ { id: 'archived', label: 'Archived' },
25
+ ],
22
26
  group: [
23
27
  { id: 'date', label: 'Date' },
24
- { id: 'starred', label: 'Starred' },
25
28
  { id: 'none', label: 'None' },
26
29
  ],
27
30
  sort: [
@@ -56,9 +59,17 @@ export function viewOf(mode, kept) {
56
59
  sort: pick(k.sort, m.sort, PLAIN.sort),
57
60
  };
58
61
  }
59
- /** Chat's conversations as rows. */
62
+ /** Chat's conversations as rows: an archived one's status says so. */
60
63
  export function chats(threads) {
61
- return threads.map((t) => ({ kind: 'chat', id: t.id, title: t.title || 'Untitled', at: t.updatedAt ?? '', began: t.updatedAt ?? '' }));
64
+ return threads.map((t) => ({
65
+ kind: 'chat',
66
+ id: t.id,
67
+ title: t.title || 'Untitled',
68
+ at: t.updatedAt ?? '',
69
+ began: t.updatedAt ?? '',
70
+ ...(t.archived ? { status: 'archived' } : {}),
71
+ ...(t.pinned ? { pinned: true } : {}),
72
+ }));
62
73
  }
63
74
  /** Dev's runs as rows. */
64
75
  export function runs(sessions) {
@@ -96,10 +107,11 @@ function day(at, now) {
96
107
  const newest = (a, b) => (a < b ? 1 : a > b ? -1 : 0);
97
108
  /**
98
109
  * The list as drawn: the rows the view and the words admit, sorted, and cut into
99
- * headed groups in the order they read. A view grouped by None is one group with
110
+ * headed groups in the order they read. Pinned rows come first, under Pinned,
111
+ * whatever the grouping; a view grouped by None holds the rest in one group with
100
112
  * no heading.
101
113
  */
102
- export function arrange(rows, view, words, starred, now = new Date()) {
114
+ export function arrange(rows, view, words, now = new Date()) {
103
115
  const w = words.trim().toLowerCase();
104
116
  const kept = rows.filter((r) => (!w || r.title.toLowerCase().includes(w)) &&
105
117
  (view.status === 'all' || r.status === view.status) &&
@@ -113,14 +125,14 @@ export function arrange(rows, view, words, starred, now = new Date()) {
113
125
  return STATE[r.status ?? ''] ?? 'Other';
114
126
  case 'project':
115
127
  return r.project || 'No project';
116
- case 'starred':
117
- return starred.has(r.id) ? 'Starred' : 'Others';
118
128
  default:
119
129
  return '';
120
130
  }
121
131
  };
122
132
  const groups = [];
123
133
  for (const r of kept) {
134
+ if (r.pinned)
135
+ continue;
124
136
  const title = headOf(r);
125
137
  const g = groups.find((one) => one.title === title);
126
138
  if (g)
@@ -128,12 +140,13 @@ export function arrange(rows, view, words, starred, now = new Date()) {
128
140
  else
129
141
  groups.push({ title, rows: [r] });
130
142
  }
131
- if (view.group === 'starred')
132
- groups.sort((a, b) => (a.title === 'Starred' ? -1 : b.title === 'Starred' ? 1 : 0));
133
143
  if (view.group === 'date')
134
144
  groups.sort((a, b) => DAYS.indexOf(a.title) - DAYS.indexOf(b.title));
135
- return groups;
145
+ const pinned = kept.filter((r) => r.pinned);
146
+ return pinned.length ? [{ title: PINNED, rows: pinned }, ...groups] : groups;
136
147
  }
148
+ /** The heading pinned rows read under, first in every view. */
149
+ export const PINNED = 'Pinned';
137
150
  /**
138
151
  * How long ago a row was last active, as a list reads it at a glance: `now`,
139
152
  * `12m`, `3h`, `2d`, `5w`, `4mo`, `2y`. '' for an instant that is not one.
@@ -0,0 +1,35 @@
1
+ import { type ReactElement, type ReactNode } from 'react';
2
+ import type { Thread } from '@hanzo/ai';
3
+ import { useThreads } from '@hanzo/ai/react';
4
+ /**
5
+ * 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.
9
+ */
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. */
12
+ export declare function useThreadActions(): {
13
+ pin: (t: Thread) => Promise<boolean>;
14
+ archive: (t: Thread) => Promise<boolean>;
15
+ rename: (t: Thread, title: string) => Promise<boolean>;
16
+ remove: (t: Thread) => Promise<boolean>;
17
+ };
18
+ /**
19
+ * One conversation's row with its menu: a right-click anywhere on it, or its ⋯.
20
+ *
21
+ * 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.
26
+ */
27
+ export declare function ThreadRow({ t, active, aside, onGone, listed, children, }: {
28
+ t: Thread;
29
+ active?: boolean;
30
+ aside?: ReactNode;
31
+ onGone?: () => void;
32
+ /** A row of a list (`role="listitem"`); false where it stands among other rows. */
33
+ listed?: boolean;
34
+ children: ReactElement;
35
+ }): import("react").JSX.Element;
package/dist/thread.js ADDED
@@ -0,0 +1,147 @@
1
+ 'use client';
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`).
11
+ //
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';
21
+ 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());
25
+ /**
26
+ * 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.
30
+ */
31
+ export function useThreadList(archived = false, seek = null) {
32
+ const store = useThreads({ archived });
33
+ const { reload, loading, threads } = store;
34
+ useEffect(() => {
35
+ lists.add(reload);
36
+ return () => {
37
+ lists.delete(reload);
38
+ };
39
+ }, [reload]);
40
+ const asked = useRef(new Set());
41
+ useEffect(() => {
42
+ if (archived || !seek || loading || asked.current.has(seek) || threads.some((t) => t.id === seek))
43
+ return;
44
+ asked.current.add(seek);
45
+ reload();
46
+ }, [archived, seek, loading, threads, reload]);
47
+ return store;
48
+ }
49
+ /** What a conversation can be asked to do. Each reports a failure in a toast, in its own words. */
50
+ export function useThreadActions() {
51
+ 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
+ }
62
+ }, []);
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)),
68
+ };
69
+ }
70
+ const titleOf = (t) => t.title || 'Untitled';
71
+ /**
72
+ * One conversation's row with its menu: a right-click anywhere on it, or its ⋯.
73
+ *
74
+ * 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.
79
+ */
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);
84
+ 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
+ ];
104
+ // 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
+ } })] }));
112
+ }
113
+ /** 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);
117
+ useEffect(() => {
118
+ if (open)
119
+ setWords(titleOf(t));
120
+ }, [open, t]);
121
+ const submit = async () => {
122
+ const title = words.trim();
123
+ if (!title || busy)
124
+ return;
125
+ setBusy(true);
126
+ if (await save(title))
127
+ onOpenChange(false);
128
+ setBusy(false);
129
+ };
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' })] })] }) }));
134
+ }
135
+ /** 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' })] })] }) }));
147
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanzo/rooms",
3
- "version": "0.1.35",
3
+ "version": "0.1.36",
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",
@@ -47,7 +47,7 @@
47
47
  "simple-icons": "16.30.0"
48
48
  },
49
49
  "peerDependencies": {
50
- "@hanzo/ai": ">=0.6.20",
50
+ "@hanzo/ai": ">=0.6.23",
51
51
  "@hanzo/appearance": ">=0.2.6",
52
52
  "@hanzo/build": ">=0.2.34",
53
53
  "@hanzo/composer": ">=0.2.0",
@@ -65,7 +65,7 @@
65
65
  "react-dom": ">=19.2.8"
66
66
  },
67
67
  "devDependencies": {
68
- "@hanzo/ai": "0.6.20",
68
+ "@hanzo/ai": "0.6.23",
69
69
  "@hanzo/appearance": "0.2.8",
70
70
  "@hanzo/build": "0.2.34",
71
71
  "@hanzo/composer": "0.2.0",