@hanzo/rooms 0.1.34 → 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/README.md CHANGED
@@ -11,13 +11,13 @@ import { CalScheduler } from '@hanzo/rooms/CalScheduler'
11
11
 
12
12
  configure({ site: '', home: '/home', signUp: '/signup' })
13
13
 
14
- <Rooms router={router} search={search} route={route} Link={Link}>
14
+ <Rooms router={router} search={search} path={pathname} route={route} Link={Link}>
15
15
  <Room mode="cal"><CalScheduler /></Room>
16
16
  </Rooms>
17
17
  ```
18
18
 
19
19
  `configure()` names the host's addresses (where its own pages live, the
20
20
  gateway and IAM, the plan catalogue to paint first). `<Rooms>` carries what
21
- moves: the router, the query, the route and the link component. The rooms
21
+ moves: the router, the query, the path, the route and the link component. The rooms
22
22
  import nothing from a host, and their look comes from `@hanzo/appearance` and
23
23
  `@hanzo/design` through `@hanzo/ui`.
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/dev.js CHANGED
@@ -7,12 +7,12 @@
7
7
  // to leave. The name drawn at the top left is Hanzo Dev: this host is the
8
8
  // dev agent. Chat, on the rooms shell, is Hanzo AI.
9
9
  //
10
- // THE ADDRESS IS THE QUERY. The app is `/` (app/page.tsx), and what it shows
11
- // rides `?at=` in the builder's own grammar (lib/host.ts `AT`), so a run, a
10
+ // THE ADDRESS IS THE PATH. Dev is `/dev`, and what it shows is the path under
11
+ // it in the builder's own grammar (lib/host.ts `app`, `under`), so a run, a
12
12
  // screen, a Settings section, a Customize tab, the plans and a project all
13
13
  // survive a reload, and Back steps through them. A move is a native history
14
- // entry, which the Next router folds into `useSearchParams`, so reading the
15
- // address and moving it are one query.
14
+ // entry, which the Next router folds into `usePathname`, so reading the address
15
+ // and moving it are one path.
16
16
  import { useMemo } from 'react';
17
17
  import { useLook, useRooms } from './host.js';
18
18
  import { useIam } from '@hanzo/iam/react';
@@ -21,7 +21,7 @@ import { administers, path, route } from '@hanzo/build';
21
21
  import { bearer, named, org, orgs, pick } from './lib/session.js';
22
22
  import { enter } from './lib/destination.js';
23
23
  import { api } from './lib/api.js';
24
- import { app, AT } from './lib/host.js';
24
+ import { app, under } from './lib/host.js';
25
25
  import { link } from './lib/tags.js';
26
26
  /** Where the builder links out: every one of them an address of this site. */
27
27
  const LINKS = {
@@ -32,14 +32,14 @@ const LINKS = {
32
32
  };
33
33
  /** The person's theme, when they have chosen one the builder can name. */
34
34
  const theme = (t) => (t === 'light' || t === 'dark' || t === 'system' ? t : undefined);
35
- /** The app address a query value names, canonical; '' for anything that is not one. */
35
+ /** The app address a builder path names, canonical; '' for anything that is not one. */
36
36
  const canonical = (at) => path(route(at ?? ''));
37
37
  /** The host for a signed-in reader. */
38
38
  export function useDevHost() {
39
39
  const { user, accessToken } = useIam();
40
40
  const logout = useSignOut();
41
- const { router, search } = useRooms();
42
- const here = canonical(search?.get(AT) ?? null);
41
+ const { router, path: pathname } = useRooms();
42
+ const here = canonical(under(pathname));
43
43
  const look = useLook();
44
44
  // The token's claims change only when the token does: a sign-in, a refresh.
45
45
  const memberships = useMemo(() => orgs(), [accessToken]); // eslint-disable-line react-hooks/exhaustive-deps
@@ -70,7 +70,7 @@ export function useDevHost() {
70
70
  path: here,
71
71
  go: (to, how) => {
72
72
  const next = app(canonical(to));
73
- if (`${window.location.pathname}${window.location.search}` === next)
73
+ if (window.location.pathname === next && !window.location.search)
74
74
  return;
75
75
  if (how?.replace)
76
76
  window.history.replaceState(null, '', next);
package/dist/host.d.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * configure({ site: 'https://hanzo.ai', home: '/', signUp: '/start' })
9
9
  *
10
- * <Rooms router={useRouter()} search={useSearchParams()} route={route} Link={Link}>
10
+ * <Rooms router={useRouter()} search={useSearchParams()} path={usePathname()} route={route} Link={Link}>
11
11
  * <Room mode="cal" />
12
12
  * </Rooms>
13
13
  *
@@ -17,7 +17,7 @@
17
17
  * any component and on pages the rooms never draw. So the host states them once
18
18
  * with `configure()`, in a module its root imports, before anything renders.
19
19
  *
20
- * The ROUTER, the query and the route change as the page moves, so they ride
20
+ * The ROUTER, the query, the path and the route change as the page moves, so they ride
21
21
  * React context, and a room reads them with `useRooms()`.
22
22
  */
23
23
  import { type AnchorHTMLAttributes, type ComponentType, type ReactNode } from 'react';
@@ -34,14 +34,18 @@ export interface Router {
34
34
  }
35
35
  interface Moving {
36
36
  router: Router;
37
- /** The address's query, for the rooms that read one (Dev's `?at=`). */
37
+ /** The address's query, for the rooms that read one (Chat's `?chat=`). */
38
38
  search: URLSearchParams | null;
39
+ /** The address's path, for the rooms that read one (Dev's, under `/dev`). */
40
+ path: string | null;
39
41
  route: string;
40
42
  Link: Link;
41
43
  }
42
- export interface RoomsProps extends Omit<Moving, 'search'> {
44
+ export interface RoomsProps extends Omit<Moving, 'search' | 'path'> {
43
45
  /** Absent where no mounted room reads the query, so a static export needs no Suspense for it. */
44
46
  search?: URLSearchParams | null;
47
+ /** Absent where no mounted room reads the path. */
48
+ path?: string | null;
45
49
  children?: ReactNode;
46
50
  }
47
51
  /** The router, the query, the route and the link the host mounted the rooms with. */
@@ -72,5 +76,5 @@ export declare function useLook(): {
72
76
  * keeps their choice in step with IAM.
73
77
  */
74
78
  export declare function Look(): any;
75
- export declare function Rooms({ router, search, route, Link, children }: RoomsProps): import("react").JSX.Element;
79
+ export declare function Rooms({ router, search, path, route, Link, children }: RoomsProps): import("react").JSX.Element;
76
80
  export {};
package/dist/host.js CHANGED
@@ -9,7 +9,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
9
9
  *
10
10
  * configure({ site: 'https://hanzo.ai', home: '/', signUp: '/start' })
11
11
  *
12
- * <Rooms router={useRouter()} search={useSearchParams()} route={route} Link={Link}>
12
+ * <Rooms router={useRouter()} search={useSearchParams()} path={usePathname()} route={route} Link={Link}>
13
13
  * <Room mode="cal" />
14
14
  * </Rooms>
15
15
  *
@@ -19,7 +19,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
19
19
  * any component and on pages the rooms never draw. So the host states them once
20
20
  * with `configure()`, in a module its root imports, before anything renders.
21
21
  *
22
- * The ROUTER, the query and the route change as the page moves, so they ride
22
+ * The ROUTER, the query, the path and the route change as the page moves, so they ride
23
23
  * React context, and a room reads them with `useRooms()`.
24
24
  */
25
25
  import { createContext, useContext } from 'react';
@@ -35,6 +35,7 @@ const Context = createContext({
35
35
  forward: () => window.history.forward(),
36
36
  },
37
37
  search: null,
38
+ path: null,
38
39
  route: '/',
39
40
  Link: Plain,
40
41
  });
@@ -71,6 +72,6 @@ export function Look() {
71
72
  useLook();
72
73
  return null;
73
74
  }
74
- export function Rooms({ router, search = null, route, Link, children }) {
75
- return _jsx(Context.Provider, { value: { router, search, route, Link }, children: children });
75
+ export function Rooms({ router, search = null, path = null, route, Link, children }) {
76
+ return _jsx(Context.Provider, { value: { router, search, path, route, Link }, children: children });
76
77
  }
@@ -30,30 +30,35 @@ export declare const ENTRY = "https://hanzo.team/?signin";
30
30
  */
31
31
  export declare const START = "https://hanzo.team/start";
32
32
  /**
33
- * The web app, at `/` for a signed-in reader, in two modes. Chat is the default.
34
- * Dev is @hanzo/build's `<Builder>`, and what it shows rides ONE query, `?at=`,
35
- * in the builder's own grammar (`route` in @hanzo/build): '' New, `sess_<id>` a
36
- * run, `-/<screen>`, `-/settings[/<section>]`, `-/customize[/<tab>]`,
37
- * `-/plans`, `<slug>` a project. A query and not a path, because a static
38
- * export has no server to answer a path under `/`.
33
+ * The web app, for a signed-in reader, in two modes. Chat is the default, at
34
+ * `/`. Dev is @hanzo/build's `<Builder>` at `/dev`, and what it shows is the
35
+ * PATH under it, in the builder's own grammar (`route` in @hanzo/build): `/dev`
36
+ * New, `/dev/sess_<id>` a run, `/dev/<org>/<repo>` a project,
37
+ * `/dev/-/<screen>`, `/dev/-/settings[/<section>]`, `/dev/-/customize[/<tab>]`,
38
+ * `/dev/<slug>` a deployed site. The export holds one page for all of them,
39
+ * `/dev`, and the edge serves it for every path under it (hanzo.ai's
40
+ * lib/edge.ts `APP`).
39
41
  */
40
- export declare const AT = "at";
41
- /** `/` in Dev at an app address; '' is Dev's New, `/?at=`. The key is the mode. */
42
+ export declare const DEV = "/dev";
43
+ /** Dev at an app address; '' is Dev's New, `/dev` itself. */
42
44
  export declare const app: (path: string) => string;
45
+ /** The app address a pathname names under `/dev`, or null for a pathname that is not Dev's. */
46
+ export declare const under: (pathname: string | null | undefined) => string | null;
43
47
  /**
44
48
  * Chat, the app's default mode: `/` is a new conversation and `?chat=<id>` one
45
- * of the reader's. So the address says which mode the app is in — `at` for Dev,
46
- * anything else Chat — and a reload keeps it.
49
+ * of the reader's. So the address says which mode the app is in — a path under
50
+ * `/dev` for Dev, anything else Chat — and a reload keeps it.
47
51
  */
48
52
  export declare const TALK = "chat";
49
53
  /** `/` in Chat, on a conversation, or `/` itself for a new one. */
50
54
  export declare const talk: (id?: string | null) => string;
51
55
  /**
52
- * The door opened on one run or project: `?run=` or `?project=`, the query the
53
- * door turns into `?at=` — and the one the console and shared links already
54
- * carry. Only a value that is exactly a session id or a project slug is
55
- * carried; anything else opens /dev itself, so no caller can make this name
56
- * another address.
56
+ * Dev opened on one run, project or site, from anywhere: every "Try Hanzo Dev"
57
+ * link, a room's Dev, the console's "open in the builder". It signs a stranger
58
+ * in on the way (app/(app)/dev/_door.tsx), and is reached through `site()`:
59
+ * here on hanzo.ai, across to hanzo.ai from hanzo.team. Only a value that is
60
+ * exactly a session id, a repository or a site's slug is carried; anything else
61
+ * opens /dev itself, so no caller can make this name another address.
57
62
  */
58
63
  export declare const dev: (ref?: string | null) => string;
59
64
  /**
package/dist/lib/host.js CHANGED
@@ -32,44 +32,59 @@ export const ENTRY = `${WORKSPACE}/?signin`;
32
32
  */
33
33
  export const START = `${WORKSPACE}/start`;
34
34
  /**
35
- * The web app, at `/` for a signed-in reader, in two modes. Chat is the default.
36
- * Dev is @hanzo/build's `<Builder>`, and what it shows rides ONE query, `?at=`,
37
- * in the builder's own grammar (`route` in @hanzo/build): '' New, `sess_<id>` a
38
- * run, `-/<screen>`, `-/settings[/<section>]`, `-/customize[/<tab>]`,
39
- * `-/plans`, `<slug>` a project. A query and not a path, because a static
40
- * export has no server to answer a path under `/`.
35
+ * The web app, for a signed-in reader, in two modes. Chat is the default, at
36
+ * `/`. Dev is @hanzo/build's `<Builder>` at `/dev`, and what it shows is the
37
+ * PATH under it, in the builder's own grammar (`route` in @hanzo/build): `/dev`
38
+ * New, `/dev/sess_<id>` a run, `/dev/<org>/<repo>` a project,
39
+ * `/dev/-/<screen>`, `/dev/-/settings[/<section>]`, `/dev/-/customize[/<tab>]`,
40
+ * `/dev/<slug>` a deployed site. The export holds one page for all of them,
41
+ * `/dev`, and the edge serves it for every path under it (hanzo.ai's
42
+ * lib/edge.ts `APP`).
41
43
  */
42
- export const AT = 'at';
43
- /** `/` in Dev at an app address; '' is Dev's New, `/?at=`. The key is the mode. */
44
- export const app = (path) => `/?${AT}=${path}`;
44
+ export const DEV = '/dev';
45
+ /** Dev at an app address; '' is Dev's New, `/dev` itself. */
46
+ export const app = (path) => {
47
+ const p = path.replace(/^\/+|\/+$/g, '');
48
+ return p ? `${DEV}/${p}` : DEV;
49
+ };
50
+ /** The app address a pathname names under `/dev`, or null for a pathname that is not Dev's. */
51
+ export const under = (pathname) => {
52
+ if (!pathname)
53
+ return null;
54
+ if (pathname === DEV || pathname === `${DEV}/`)
55
+ return '';
56
+ if (!pathname.startsWith(`${DEV}/`))
57
+ return null;
58
+ try {
59
+ return decodeURIComponent(pathname.slice(DEV.length + 1));
60
+ }
61
+ catch {
62
+ return '';
63
+ }
64
+ };
45
65
  /**
46
66
  * Chat, the app's default mode: `/` is a new conversation and `?chat=<id>` one
47
- * of the reader's. So the address says which mode the app is in — `at` for Dev,
48
- * anything else Chat — and a reload keeps it.
67
+ * of the reader's. So the address says which mode the app is in — a path under
68
+ * `/dev` for Dev, anything else Chat — and a reload keeps it.
49
69
  */
50
70
  export const TALK = 'chat';
51
71
  /** `/` in Chat, on a conversation, or `/` itself for a new one. */
52
72
  export const talk = (id) => (id ? `/?${TALK}=${encodeURIComponent(id)}` : '/');
53
- /**
54
- * The door into the app: every "Try Hanzo Dev" link, the rail's Dev, the
55
- * console's "open in the builder", dev.hanzo.ai. It signs a stranger in and
56
- * forwards a member to Dev in the app, `/?at=` (app/(app)/dev/_door.tsx). It is
57
- * the app's page, so it is reached through `site()`: here on hanzo.ai, across
58
- * to hanzo.ai from hanzo.team.
59
- */
60
- const DEV = '/dev';
61
73
  /** A run, as the platform mints its id. */
62
74
  const SESSION = /^sess_[0-9a-f]{32}$/;
63
- /** A project, as the Sites plane slugs it. */
75
+ /** A deployed site, as the Sites plane slugs it. */
64
76
  const SLUG = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/;
77
+ /** A repository on the forge, `<org>/<name>`. */
78
+ const REPO = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}\/[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
65
79
  /**
66
- * The door opened on one run or project: `?run=` or `?project=`, the query the
67
- * door turns into `?at=` — and the one the console and shared links already
68
- * carry. Only a value that is exactly a session id or a project slug is
69
- * carried; anything else opens /dev itself, so no caller can make this name
70
- * another address.
80
+ * Dev opened on one run, project or site, from anywhere: every "Try Hanzo Dev"
81
+ * link, a room's Dev, the console's "open in the builder". It signs a stranger
82
+ * in on the way (app/(app)/dev/_door.tsx), and is reached through `site()`:
83
+ * here on hanzo.ai, across to hanzo.ai from hanzo.team. Only a value that is
84
+ * exactly a session id, a repository or a site's slug is carried; anything else
85
+ * opens /dev itself, so no caller can make this name another address.
71
86
  */
72
- export const dev = (ref) => site(!ref ? DEV : SESSION.test(ref) ? `${DEV}?run=${ref}` : SLUG.test(ref) ? `${DEV}?project=${ref}` : DEV);
87
+ export const dev = (ref) => site(ref && (SESSION.test(ref) || REPO.test(ref) || SLUG.test(ref)) ? app(ref) : DEV);
73
88
  /** The routes under app/(app) the apex keeps: the app — /chat, a chat shared by link, and /dev. */
74
89
  const KEPT = new Set(['/chat', '/chat/shared', '/dev']);
75
90
  /** Settings, and every page under it: the person's and the organization's, served wherever the app is. */
@@ -14,7 +14,7 @@
14
14
  * who was signed in, which reads as a permissions problem rather than a typo.
15
15
  * Two derivations of one name never agree; this is the one derivation.
16
16
  */
17
- import { AT, TALK } from './host.js';
17
+ import { TALK } from './host.js';
18
18
  import { api } from './api.js';
19
19
  /** The SDK's own keys, `hanzo_iam_`-prefixed. The far side of a boundary. */
20
20
  const PREFIX = 'hanzo_iam_';
@@ -59,12 +59,13 @@ export function live() {
59
59
  /** The attribute on `<html>` that says `/` is showing the app, not the landing. */
60
60
  export const APP = 'data-app';
61
61
  /**
62
- * An address on `/` that names a place in the app — `?at=` in Dev, `?chat` in
63
- * Chat (lib/host.ts `AT`, `TALK`). The app is signed-in only, so a reader
64
- * nobody has signed in is sent to hanzo.id with it rather than shown the
65
- * landing, and it comes back with them.
62
+ * An address on `/` that names a place in the app — `?chat` in Chat
63
+ * (lib/host.ts `TALK`; Dev's places are paths under `/dev`, whose own page
64
+ * signs a stranger in). The app is signed-in only, so a reader nobody has
65
+ * signed in is sent to hanzo.id with it rather than shown the landing, and it
66
+ * comes back with them.
66
67
  */
67
- const PLACE = new RegExp(`[?&](?:${AT}|${TALK})(?:[=&]|$)`);
68
+ const PLACE = new RegExp(`[?&]${TALK}(?:[=&]|$)`);
68
69
  /** Whether `search` names a place in the app. */
69
70
  export const place = (search) => PLACE.test(search);
70
71
  /**
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.34",
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",