@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 +10 -7
- package/dist/Column.js +1 -1
- package/dist/Recents.d.ts +3 -1
- package/dist/Recents.js +31 -33
- package/dist/Shell.js +18 -11
- package/dist/recent.d.ts +12 -5
- package/dist/recent.js +24 -11
- package/dist/thread.d.ts +35 -0
- package/dist/thread.js +147 -0
- package/package.json +3 -3
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,
|
|
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 {
|
|
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: () =>
|
|
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`,
|
|
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 {
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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,
|
|
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 } =
|
|
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
|
-
|
|
408
|
-
const
|
|
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:
|
|
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 !==
|
|
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) =>
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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' | '
|
|
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.
|
|
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,
|
|
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) => ({
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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.
|
package/dist/thread.d.ts
ADDED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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",
|