@hanzo/rooms 0.1.91 → 0.1.92

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/App.js CHANGED
@@ -119,7 +119,7 @@ function Window({ bar }) {
119
119
  const shut = useCallback(() => setDrawer(false), []);
120
120
  // The sidebar shut, kept per browser under the key the builder's rail kept it under.
121
121
  const [closed, setClosed] = useKept('hanzo.build.rail', false);
122
- // The palette is open (⌘K, the sidebar's search).
122
+ // The palette is open (the sidebar's search; ⌘K toggles it).
123
123
  const [finding, setFinding] = useState(false);
124
124
  const found = useCallback(() => setFinding(false), []);
125
125
  const dev = useDevHost();
@@ -217,7 +217,7 @@ function Window({ bar }) {
217
217
  const key = (e) => {
218
218
  if (summons(e)) {
219
219
  e.preventDefault();
220
- setFinding(true);
220
+ setFinding((open) => !open);
221
221
  return;
222
222
  }
223
223
  if (typing(e))
@@ -0,0 +1,21 @@
1
+ /** The apps in beta as this reader stands: which open, which they asked for, and the ask. */
2
+ export declare function useBetas(): {
3
+ /** The one predicate: whether this reader may open the app `id`. */
4
+ open: (id: string) => boolean;
5
+ /** Whether they asked for it. */
6
+ asked: (id: string) => boolean;
7
+ /** Ask for these apps. */
8
+ request: (ids: string[]) => Promise<void>;
9
+ /** The cloud has answered for this reader. */
10
+ ready: boolean;
11
+ };
12
+ /** Every app in beta, by group, each with its action. */
13
+ export declare function BetasList({ go }: {
14
+ go?: (href: string) => void;
15
+ }): import("react").JSX.Element;
16
+ /** The Betas list in a dialog, from the rail's More. */
17
+ export declare function BetasDialog({ open, onOpenChange, go }: {
18
+ open: boolean;
19
+ onOpenChange: (open: boolean) => void;
20
+ go?: (href: string) => void;
21
+ }): import("react").JSX.Element;
package/dist/Betas.js ADDED
@@ -0,0 +1,127 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // The apps in beta: which this reader may open, and the list they ask from.
4
+ //
5
+ // WHO MAY OPEN ONE is the cloud's answer, never this page's. Each app in beta is a
6
+ // principal switch, `beta.<id>`, decided per caller from the validated org and
7
+ // person (cloud apps/flags), and GET /v1/flag/platform answers every one for the
8
+ // signed-in caller. A SuperAdmin grants one to a person from their CRM record.
9
+ // `opens` (lib/betas) reads that answer, the one predicate every caller asks.
10
+ //
11
+ // WHAT THEY ASKED FOR is their CRM record's, apps/marketing's beta record:
12
+ // GET /v1/marketing/beta reads it and POST /v1/marketing/beta/request adds to it,
13
+ // stamped with the time and stated on the event plane under the person and the
14
+ // org, so the request reaches the team where it works the person.
15
+ //
16
+ // Signed out, still loading, or on any error nothing is open: a flags outage
17
+ // cannot leak an unfinished app.
18
+ import { useCallback, useEffect, useState, useSyncExternalStore } from 'react';
19
+ import { useIam } from '@hanzo/iam/react';
20
+ import { Button, Dialog, DialogContent, DialogTitle, Text, XStack, YStack } from '@hanzo/ui';
21
+ import { api } from './lib/api.js';
22
+ import { BETAS, GROUPS, opens } from './lib/betas.js';
23
+ import { bearer, scope, superAdmin } from './lib/session.js';
24
+ const NONE = { flags: {}, asked: [] };
25
+ /** One read per token, shared by every caller on the page. */
26
+ let held = null;
27
+ let reading = null;
28
+ const listeners = new Set();
29
+ const tell = () => listeners.forEach((f) => f());
30
+ /** A switch read this long ago is read again on the next mount. */
31
+ const FRESH = 30_000;
32
+ async function get(path) {
33
+ const r = await fetch(`${api()}${path}`, { headers: scope() });
34
+ return r.ok ? (await r.json()) : null;
35
+ }
36
+ function read(token) {
37
+ if (reading)
38
+ return reading;
39
+ reading = Promise.all([
40
+ get('/v1/flag/platform').catch(() => null),
41
+ get('/v1/marketing/beta').catch(() => null),
42
+ ])
43
+ .then(([f, b]) => {
44
+ held = { token, said: { flags: f?.flags ?? {}, asked: b?.betas ?? [] }, at: Date.now() };
45
+ })
46
+ .finally(() => {
47
+ reading = null;
48
+ tell();
49
+ });
50
+ return reading;
51
+ }
52
+ const subscribe = (f) => {
53
+ listeners.add(f);
54
+ return () => listeners.delete(f);
55
+ };
56
+ /** The apps in beta as this reader stands: which open, which they asked for, and the ask. */
57
+ export function useBetas() {
58
+ const { accessToken } = useIam();
59
+ const [sudo, setSudo] = useState(false);
60
+ const [local, setLocal] = useState(false);
61
+ // Facts only the browser holds arrive after mount, so the first render
62
+ // matches the HTML it hydrates.
63
+ useEffect(() => setLocal(here()), []);
64
+ useEffect(() => setSudo(superAdmin()), [accessToken]);
65
+ const token = accessToken ? bearer() ?? '' : '';
66
+ const now = useSyncExternalStore(subscribe, () => held, () => null);
67
+ useEffect(() => {
68
+ if (token && (!held || held.token !== token || Date.now() - held.at > FRESH))
69
+ void read(token);
70
+ }, [token]);
71
+ const said = token && now?.token === token ? now.said : NONE;
72
+ const open = useCallback((id) => opens(id, said.flags, sudo || local), [said, sudo, local]);
73
+ const asked = useCallback((id) => said.asked.includes(id), [said]);
74
+ const request = useCallback(async (ids) => {
75
+ const r = await fetch(`${api()}/v1/marketing/beta/request`, {
76
+ method: 'POST',
77
+ headers: { 'content-type': 'application/json', ...scope() },
78
+ body: JSON.stringify({ betas: ids }),
79
+ });
80
+ if (!r.ok)
81
+ throw new Error(r.status === 401 ? 'Sign in to ask for access.' : `The request was not taken (${r.status}).`);
82
+ const record = (await r.json());
83
+ if (held && held.token === token) {
84
+ held = { ...held, said: { ...held.said, asked: record.betas ?? [...held.said.asked, ...ids] } };
85
+ tell();
86
+ }
87
+ }, [token]);
88
+ return { open, asked, request, ready: Boolean(token) && now?.token === token };
89
+ }
90
+ /**
91
+ * Whether this page is served from a developer's own machine, where every app
92
+ * opens: a room nobody can open is a room nobody can work on. The ORIGIN, which
93
+ * no deployment can acquire by configuration.
94
+ */
95
+ function here() {
96
+ if (typeof window === 'undefined')
97
+ return false;
98
+ const host = window.location.hostname;
99
+ return host === 'localhost' || host === '127.0.0.1' || host === '[::1]' || host.endsWith('.local') || /^10\./.test(host) || /^192\.168\./.test(host);
100
+ }
101
+ /** One app's action: Open where it may, Requested once asked, else Request access. */
102
+ function Ask({ beta, go }) {
103
+ const { open, asked, request } = useBetas();
104
+ const [busy, setBusy] = useState(false);
105
+ const [wrong, setWrong] = useState(null);
106
+ if (open(beta.id)) {
107
+ return beta.href && go ? (_jsx(Button, { size: "sm", rounded: 999, onPress: () => go(beta.href), "data-slot": "beta-open", children: "Open" })) : (_jsx(Text, { fontSize: "$2", color: "$color11", "data-slot": "beta-on", children: "You\u2019re in" }));
108
+ }
109
+ if (asked(beta.id)) {
110
+ return (_jsx(Text, { fontSize: "$2", color: "$color11", "data-slot": "beta-requested", children: "Requested" }));
111
+ }
112
+ return (_jsxs(YStack, { items: "flex-end", gap: "$1", children: [_jsx(Button, { size: "sm", rounded: 999, variant: "outline", disabled: busy, "data-slot": "beta-request", onPress: () => {
113
+ setBusy(true);
114
+ setWrong(null);
115
+ request([beta.id])
116
+ .catch((e) => setWrong(e instanceof Error ? e.message : String(e)))
117
+ .finally(() => setBusy(false));
118
+ }, children: busy ? 'Requesting…' : 'Request access' }), wrong ? (_jsx(Text, { fontSize: "$1", color: "$red10", children: wrong })) : null] }));
119
+ }
120
+ /** Every app in beta, by group, each with its action. */
121
+ export function BetasList({ go }) {
122
+ return (_jsx(YStack, { gap: "$4", "data-slot": "betas", children: GROUPS.map((group) => (_jsxs(YStack, { gap: "$2", children: [_jsx(Text, { fontSize: "$1", color: "$color10", textTransform: "uppercase", letterSpacing: 0.6, children: group }), BETAS.filter((b) => b.group === group).map((b) => (_jsxs(XStack, { items: "center", gap: "$3", "data-beta": b.id, children: [_jsxs(YStack, { flex: 1, minW: 0, children: [_jsx(Text, { fontSize: "$3", fontWeight: "500", children: b.name }), _jsx(Text, { fontSize: "$2", color: "$color11", children: b.line })] }), _jsx(Ask, { beta: b, go: go })] }, b.id)))] }, group))) }));
123
+ }
124
+ /** The Betas list in a dialog, from the rail's More. */
125
+ export function BetasDialog({ open, onOpenChange, go }) {
126
+ return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { maxW: 520, rounded: "$6", gap: "$3", children: [_jsx(DialogTitle, { children: "Betas" }), _jsx(Text, { fontSize: "$2", color: "$color11", children: "Apps we have not opened to everyone yet. Ask for the ones you want and we will let you in." }), _jsx(YStack, { maxH: "60vh", overflowY: "auto", overflowX: "hidden", pr: "$2", children: _jsx(BetasList, { go: go }) })] }) }));
127
+ }
package/dist/Chat.js CHANGED
@@ -42,7 +42,7 @@ import { AgentMaker, HOUSE, useAgentShelf, useCrew } from "./crew.js";
42
42
  import { called } from "./team.js";
43
43
  import { Anchor, Box, Button, Text, View, XStack, YStack } from "@hanzo/ui";
44
44
  import { useMedia } from "@hanzo/gui";
45
- import { tidy } from "./lib/title.js";
45
+ import { given, tidy } from "./lib/title.js";
46
46
  import { useIam, useIamToken } from "@hanzo/iam/react";
47
47
  import { bearer, hasSession, named, org } from "./lib/session.js";
48
48
  import { api } from "./lib/api.js";
@@ -64,7 +64,7 @@ import { Share } from "./Share.js";
64
64
  import { ConversationMenu, ThreadMore, useThreadList } from "./thread.js";
65
65
  import { Schedule } from "./Schedule.js";
66
66
  import { keepFile, useAttach, useKeep } from "./keep.js";
67
- import { artifacts, lead, nameOf, PAGE, Panel, PanelToggle, prompt, renders, Reply, Served, Summary, useDeck, useKey, Suggestions, useCarry, useLanguage, useMention, useMind, useSuggestions, CHAT_MODES, usePanel, frames, name as tabName, } from "@hanzo/build";
67
+ import { artifacts, lead, nameOf, PAGE, Panel, PanelToggle, prompt, renders, Reply, Served, Summary, useDeck, useKey, Suggestions, useCarry, useLanguage, useMention, useMind, useSuggestions, book, hand, taken, want, path as devPath, CHAT_MODES, usePanel, frames, name as tabName, } from "@hanzo/build";
68
68
  import { checkoutUrl } from './lib/pay.js';
69
69
  import { Upgrade, useFree } from './Upgrade.js';
70
70
  import { enter, LOGIN } from './lib/destination.js';
@@ -527,8 +527,9 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
527
527
  go: (href) => (/^https?:/.test(href) ? void window.open(href, "_blank", "noopener,noreferrer") : go(href)),
528
528
  }, CHAT_MODES);
529
529
  const agentShelf = useAgentShelf();
530
- // The opening greets the person by their first name, as their account spells it.
531
- const firstName = useMemo(() => (anonymous ? "" : ((named()?.name ?? "").trim().split(/\s+/)[0] ?? "")), [anonymous]);
530
+ // The opening greets the person by the first word of their display name, capitalised ("What’s up, Z?").
531
+ // A username is a handle, not a name: without a display name the greeting has none.
532
+ const firstName = useMemo(() => (anonymous ? "" : given(named()?.display ?? "")), [anonymous]);
532
533
  // Up to three things worth doing next, from the person's own activity (@hanzo/build).
533
534
  const hints = useSuggestions(target);
534
535
  // THE HEADER'S ⋯ IS THE ROW'S MENU (thread.tsx): rename, pin, share, its
@@ -1179,8 +1180,8 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
1179
1180
  .then((id) => (!open && id ? reopen(id) : undefined))
1180
1181
  .catch(() => { });
1181
1182
  };
1182
- const submit = () => {
1183
- const text = draft.trim();
1183
+ const submit = (ask = draft) => {
1184
+ const text = ask.trim();
1184
1185
  if (preparing || (!text && !files.length))
1185
1186
  return;
1186
1187
  if (!carry.preparing)
@@ -1356,10 +1357,46 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
1356
1357
  };
1357
1358
  // THE COMPOSER'S OPTIONS, held apart so its keys (the `/` and `@` lists' arrows,
1358
1359
  // Ctrl+U) hook in through `useMention` before the frame is drawn.
1360
+ // What pressing a suggestion does, at once: open what it names, send its prompt
1361
+ // here (the room is a new chat while one shows), hand its run to Dev, or make
1362
+ // its scheduled task and show it in Scheduled tasks.
1363
+ const hint = (a) => {
1364
+ switch (a.kind) {
1365
+ case "open":
1366
+ if (a.item)
1367
+ want(a.item);
1368
+ if (/^https:\/\//.test(a.to))
1369
+ window.open(a.to, "_blank", "noopener,noreferrer");
1370
+ else
1371
+ go(a.to.startsWith("/") ? a.to : app(a.to));
1372
+ return;
1373
+ case "send":
1374
+ submit(a.prompt);
1375
+ return;
1376
+ case "task":
1377
+ hand("task", a.prompt);
1378
+ go(app(""));
1379
+ return;
1380
+ case "schedule":
1381
+ void book(target, a, "chat").then((made) => go(app(devPath({ kind: "automation", id: made.id }))), (e) => setPrepWrong(e instanceof Error && e.message ? e.message : "The scheduled task could not be made."));
1382
+ return;
1383
+ }
1384
+ };
1385
+ // A chat a suggestion in Dev left for this room is sent once the room is a new
1386
+ // chat: the address's own conversation let go of, nothing said yet.
1387
+ const [handed] = useState(() => ({ prompt: anonymous ? "" : taken("send") }));
1388
+ useEffect(() => {
1389
+ if (!handed.prompt || open || said.length)
1390
+ return;
1391
+ const p = handed.prompt;
1392
+ handed.prompt = "";
1393
+ submit(p);
1394
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1395
+ }, [open, said.length]);
1359
1396
  const composing = {
1360
1397
  value: draft,
1361
1398
  onChange: setDraft,
1362
- onSend: submit,
1399
+ onSend: () => submit(),
1363
1400
  onStop: chat.stop,
1364
1401
  busy: chat.streaming,
1365
1402
  // Files alone are a message: what is held goes with an empty draft too.
@@ -1424,7 +1461,7 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
1424
1461
  onLanguage: speakIn,
1425
1462
  talk,
1426
1463
  // While nothing has been said: what is worth doing next.
1427
- below: said.length === 0 && !anonymous ? _jsx(Suggestions, { rows: hints.rows, onPick: setDraft, onDismiss: hints.dismiss }) : undefined,
1464
+ below: said.length === 0 && !anonymous ? _jsx(Suggestions, { rows: hints.rows, onAct: hint, onDismiss: hints.dismiss }) : undefined,
1428
1465
  };
1429
1466
  const mention = useMention(composing);
1430
1467
  return (_jsxs(YStack, { flex: 1, minH: 0, width: "100%", position: "relative", "data-slot": "chat-room",
package/dist/Me.js CHANGED
@@ -5,7 +5,8 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
5
5
  // other app (Shell.tsx).
6
6
  //
7
7
  // Top to bottom: who, with their plan, a row whose › opens the account itself;
8
- // then Upgrade plan, Personalization, Profile and Settings (⌘, / Ctrl+,); then
8
+ // then Upgrade plan, Personalization, Profile and Settings (⌘, / Ctrl+,); Beta,
9
+ // the person's place on the beta waitlist (the site's /beta/status); then
9
10
  // Help ›, the site's own pages and the keyboard shortcuts (⌘/ / Ctrl+/ from
10
11
  // anywhere); and Log out. Every row opens a real destination: Settings'
11
12
  // sections through `showSettings` (open.ts), which the app answers with the
@@ -29,7 +30,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
29
30
  // mints a token that carries it.
30
31
  import { useEffect, useMemo, useRef, useState } from 'react';
31
32
  import { SizableText, XStack, YStack } from '@hanzo/gui';
32
- import { ArrowUpRight, Building2, Check, ChevronsUpDown, CircleHelp, CircleUserRound, Download, FileText, Info, Keyboard, LifeBuoy, LogOut, Palette, Plus, ScrollText, Settings, ShieldCheck, Sparkles, Users, X, } from 'lucide-react';
33
+ import { ArrowUpRight, Building2, Check, ChevronsUpDown, CircleHelp, CircleUserRound, Download, FileText, FlaskConical, Info, Keyboard, LifeBuoy, LogOut, Palette, Plus, ScrollText, Settings, ShieldCheck, Sparkles, Users, X, } from 'lucide-react';
33
34
  import { label, Meter, said as spoken, useStanding } from '@hanzo/build';
34
35
  import { useOrganizations } from '@hanzo/iam/react';
35
36
  import { Button, Dialog, DialogContent, DialogTitle, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, Input, Kbd, } from '@hanzo/ui';
@@ -196,7 +197,7 @@ export function Me({ host, narrow = false, above = false, onOrg, }) {
196
197
  // Clear of the rail's mark (or the row) it opens from, and flush with its foot.
197
198
  offset: 8, children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs(XStack, { ref: (el) => {
198
199
  row.current = el instanceof HTMLElement ? el : null;
199
- }, render: "button", "data-slot": "me", "aria-label": `Account: ${[who, current].filter(Boolean).join(' · ')}`, ...{ title: who }, width: narrow ? 36 : '100%', minH: narrow ? 36 : 48, px: narrow ? 0 : '$2', gap: "$2.5", rounded: narrow ? 999 : '$5', items: "center", justify: narrow ? 'center' : 'flex-start', hoverStyle: { bg: '$hover' }, focusVisibleStyle: { outlineWidth: 2, outlineStyle: 'solid', outlineColor: '$outlineColor' }, cursor: "pointer", children: [_jsx(Face, { host: host, size: narrow ? 26 : 32 }), narrow ? null : (_jsxs(_Fragment, { children: [_jsxs(YStack, { flex: 1, minW: 0, children: [_jsx(SizableText, { size: "$3", fontWeight: "600", color: "$ink", numberOfLines: 1, text: "left", children: who }), working ? (_jsx(SizableText, { size: "$1", color: "$soft", numberOfLines: 1, text: "left", children: working })) : null] }), _jsx(ChevronsUpDown, { size: 14, "aria-hidden": true, opacity: 0.6 })] }))] }) }), _jsxs(DropdownMenuContent, { minW: 260, maxW: "calc(100vw - 16px)", "aria-labelledby": undefined, "aria-label": "Account", onCloseAutoFocus: back, children: [_jsxs(DropdownMenuSub, { open: account, onOpenChange: setAccount, children: [_jsxs(DropdownMenuSubTrigger, { minH: 48, gap: "$2.5", children: [_jsx(Face, { host: host, size: 28 }), _jsxs(YStack, { flex: 1, minW: 0, children: [_jsx(SizableText, { size: "$2", fontWeight: "600", color: "$ink", numberOfLines: 1, children: who }), _jsx(SizableText, { size: "$1", color: "$soft", numberOfLines: 1, children: host.person.email || working })] })] }), account ? (_jsxs(DropdownMenuSubContent, { minW: 260, maxW: "calc(100vw - 16px)", pointerEvents: "auto", children: [_jsx(Meter, { read: read, onPlans: () => leave('here'), onBilling: () => (setOpen(false), showSettings('billing')) }), _jsx(DropdownMenuSeparator, {}), _jsx(DropdownMenuLabel, { children: "Organizations" }), all.map((o) => (_jsx(DropdownMenuItem, { onSelect: () => o.name !== current && onOrg(o.name), children: _jsx(Line, { icon: _jsx(Mark, { org: o }), sub: [o.personal ? 'Personal' : said(o.role), planFor(o)].filter(Boolean).join(' · '), trail: o.name === current ? _jsx(Check, { size: 16, "aria-label": "Current" }) : null, children: o.display }) }, o.name))), _jsx(DropdownMenuItem, { onSelect: () => setCreating(true), children: _jsx(Line, { icon: _jsx(Plus, { size: 16, "aria-hidden": true }), children: "Create organization" }) }), _jsx(DropdownMenuItem, { onSelect: () => window.location.assign(site(ORG_SETTINGS)), children: _jsx(Line, { icon: _jsx(Settings, { size: 16, "aria-hidden": true }), children: "Organization settings" }) }), where().team ? null : here && !here.personal ? (_jsx(DropdownMenuItem, { onSelect: () => out(`${ENTRY}&org=${encodeURIComponent(here.name)}`), children: _jsx(Line, { icon: _jsx(Users, { size: 16, "aria-hidden": true }), trail: _jsx(ArrowUpRight, { size: 14, "aria-hidden": true, opacity: 0.6 }), children: "Open in Hanzo Team" }) })) : (_jsx(DropdownMenuItem, { onSelect: () => setCreating(true), children: _jsx(Line, { icon: _jsx(Users, { size: 16, "aria-hidden": true }), children: "Create a team" }) })), superAdmin() ? (_jsx(DropdownMenuItem, { onSelect: choose, children: _jsx(Line, { icon: _jsx(Building2, { size: 16, "aria-hidden": true }), children: "All organizations" }) })) : null] })) : null] }), team ? (_jsx(DropdownMenuItem, { "data-slot": "me-team", onSelect: () => team.name !== current && onOrg(team.name), children: _jsx(Line, { icon: _jsx(Mark, { org: team }), trail: team.name === current ? _jsx(Check, { size: 16, "aria-label": "Current" }) : null, children: `${team.display} · ${teamPlan || 'No plan'}` }) })) : null, personal ? (_jsx(DropdownMenuItem, { "data-slot": "me-personal", onSelect: () => personal.name !== current && onOrg(personal.name), actionsVisibility: "always", actions: ownPlan ? undefined : (_jsx(Button, { size: "xs", variant: "outline", onPress: getPro, children: "Get Pro" })), children: _jsx(Line, { icon: _jsx(CircleUserRound, { size: 16, "aria-hidden": true }), trail: personal.name === current ? _jsx(Check, { size: 16, "aria-label": "Current" }) : null, children: `Personal · ${ownPlan || 'No plan'}` }) })) : null, _jsx(DropdownMenuSeparator, {}), _jsx(DropdownMenuItem, { onSelect: () => leave('here'), children: _jsx(Line, { icon: _jsx(Sparkles, { size: 16, "aria-hidden": true }), children: upgrade(read.value?.label?.name) }) }), _jsx(DropdownMenuItem, { onSelect: () => showSettings('general'), children: _jsx(Line, { icon: _jsx(Palette, { size: 16, "aria-hidden": true }), children: "Personalization" }) }), _jsx(DropdownMenuItem, { onSelect: () => showSettings('account'), children: _jsx(Line, { icon: _jsx(CircleUserRound, { size: 16, "aria-hidden": true }), children: "Profile" }) }), _jsx(DropdownMenuItem, { "aria-keyshortcuts": "Control+, Meta+,", onSelect: () => showSettings('general'), children: _jsx(Line, { icon: _jsx(Settings, { size: 16, "aria-hidden": true }), trail: _jsx(DropdownMenuShortcut, { "aria-hidden": true, children: apple ? '⌘,' : 'Ctrl+,' }), children: "Settings" }) }), _jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuSub, { open: help, onOpenChange: setHelp, children: [_jsx(DropdownMenuSubTrigger, { children: _jsx(Line, { icon: _jsx(LifeBuoy, { size: 16, "aria-hidden": true }), children: "Help" }) }), help ? (_jsxs(DropdownMenuSubContent, { minW: 240, maxW: "calc(100vw - 16px)", pointerEvents: "auto", children: [pages().map((p) => (_jsx(DropdownMenuItem, { onSelect: () => out(p.href), children: _jsx(Line, { icon: ICONS[p.key], trail: p.key === 'help' ? _jsx(ArrowUpRight, { size: 14, "aria-hidden": true, opacity: 0.6 }) : null, children: p.label }) }, p.key))), _jsx(DropdownMenuItem, { "aria-keyshortcuts": "Control+/ Meta+/", onSelect: () => setKeys(true), children: _jsx(Line, { icon: _jsx(Keyboard, { size: 16, "aria-hidden": true }), trail: _jsx(DropdownMenuShortcut, { "aria-hidden": true, children: apple ? '⌘/' : 'Ctrl+/' }), children: "Keyboard shortcuts" }) }), _jsx(DropdownMenuSeparator, {}), legal().map((p) => (_jsx(DropdownMenuItem, { onSelect: () => out(p.href), children: _jsx(Line, { icon: ICONS[p.key], children: p.label }) }, p.key)))] })) : null] }), host.signOut ? (_jsx(DropdownMenuItem, { onSelect: () => host.signOut?.(), children: _jsx(Line, { icon: _jsx(LogOut, { size: 16, "aria-hidden": true }), children: "Log out" }) })) : null] })] }), _jsx(Create, { open: creating, onOpenChange: setCreating }), _jsx(Shortcuts, { open: keys, onOpenChange: setKeys, apple: apple })] }));
200
+ }, render: "button", "data-slot": "me", "aria-label": `Account: ${[who, current].filter(Boolean).join(' · ')}`, ...{ title: who }, width: narrow ? 36 : '100%', minH: narrow ? 36 : 48, px: narrow ? 0 : '$2', gap: "$2.5", rounded: narrow ? 999 : '$5', items: "center", justify: narrow ? 'center' : 'flex-start', hoverStyle: { bg: '$hover' }, focusVisibleStyle: { outlineWidth: 2, outlineStyle: 'solid', outlineColor: '$outlineColor' }, cursor: "pointer", children: [_jsx(Face, { host: host, size: narrow ? 26 : 32 }), narrow ? null : (_jsxs(_Fragment, { children: [_jsxs(YStack, { flex: 1, minW: 0, children: [_jsx(SizableText, { size: "$3", fontWeight: "600", color: "$ink", numberOfLines: 1, text: "left", children: who }), working ? (_jsx(SizableText, { size: "$1", color: "$soft", numberOfLines: 1, text: "left", children: working })) : null] }), _jsx(ChevronsUpDown, { size: 14, "aria-hidden": true, opacity: 0.6 })] }))] }) }), _jsxs(DropdownMenuContent, { minW: 260, maxW: "calc(100vw - 16px)", "aria-labelledby": undefined, "aria-label": "Account", onCloseAutoFocus: back, children: [_jsxs(DropdownMenuSub, { open: account, onOpenChange: setAccount, children: [_jsxs(DropdownMenuSubTrigger, { minH: 48, gap: "$2.5", children: [_jsx(Face, { host: host, size: 28 }), _jsxs(YStack, { flex: 1, minW: 0, children: [_jsx(SizableText, { size: "$2", fontWeight: "600", color: "$ink", numberOfLines: 1, children: who }), _jsx(SizableText, { size: "$1", color: "$soft", numberOfLines: 1, children: host.person.email || working })] })] }), account ? (_jsxs(DropdownMenuSubContent, { minW: 260, maxW: "calc(100vw - 16px)", pointerEvents: "auto", children: [_jsx(Meter, { read: read, onPlans: () => leave('here'), onBilling: () => (setOpen(false), showSettings('billing')) }), _jsx(DropdownMenuSeparator, {}), _jsx(DropdownMenuLabel, { children: "Organizations" }), all.map((o) => (_jsx(DropdownMenuItem, { onSelect: () => o.name !== current && onOrg(o.name), children: _jsx(Line, { icon: _jsx(Mark, { org: o }), sub: [o.personal ? 'Personal' : said(o.role), planFor(o)].filter(Boolean).join(' · '), trail: o.name === current ? _jsx(Check, { size: 16, "aria-label": "Current" }) : null, children: o.display }) }, o.name))), _jsx(DropdownMenuItem, { onSelect: () => setCreating(true), children: _jsx(Line, { icon: _jsx(Plus, { size: 16, "aria-hidden": true }), children: "Create organization" }) }), _jsx(DropdownMenuItem, { onSelect: () => window.location.assign(site(ORG_SETTINGS)), children: _jsx(Line, { icon: _jsx(Settings, { size: 16, "aria-hidden": true }), children: "Organization settings" }) }), where().team ? null : here && !here.personal ? (_jsx(DropdownMenuItem, { onSelect: () => out(`${ENTRY}&org=${encodeURIComponent(here.name)}`), children: _jsx(Line, { icon: _jsx(Users, { size: 16, "aria-hidden": true }), trail: _jsx(ArrowUpRight, { size: 14, "aria-hidden": true, opacity: 0.6 }), children: "Open in Hanzo Team" }) })) : (_jsx(DropdownMenuItem, { onSelect: () => setCreating(true), children: _jsx(Line, { icon: _jsx(Users, { size: 16, "aria-hidden": true }), children: "Create a team" }) })), superAdmin() ? (_jsx(DropdownMenuItem, { onSelect: choose, children: _jsx(Line, { icon: _jsx(Building2, { size: 16, "aria-hidden": true }), children: "All organizations" }) })) : null] })) : null] }), team ? (_jsx(DropdownMenuItem, { "data-slot": "me-team", onSelect: () => team.name !== current && onOrg(team.name), children: _jsx(Line, { icon: _jsx(Mark, { org: team }), trail: team.name === current ? _jsx(Check, { size: 16, "aria-label": "Current" }) : null, children: `${team.display} · ${teamPlan || 'No plan'}` }) })) : null, personal ? (_jsx(DropdownMenuItem, { "data-slot": "me-personal", onSelect: () => personal.name !== current && onOrg(personal.name), actionsVisibility: "always", actions: ownPlan ? undefined : (_jsx(Button, { size: "xs", variant: "outline", onPress: getPro, children: "Get Pro" })), children: _jsx(Line, { icon: _jsx(CircleUserRound, { size: 16, "aria-hidden": true }), trail: personal.name === current ? _jsx(Check, { size: 16, "aria-label": "Current" }) : null, children: `Personal · ${ownPlan || 'No plan'}` }) })) : null, _jsx(DropdownMenuSeparator, {}), _jsx(DropdownMenuItem, { onSelect: () => leave('here'), children: _jsx(Line, { icon: _jsx(Sparkles, { size: 16, "aria-hidden": true }), children: upgrade(read.value?.label?.name) }) }), _jsx(DropdownMenuItem, { onSelect: () => showSettings('general'), children: _jsx(Line, { icon: _jsx(Palette, { size: 16, "aria-hidden": true }), children: "Personalization" }) }), _jsx(DropdownMenuItem, { onSelect: () => showSettings('account'), children: _jsx(Line, { icon: _jsx(CircleUserRound, { size: 16, "aria-hidden": true }), children: "Profile" }) }), _jsx(DropdownMenuItem, { "aria-keyshortcuts": "Control+, Meta+,", onSelect: () => showSettings('general'), children: _jsx(Line, { icon: _jsx(Settings, { size: 16, "aria-hidden": true }), trail: _jsx(DropdownMenuShortcut, { "aria-hidden": true, children: apple ? '⌘,' : 'Ctrl+,' }), children: "Settings" }) }), _jsx(DropdownMenuItem, { onSelect: () => window.location.assign(site('/beta/status')), children: _jsx(Line, { icon: _jsx(FlaskConical, { size: 16, "aria-hidden": true }), children: "Beta" }) }), _jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuSub, { open: help, onOpenChange: setHelp, children: [_jsx(DropdownMenuSubTrigger, { children: _jsx(Line, { icon: _jsx(LifeBuoy, { size: 16, "aria-hidden": true }), children: "Help" }) }), help ? (_jsxs(DropdownMenuSubContent, { minW: 240, maxW: "calc(100vw - 16px)", pointerEvents: "auto", children: [pages().map((p) => (_jsx(DropdownMenuItem, { onSelect: () => out(p.href), children: _jsx(Line, { icon: ICONS[p.key], trail: p.key === 'help' ? _jsx(ArrowUpRight, { size: 14, "aria-hidden": true, opacity: 0.6 }) : null, children: p.label }) }, p.key))), _jsx(DropdownMenuItem, { "aria-keyshortcuts": "Control+/ Meta+/", onSelect: () => setKeys(true), children: _jsx(Line, { icon: _jsx(Keyboard, { size: 16, "aria-hidden": true }), trail: _jsx(DropdownMenuShortcut, { "aria-hidden": true, children: apple ? '⌘/' : 'Ctrl+/' }), children: "Keyboard shortcuts" }) }), _jsx(DropdownMenuSeparator, {}), legal().map((p) => (_jsx(DropdownMenuItem, { onSelect: () => out(p.href), children: _jsx(Line, { icon: ICONS[p.key], children: p.label }) }, p.key)))] })) : null] }), host.signOut ? (_jsx(DropdownMenuItem, { onSelect: () => host.signOut?.(), children: _jsx(Line, { icon: _jsx(LogOut, { size: 16, "aria-hidden": true }), children: "Log out" }) })) : null] })] }), _jsx(Create, { open: creating, onOpenChange: setCreating }), _jsx(Shortcuts, { open: keys, onOpenChange: setKeys, apple: apple })] }));
200
201
  }
201
202
  /** The app's keys (help.ts `KEYS`), one row each, spelled for this platform. */
202
203
  function Shortcuts({ open, onOpenChange, apple }) {
package/dist/Palette.d.ts CHANGED
@@ -3,22 +3,36 @@ import { type Host } from '@hanzo/build';
3
3
  import { type ShortcutKeys } from '@hanzo/ui';
4
4
  import { type Place } from './lib/host.js';
5
5
  import { type Hit } from './seek.js';
6
- /** A row: what it is called, its glyph, and what pressing it does. */
6
+ /** A row: what it is called, its glyph, where it lives, and what pressing it does. */
7
7
  export interface Action extends Hit {
8
8
  icon?: ReactNode;
9
9
  shortcut?: ShortcutKeys;
10
+ /** Where it lives, dimmed after its name: Settings, Customize, Help. */
11
+ where?: string;
10
12
  run: () => void;
11
13
  /** Pressing it keeps the palette open. */
12
14
  stay?: boolean;
13
15
  }
14
- export declare function Palette({ onClose, go, actions, mode, host, }: {
16
+ /**
17
+ * Every place the reader can be taken: `top` is what an empty field lists, the
18
+ * products and the places of the app; `deep` is what only a query reaches.
19
+ * Without a host (hanzo.team's frame) the builder's registries are not there,
20
+ * and the products and the site's pages are.
21
+ */
22
+ export declare function places(host: Host | undefined, go: (address: string) => void): {
23
+ top: Action[];
24
+ deep: Action[];
25
+ };
26
+ /** The palette's actions; a host that cannot do one (no theme to change, no org to pick) is not offered it. */
27
+ export declare function actions(host: Host | undefined, go: (address: string) => void, signOut: () => void, keys: () => void): Action[];
28
+ export declare function Palette({ onClose, go, actions: frame, mode, host, }: {
15
29
  onClose: () => void;
16
- /** Moves the app to one of its addresses (lib/host `talk`, `app`). */
30
+ /** Moves the app to one of its addresses (lib/host `talk`, `app`, `studio`). */
17
31
  go: (address: string) => void;
18
- /** The frame's own actions, after the palette's; one with a palette action's id stands in for it. */
32
+ /** The frame's own places; one with a palette row's id stands in for it, the rest follow under Go to. */
19
33
  actions?: Action[];
20
- /** The product the reader is in: what the empty field lists and what a query ranks first. */
34
+ /** The product the reader is in: its own things lead a tie. */
21
35
  mode?: Place['mode'];
22
- /** Dev's host, whose runs the palette lists in Dev. */
36
+ /** The builder's host: its registries, its runs, its theme and organization. */
23
37
  host?: Host;
24
38
  }): import("react").JSX.Element;
package/dist/Palette.js CHANGED
@@ -1,30 +1,36 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- // The one search. ⌘K / Ctrl+K from anywhere and the sidebar's search open it:
4
- // a dialog with the field on top, listing the product's own recents while the
5
- // field is empty — Chat's Recent chats, Dev's Recent runs — and, for a query,
6
- // the product's own things first: in Chat, Chats, Projects, Agents, Skills and
7
- // Actions; in Dev, Runs, Dev's places (Templates, Projects, Issues, Machines,
8
- // Environments, Library, Scheduled tasks, Customize), Projects, Skills, Actions
9
- // and Chats last; each cut to its best five (seek.ts `findIn`). A row opens
10
- // where it lives: a chat at its address, a run or a place in Dev, a project in
11
- // Dev, an agent in a new chat with it, a skill in Dev's Customize, an action
12
- // runs. Arrows move, Enter opens, Escape closes (@hanzo/ui's CommandMenu);
13
- // Keyboard shortcuts lists the app's keys, and Backspace on an empty field
14
- // comes back.
3
+ // The palette: ⌘K / Ctrl+K from anywhere toggles it, the sidebar's search opens
4
+ // it. Every place in the app can be jumped to from it and every action run.
15
5
  //
16
- // MOUNTED ONLY WHILE OPEN, and every list is read once as it opens, then
17
- // searched here as the reader types: the conversations store (hanzoai/agent)
18
- // answers its whole list and searches nothing, titles or words, so there is no
19
- // read per key to wait for or to cancel. The chats are the sidebar's last read
20
- // (thread.tsx `useThreadList`), drawn at once and read again behind it; the
21
- // projects (`/v1/projects`), the active skills (`/v1/tool/skills?activated=true`)
22
- // and the agents (crew.tsx) arrive while the reader types, and a read the
23
- // palette closes on is aborted.
6
+ // An empty field lists Recent (the reader's chats and Dev tasks, newest first,
7
+ // each with its kind's icon), Actions, and Go to: the products, the places of
8
+ // the app and the apps in beta the reader may open. A query searches all of it, deeper places too (Settings' sections,
9
+ // Customize's tabs, the Library's kinds, Studio's places, Help), and the
10
+ // reader's chats, Dev tasks, projects, agents, skills and connectors by name;
11
+ // each group keeps its best five and the group with the best answer leads
12
+ // (seek.ts `find`). A row opens where it lives; an action runs.
13
+ //
14
+ // GO TO IS DERIVED, never listed here: the places are @hanzo/build's own
15
+ // registries (`nav` for Dev's places, `sections` for Settings, `tabs` for
16
+ // Customize, `library` for the Library's kinds), @hanzo/studio's `nav`, and
17
+ // help.ts's pages, lib/betas.ts's granted apps: the same lists the sidebars and
18
+ // menus draw. A page added to
19
+ // any of them is in the palette with nothing written here.
20
+ //
21
+ // MOUNTED ONLY WHILE OPEN, and every list is read once as it opens and searched
22
+ // here as the reader types: the conversations store (hanzoai/agent) answers its
23
+ // whole list and searches nothing, titles or words, so a title is all there is
24
+ // to find a chat by. The chats are the sidebar's last read (thread.tsx
25
+ // `useThreadList`), drawn at once; the projects (`/v1/projects`), the active
26
+ // skills (`/v1/tool/skills?activated=true`), the connectors
27
+ // (`/v1/tool/mcp/servers`) and the agents (crew.tsx) arrive while the reader
28
+ // types, and a read the palette closes on is aborted.
24
29
  import { useEffect, useMemo, useState } from 'react';
25
- import { BookOpen, Bot, Boxes, Clock, Code2, FolderGit2, Keyboard, LayoutTemplate, Library, ListTodo, LogOut, MessageCircle, Puzzle, Server, Settings, SquarePen } from 'lucide-react';
30
+ import { BookOpen, Bot, Building2, CircleHelp, Clock, Code2, FlaskConical, FolderGit2, Gift, Keyboard, LogOut, MessageCircle, MessageSquare, Plug, Settings, Sparkles, SquarePen, SquareTerminal, SunMoon, Users, WandSparkles, } from 'lucide-react';
26
31
  import { useAi } from '@hanzo/ai/react';
27
- import { path as written, useSessions } from '@hanzo/build';
32
+ import { library, nav, NEW, path as written, sections, tabs, useSessions } from '@hanzo/build';
33
+ import { nav as made } from '@hanzo/studio';
28
34
  import { Dialog, DialogContent, DialogTitle } from '@hanzo/ui';
29
35
  import { CommandMenu } from '@hanzo/ui/product/CommandMenu';
30
36
  import { ordered } from './agents.js';
@@ -33,77 +39,170 @@ import { called } from './team.js';
33
39
  import { empty, openThread, withAgents } from './open.js';
34
40
  import { useThreadList } from './thread.js';
35
41
  import { useSignOut } from './lib/signout.js';
36
- import { app, talk } from './lib/host.js';
42
+ import { choose } from './lib/session.js';
43
+ import { app, studio, talk, TERMINALS, WORKSPACE } from './lib/host.js';
44
+ import { BETAS } from './lib/betas.js';
45
+ import { useBetas } from './Betas.js';
37
46
  import { tidy } from './lib/title.js';
38
- import { find, findIn } from './seek.js';
47
+ import { KEYS, legal, out, pages } from './help.js';
48
+ import { site } from './where.js';
49
+ import { find } from './seek.js';
39
50
  const ICON = { size: 16, 'aria-hidden': true };
40
- /** The app's keys, as Keyboard shortcuts lists them. */
41
- const KEYS = [
42
- { id: 'search', label: 'Search', shortcut: ['Mod', 'K'] },
43
- { id: 'settings', label: 'Settings', shortcut: ['Mod', ','] },
44
- { id: 'panel', label: 'Side panel', shortcut: ['Mod', '.'] },
45
- { id: 'rename', label: 'Rename chat', shortcut: ['Ctrl', 'Alt', 'R'] },
46
- { id: 'pin', label: 'Pin or unpin chat', shortcut: ['Ctrl', 'Alt', 'P'] },
47
- { id: 'back', label: 'Back, in the desktop app', shortcut: ['Alt', 'ArrowLeft'] },
48
- { id: 'forward', label: 'Forward, in the desktop app', shortcut: ['Alt', 'ArrowRight'] },
49
- ];
51
+ /** The builder's Settings, its first section. */
52
+ const SETTINGS = written({ kind: 'settings', section: 'general' });
50
53
  const SKILLS = written({ kind: 'customize', tab: 'skills' });
51
- /** Dev's places, as its sidebar lists them, by the builder address each opens. */
52
- const PLACES = [
53
- { id: 'templates', label: 'Templates', at: '-/templates', icon: _jsx(LayoutTemplate, { ...ICON }) },
54
- { id: 'projects', label: 'Projects', at: '-/projects', icon: _jsx(FolderGit2, { ...ICON }) },
55
- { id: 'issues', label: 'Issues', at: '-/issues', icon: _jsx(ListTodo, { ...ICON }) },
56
- { id: 'machines', label: 'Machines', at: '-/settings/machines', icon: _jsx(Server, { ...ICON }) },
57
- { id: 'environments', label: 'Environments', at: '-/settings/environments', icon: _jsx(Boxes, { ...ICON }) },
58
- { id: 'library', label: 'Library', at: '-/artifacts', icon: _jsx(Library, { ...ICON }) },
59
- { id: 'scheduled', label: 'Scheduled tasks', at: '-/automations', icon: _jsx(Clock, { ...ICON }) },
60
- { id: 'customize', label: 'Customize', at: '-/customize', icon: _jsx(Puzzle, { ...ICON }) },
61
- ];
54
+ const CONNECTORS = written({ kind: 'customize', tab: 'connectors' });
55
+ /** The words a Settings section is also asked for by: the account menu's names for two of them, and the obvious ones. */
56
+ const ALSO = {
57
+ general: ['personalization', 'appearance', 'theme'],
58
+ account: ['profile'],
59
+ privacy: ['security'],
60
+ billing: ['plans', 'subscription'],
61
+ members: ['organization', 'team'],
62
+ };
63
+ /** A registry's groups as rows, each named for where it lives. */
64
+ const rows = (groups, where, prefix) => groups
65
+ .flatMap((g) => g.links)
66
+ .map((l) => ({ id: `${prefix}-${l.id}`, label: l.label, icon: l.icon, where, keywords: [where, ...(ALSO[l.id] ?? [])].filter(Boolean), run: () => l.onPress?.() }));
67
+ /**
68
+ * Every place the reader can be taken: `top` is what an empty field lists, the
69
+ * products and the places of the app; `deep` is what only a query reaches.
70
+ * Without a host (hanzo.team's frame) the builder's registries are not there,
71
+ * and the products and the site's pages are.
72
+ */
73
+ export function places(host, go) {
74
+ const builder = (p) => go(app(p));
75
+ const top = [
76
+ { id: 'chat', label: 'Chat', icon: _jsx(MessageSquare, { ...ICON }), run: () => go(talk()) },
77
+ { id: 'dev', label: 'Dev', icon: _jsx(Code2, { ...ICON }), run: () => go(app('')) },
78
+ { id: 'studio', label: 'Studio', icon: _jsx(WandSparkles, { ...ICON }), run: () => go(studio('')) },
79
+ { id: 'tabs', label: 'Tabs', icon: _jsx(SquareTerminal, { ...ICON }), run: () => go(TERMINALS) },
80
+ ...(host ? [{ id: 'team', label: 'Team', icon: _jsx(Users, { ...ICON }), keywords: ['workspace'], run: () => out(WORKSPACE) }] : []),
81
+ ...(host ? rows(nav(host, builder).map((g) => ({ links: g.links.filter((l) => l.id !== NEW) })), '', 'dev') : []),
82
+ { id: 'settings', label: 'Settings', icon: _jsx(Settings, { ...ICON }), shortcut: ['Mod', ','], run: () => go(app(SETTINGS)) },
83
+ // The site's own pages, outside the app: the browser loads them.
84
+ { id: 'referral', label: 'Referral', icon: _jsx(Gift, { ...ICON }), keywords: ['invite', 'share'], run: () => window.location.assign(site('/referral')) },
85
+ { id: 'beta', label: 'Beta status', icon: _jsx(FlaskConical, { ...ICON }), keywords: ['waitlist'], run: () => window.location.assign(site('/beta/status')) },
86
+ ...pages()
87
+ .filter((p) => p.key === 'help')
88
+ .map((p) => ({ id: `help-${p.key}`, label: p.label, icon: _jsx(CircleHelp, { ...ICON }), keywords: ['support', 'docs'], run: () => out(p.href) })),
89
+ ];
90
+ const deep = [
91
+ ...(host
92
+ ? [
93
+ ...rows(sections(host, builder), 'Settings', 'settings'),
94
+ ...rows(tabs(host, builder), 'Customize', 'customize'),
95
+ ...rows(library(host, builder).slice(1), 'Library', 'library'),
96
+ ...rows(made(host, (p) => go(studio(p))), 'Studio', 'studio').filter((a) => a.id !== `studio-${NEW}`),
97
+ ]
98
+ : []),
99
+ ...[...pages(), ...legal()]
100
+ .filter((p) => p.key !== 'help')
101
+ .map((p) => ({ id: `help-${p.key}`, label: p.label, where: 'Help', keywords: ['Help'], run: () => out(p.href) })),
102
+ ];
103
+ // A place two registries hold (Machines is Dev's and a Settings section) is listed once, as Dev's.
104
+ return { top, deep: deep.filter((a) => !top.some((t) => t.id === a.id.replace(/^settings-/, 'dev-'))) };
105
+ }
106
+ /** The composer's model menu, opened as a press would: a page without a composer opens New chat's. */
107
+ function tune(go) {
108
+ const open = () => {
109
+ const chip = document.querySelector('[data-slot="mind"][aria-haspopup="menu"]');
110
+ chip?.focus();
111
+ chip?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
112
+ return !!chip;
113
+ };
114
+ let tries = 0;
115
+ const wait = () => {
116
+ if (!open() && ++tries < 120)
117
+ requestAnimationFrame(wait);
118
+ };
119
+ // After the palette has closed and handed focus back, so the menu keeps it.
120
+ setTimeout(() => {
121
+ if (!open()) {
122
+ go(talk());
123
+ requestAnimationFrame(wait);
124
+ }
125
+ }, 0);
126
+ }
127
+ /** The palette's actions; a host that cannot do one (no theme to change, no org to pick) is not offered it. */
128
+ export function actions(host, go, signOut, keys) {
129
+ const upload = host ? library(host, (p) => go(app(p)))[0]?.links.find((l) => l.id === 'upload') : undefined;
130
+ const list = [
131
+ { id: 'new-chat', label: 'New chat', icon: _jsx(SquarePen, { ...ICON }), run: () => (empty(), go(talk())) },
132
+ { id: 'new-task', label: 'New Dev task', icon: _jsx(Code2, { ...ICON }), keywords: ['run'], run: () => go(app('')) },
133
+ { id: 'new-creation', label: 'New Studio creation', icon: _jsx(WandSparkles, { ...ICON }), keywords: ['image', 'video'], run: () => go(studio('')) },
134
+ { id: 'new-scheduled', label: 'New scheduled task', icon: _jsx(Clock, { ...ICON }), keywords: ['automation'], run: () => go(app(written({ kind: 'automation', id: 'new' }))) },
135
+ !!upload && { id: 'upload', label: 'Upload', icon: upload.icon, keywords: ['file', 'library'], run: () => upload.onPress?.() },
136
+ { id: 'model', label: 'Switch model', icon: _jsx(Sparkles, { ...ICON }), keywords: ['model'], run: () => tune(go) },
137
+ !!host?.chooseTheme && {
138
+ id: 'theme',
139
+ label: 'Toggle theme',
140
+ icon: _jsx(SunMoon, { ...ICON }),
141
+ keywords: ['dark', 'light', 'appearance'],
142
+ run: () => host.chooseTheme?.(document.documentElement.classList.contains('dark') ? 'light' : 'dark'),
143
+ },
144
+ !!host && { id: 'org', label: 'Switch organization', icon: _jsx(Building2, { ...ICON }), keywords: ['org', 'team', 'workspace'], run: choose },
145
+ { id: 'keys', label: 'Keyboard shortcuts', icon: _jsx(Keyboard, { ...ICON }), stay: true, run: keys },
146
+ { id: 'logout', label: 'Log out', icon: _jsx(LogOut, { ...ICON }), keywords: ['sign out'], run: signOut },
147
+ ];
148
+ return list.filter((a) => !!a);
149
+ }
62
150
  /** A list read once as the palette opens; a refusal leaves it empty and the rest still answer. */
63
151
  function useRead(path, shape) {
64
152
  const ai = useAi();
65
- const [rows, setRows] = useState([]);
153
+ const [found, setFound] = useState([]);
66
154
  useEffect(() => {
67
155
  const stop = new AbortController();
68
- ai.http.json({ path, signal: stop.signal }).then((body) => setRows(shape(body)), () => { });
156
+ ai.http.json({ path, signal: stop.signal }).then((body) => setFound(shape(body)), () => { });
69
157
  return () => stop.abort();
70
158
  // `shape` is a module constant at each call.
71
159
  // eslint-disable-next-line react-hooks/exhaustive-deps
72
160
  }, [ai, path]);
73
- return rows;
161
+ return found;
74
162
  }
75
163
  const obj = (v) => (v && typeof v === 'object' ? v : {});
76
164
  const str = (v) => (typeof v === 'string' ? v : '');
77
- const rows = (v) => (Array.isArray(v) ? v : []);
165
+ const list = (v) => (Array.isArray(v) ? v : []);
78
166
  /** `/v1/projects`: a project by its slug, found by its name, slug and repository. */
79
- const projects = (body) => (Array.isArray(body) ? body : rows(obj(body).data))
167
+ const projects = (body) => (Array.isArray(body) ? body : list(obj(body).data))
80
168
  .map(obj)
81
169
  .filter((p) => str(p.slug))
82
170
  .map((p) => ({ id: str(p.slug), label: str(p.name) || str(p.slug), keywords: [str(p.slug), str(obj(p.repo).url)] }));
83
171
  /** `/v1/tool/skills?activated=true`: a skill by its name, its tool's less `skill_`. */
84
- const skills = (body) => rows(obj(body).tools)
172
+ const skills = (body) => list(obj(body).tools)
85
173
  .map(obj)
86
174
  .filter((t) => str(t.name))
87
175
  .map((t) => ({ id: str(t.name), label: str(t.name).replace(/^skill_/, ''), keywords: [str(t.description)] }));
88
- export function Palette({ onClose, go, actions = [], mode = 'chat', host, }) {
176
+ /** `/v1/tool/mcp/servers`: a connector by its name. */
177
+ const connectors = (body) => list(obj(body).servers)
178
+ .map(obj)
179
+ .filter((s) => str(s.id))
180
+ .map((s) => ({ id: str(s.id), label: str(s.name) || str(s.id), keywords: [str(s.url)] }));
181
+ export function Palette({ onClose, go, actions: frame = [], mode = 'chat', host, }) {
89
182
  const [query, setQuery] = useState('');
90
183
  const [keys, setKeys] = useState(false);
91
184
  const { threads } = useThreadList();
92
185
  const { agents } = useCrew();
93
- const made = useRead('/v1/projects', projects);
186
+ const repos = useRead('/v1/projects', projects);
94
187
  const known = useRead('/v1/tool/skills?activated=true', skills);
188
+ const plugged = useRead('/v1/tool/mcp/servers', connectors);
95
189
  const signOut = useSignOut();
96
190
  const runs = useSessions(host ?? null);
191
+ const { open: opened } = useBetas();
97
192
  const groups = useMemo(() => {
98
- const own = [
99
- { id: 'new-chat', label: 'New chat', icon: _jsx(SquarePen, { ...ICON }), run: () => (empty(), go(talk())) },
100
- { id: 'new-task', label: 'New Dev task', icon: _jsx(Code2, { ...ICON }), run: () => go(app('')) },
101
- { id: 'settings', label: 'Settings', icon: _jsx(Settings, { ...ICON }), shortcut: ['Mod', ','], run: () => go(app(written({ kind: 'settings', section: 'general' }))) },
102
- { id: 'customize', label: 'Customize', icon: _jsx(Puzzle, { ...ICON }), run: () => go(app(SKILLS)) },
103
- { id: 'keys', label: 'Keyboard shortcuts', icon: _jsx(Keyboard, { ...ICON }), stay: true, run: () => (setQuery(''), setKeys(true)) },
104
- { id: 'logout', label: 'Log out', icon: _jsx(LogOut, { ...ICON }), run: () => void signOut() },
105
- ];
106
- const doing = [...own.map((a) => actions.find((b) => b.id === a.id) ?? a), ...actions.filter((b) => !own.some((a) => a.id === b.id))];
193
+ const { top, deep } = places(host, go);
194
+ const swap = (a) => frame.find((b) => b.id === a.id) ?? a;
195
+ // The apps in beta this reader may open, each where it lives (the rail's More).
196
+ const granted = BETAS.filter((b) => b.href && opened(b.id)).map((b) => ({
197
+ id: `beta-${b.id}`,
198
+ label: b.name,
199
+ icon: _jsx(Sparkles, { ...ICON }),
200
+ where: 'Beta',
201
+ keywords: ['beta', b.line],
202
+ run: () => out(b.href),
203
+ }));
204
+ const here = [...top.map(swap), ...frame.filter((b) => !top.some((a) => a.id === b.id)), ...granted];
205
+ const doing = actions(host, go, () => void signOut(), () => (setQuery(''), setKeys(true)));
107
206
  const chats = threads.map((t) => ({
108
207
  id: `chat-${t.id}`,
109
208
  label: tidy(t.title),
@@ -111,47 +210,49 @@ export function Palette({ onClose, go, actions = [], mode = 'chat', host, }) {
111
210
  icon: _jsx(MessageCircle, { ...ICON }),
112
211
  run: () => (openThread(t.id), go(talk(t.id))),
113
212
  }));
114
- const projectRows = { id: 'projects', label: 'Projects', items: made.map((p) => ({ ...p, id: `project-${p.id}`, icon: _jsx(FolderGit2, { ...ICON }), run: () => go(app(p.id)) })) };
115
- const skillRows = { id: 'skills', label: 'Skills', items: known.map((s) => ({ ...s, id: `skill-${s.id}`, icon: _jsx(BookOpen, { ...ICON }), run: () => go(app(SKILLS)) })) };
116
- const actionRows = { id: 'actions', label: 'Actions', items: doing };
117
- const shown = mode === 'dev'
118
- ? findIn(query, {
119
- label: 'Recent runs',
120
- items: [...(runs?.value ?? [])].map((s) => ({
121
- id: `run-${s.id}`,
122
- label: tidy(s.title, 80, 'Untitled run'),
123
- at: s.updatedAt || s.createdAt,
124
- keywords: [s.status ?? ''],
125
- icon: _jsx(Code2, { ...ICON }),
126
- run: () => go(app(s.id)),
127
- })),
128
- }, [
129
- { id: 'runs', label: 'Runs', items: [...(runs?.value ?? [])].map((s) => ({ id: `run-${s.id}`, label: tidy(s.title, 80, 'Untitled run'), at: s.updatedAt || s.createdAt, keywords: [s.status ?? ''], icon: _jsx(Code2, { ...ICON }), run: () => go(app(s.id)) })) },
130
- { id: 'places', label: 'Dev', items: PLACES.map((p) => ({ id: `place-${p.id}`, label: p.label, icon: p.icon, run: () => go(app(p.at)) })) },
131
- projectRows,
132
- skillRows,
133
- actionRows,
213
+ const tasks = (runs?.value ?? []).map((s) => ({
214
+ id: `run-${s.id}`,
215
+ label: tidy(s.title, 80, 'Untitled run'),
216
+ at: s.updatedAt || s.createdAt,
217
+ keywords: [s.status ?? ''],
218
+ icon: _jsx(Code2, { ...ICON }),
219
+ run: () => go(app(s.id)),
220
+ }));
221
+ const mine = mode === 'dev'
222
+ ? [
223
+ { id: 'runs', label: 'Dev tasks', items: tasks },
134
224
  { id: 'chats', label: 'Chats', items: chats },
135
- ])
136
- : find(query, chats, [
137
- projectRows,
138
- {
139
- id: 'agents',
140
- label: 'Agents',
141
- items: ordered(agents).map((one) => {
142
- const name = called(one.name);
143
- return { id: `agent-${one.id || one.name}`, label: name, icon: _jsx(Bot, { ...ICON }), run: () => (withAgents([name]), go(talk())) };
144
- }),
145
- },
146
- skillRows,
147
- actionRows,
148
- ]);
225
+ ]
226
+ : [
227
+ { id: 'chats', label: 'Chats', items: chats },
228
+ { id: 'runs', label: 'Dev tasks', items: tasks },
229
+ ];
230
+ const shown = find(query, [...chats, ...tasks], [
231
+ { id: 'actions', label: 'Actions', items: doing },
232
+ { id: 'go', label: 'Go to', items: here },
233
+ ], [
234
+ { id: 'go', label: 'Go to', items: [...here, ...deep] },
235
+ { id: 'actions', label: 'Actions', items: doing },
236
+ ...mine,
237
+ { id: 'projects', label: 'Projects', items: repos.map((p) => ({ ...p, id: `project-${p.id}`, icon: _jsx(FolderGit2, { ...ICON }), run: () => go(app(p.id)) })) },
238
+ {
239
+ id: 'agents',
240
+ label: 'Agents',
241
+ items: ordered(agents).map((one) => {
242
+ const name = called(one.name);
243
+ return { id: `agent-${one.id || one.name}`, label: name, icon: _jsx(Bot, { ...ICON }), run: () => (withAgents([name]), go(talk())) };
244
+ }),
245
+ },
246
+ { id: 'skills', label: 'Skills', items: known.map((s) => ({ ...s, id: `skill-${s.id}`, icon: _jsx(BookOpen, { ...ICON }), run: () => go(app(SKILLS)) })) },
247
+ { id: 'connectors', label: 'Connectors', items: plugged.map((c) => ({ ...c, id: `connector-${c.id}`, icon: _jsx(Plug, { ...ICON }), run: () => go(app(CONNECTORS)) })) },
248
+ ]);
149
249
  return shown.map((g) => ({
150
250
  ...g,
151
251
  items: g.items.map((a) => ({
152
252
  id: a.id,
153
253
  label: a.label,
154
254
  icon: a.icon,
255
+ contextual: a.where || undefined,
155
256
  shortcut: a.shortcut,
156
257
  // The palette ranked these against the query; the menu's own filter passes what it kept.
157
258
  keywords: [query],
@@ -162,9 +263,12 @@ export function Palette({ onClose, go, actions = [], mode = 'chat', host, }) {
162
263
  },
163
264
  })),
164
265
  }));
165
- }, [query, threads, agents, made, known, actions, go, onClose, signOut, mode, runs]);
266
+ }, [query, threads, agents, repos, known, plugged, frame, go, onClose, signOut, mode, runs, host, opened]);
267
+ const apple = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.userAgent);
166
268
  const listed = keys
167
- ? [{ id: 'keys', label: 'Keyboard shortcuts', items: KEYS.map((k) => ({ ...k, id: `key-${k.id}`, onSelect: () => setKeys(false) })) }]
269
+ ? [{ id: 'keys', label: 'Keyboard shortcuts', items: KEYS.map((k) => ({ id: `key-${k.does}`, label: k.does, contextual: apple ? k.mac : k.pc, onSelect: () => setKeys(false) })) }]
168
270
  : groups;
169
- return (_jsx(Dialog, { open: true, onOpenChange: (open) => (open ? null : onClose()), children: _jsxs(DialogContent, { "data-slot": "palette", p: 0, gap: 0, width: "100%", maxW: 680, height: "min(520px, calc(100dvh - 32px))", rounded: "$6", overflow: "hidden", "$max-md": { width: '100vw', maxW: '100vw', height: '100dvh', rounded: 0, borderWidth: 0 }, children: [_jsx(DialogTitle, { position: "absolute", width: 1, height: 1, overflow: "hidden", opacity: 0, children: "Search" }), _jsx(CommandMenu, { groups: listed, query: query, onQueryChange: setQuery, placeholder: "Search\u2026", label: "Search", empty: keys ? 'No such key.' : query.trim() ? `Nothing matches “${query.trim()}”.` : mode === 'dev' ? 'No runs yet.' : 'No chats yet.', onBack: keys ? () => setKeys(false) : undefined, onClose: onClose })] }) }));
271
+ return (_jsx(Dialog, { open: true, onOpenChange: (open) => (open ? null : onClose()), children: _jsxs(DialogContent, { "data-slot": "palette", showCloseButton: false, p: 0, gap: 0, width: "100%", maxW: 640,
272
+ // As tall as its rows, under a fixed top, so typing never moves the field.
273
+ maxH: "min(520px, 76dvh)", mt: "12dvh", mb: "auto", rounded: "$6", overflow: "hidden", "$max-md": { width: '100vw', maxW: '100vw', height: '100dvh', maxH: '100dvh', mt: 0, mb: 0, rounded: 0, borderWidth: 0 }, children: [_jsx(DialogTitle, { position: "absolute", width: 1, height: 1, overflow: "hidden", opacity: 0, children: "Search" }), _jsx(CommandMenu, { groups: listed, query: query, onQueryChange: setQuery, placeholder: "Search or jump to\u2026", label: "Search", empty: keys ? 'No such key.' : `Nothing matches “${query.trim()}”.`, onBack: keys ? () => setKeys(false) : undefined, onClose: onClose })] }) }));
170
274
  }
package/dist/Rail.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ReactNode } from 'react';
1
+ import { type ReactNode } from 'react';
2
2
  import { type Host } from '@hanzo/build';
3
3
  import { type Place } from './lib/host.js';
4
4
  /** The rail's width, px. */
package/dist/Rail.js CHANGED
@@ -2,7 +2,9 @@
2
2
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  // The app's rail: its products as marks down the window's left edge, in one
4
4
  // order — Chat, Dev, Studio, Tabs, Library, Scheduled tasks, Customize — then
5
- // More (Team, on a host of its own, never in Studio), and the account at the foot. It
5
+ // More (Team, on a host of its own, then the apps in beta this reader may open
6
+ // and Betas, the list of every app in beta; none of it in Studio), and the
7
+ // account at the foot. It
6
8
  // is how the app moves between its products; a phone, which has no rail,
7
9
  // switches Chat and Dev in its bar (Top.tsx).
8
10
  //
@@ -26,11 +28,14 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
26
28
  // rail lies along the foot of the drawer (`flat`), the account at its end; the
27
29
  // drawer is too narrow for every mark, so Library, Scheduled tasks and
28
30
  // Customize lead More there.
29
- import { Clock, Code2, Ellipsis, Library, MessageSquare, PanelLeft, Puzzle, SquareTerminal, Users, WandSparkles } from 'lucide-react';
31
+ import { Clock, Code2, Ellipsis, FlaskConical, Library, MessageSquare, PanelLeft, Puzzle, Sparkles, SquareTerminal, Users, WandSparkles } from 'lucide-react';
32
+ import { useState } from 'react';
30
33
  import { XStack, YStack } from '@hanzo/gui';
31
34
  import { route } from '@hanzo/build';
32
35
  import { DropdownMenu } from '@hanzo/ui';
36
+ import { BetasDialog, useBetas } from './Betas.js';
33
37
  import { out } from './help.js';
38
+ import { BETAS } from './lib/betas.js';
34
39
  import { Me } from './Me.js';
35
40
  import { app, WORKSPACE } from './lib/host.js';
36
41
  /** The rail's width, px. */
@@ -70,6 +75,10 @@ export function Rail({ host, mode, at, open, toggle, flat = false, onChat, onDev
70
75
  // The products on hosts of their own, behind More; on a phone the raised places lead it.
71
76
  // Studio offers no Team, so on a laptop it draws no More.
72
77
  const team = mode !== 'studio';
78
+ const { open: opened } = useBetas();
79
+ const [betas, setBetas] = useState(false);
80
+ // The apps in beta this reader may open, each where it lives.
81
+ const granted = BETAS.filter((b) => b.href && opened(b.id));
73
82
  const rest = [
74
83
  ...(flat
75
84
  ? [
@@ -79,8 +88,15 @@ export function Rail({ host, mode, at, open, toggle, flat = false, onChat, onDev
79
88
  ...(team ? [{ type: 'separator', key: 'raised' }] : []),
80
89
  ]
81
90
  : []),
82
- ...(team ? [{ key: 'team', label: 'Team', icon: _jsx(Users, { size: 16, "aria-hidden": true }), onSelect: () => out(WORKSPACE) }] : []),
91
+ ...(team
92
+ ? [
93
+ { key: 'team', label: 'Team', icon: _jsx(Users, { size: 16, "aria-hidden": true }), onSelect: () => out(WORKSPACE) },
94
+ ...granted.map((b) => ({ key: `beta-${b.id}`, label: b.name, icon: _jsx(Sparkles, { size: 16, "aria-hidden": true }), onSelect: () => out(b.href) })),
95
+ { type: 'separator', key: 'betas-rule' },
96
+ { key: 'betas', label: 'Betas', icon: _jsx(FlaskConical, { size: 16, "aria-hidden": true }), onSelect: () => setBetas(true) },
97
+ ]
98
+ : []),
83
99
  ];
84
100
  const Stack = flat ? XStack : YStack;
85
- return (_jsxs(Stack, { "data-slot": "rail", render: "nav", "aria-label": "Products", ...(flat ? { pt: '$2', mt: '$2', borderTopWidth: 1, borderColor: '$borderColor', gap: '$1' } : { width: RAIL, flex: 1, pt: '$2', pb: '$2', gap: 8 }), shrink: 0, items: "center", children: [toggle ? (_jsx(YStack, { pb: 8, children: _jsx(Toggle, { open: open, onPress: toggle }) })) : null, _jsx(Mark, { label: "Chat", on: mode === 'chat', onPress: onChat, "data-slot": "rail-chat", children: _jsx(MessageSquare, { size: 18, "aria-hidden": true }) }), _jsx(Mark, { label: "Dev", on: mode === 'dev' && !here, onPress: onDev, "data-slot": "rail-dev", children: _jsx(Code2, { size: 18, "aria-hidden": true }) }), _jsx(Mark, { label: "Studio", on: mode === 'studio', onPress: onStudio, "data-slot": "rail-studio", children: _jsx(WandSparkles, { size: 18, "aria-hidden": true }) }), _jsx(Mark, { label: "Tabs", on: mode === 'tabs', onPress: onTabs, "data-slot": "rail-tabs", children: _jsx(SquareTerminal, { size: 18, "aria-hidden": true }) }), flat ? null : (_jsxs(_Fragment, { children: [_jsx(Mark, { label: "Library", on: here === 'library', onPress: to(RAISED.library), "data-slot": "rail-library", children: _jsx(Library, { size: 18, "aria-hidden": true }) }), _jsx(Mark, { label: "Scheduled tasks", on: here === 'scheduled', onPress: to(RAISED.scheduled), "data-slot": "rail-scheduled", children: _jsx(Clock, { size: 18, "aria-hidden": true }) }), _jsx(Mark, { label: "Customize", on: here === 'customize', onPress: to(RAISED.customize), "data-slot": "rail-customize", children: _jsx(Puzzle, { size: 18, "aria-hidden": true }) })] })), rest.length ? (_jsx(DropdownMenu, { placement: flat ? 'top-start' : 'right-start', minWidth: 220, trigger: _jsx(XStack, { render: "button", "aria-label": "More", ...{ title: 'More' }, "data-slot": "rail-more", width: MARK, height: MARK, rounded: "$4", items: "center", justify: "center", cursor: "pointer", opacity: 0.62, hoverStyle: { bg: '$hover', opacity: 1 }, pressStyle: { bg: '$raised' }, focusVisibleStyle: { outlineWidth: 2, outlineStyle: 'solid', outlineColor: '$outlineColor' }, $touchable: { width: 44, height: 44 }, children: _jsx(Ellipsis, { size: 18, "aria-hidden": true }) }), items: rest })) : null, _jsx(YStack, { flex: 1 }), _jsx(Me, { host: host, narrow: true, above: flat, onOrg: onOrg })] }));
101
+ return (_jsxs(Stack, { "data-slot": "rail", render: "nav", "aria-label": "Products", ...(flat ? { pt: '$2', mt: '$2', borderTopWidth: 1, borderColor: '$borderColor', gap: '$1' } : { width: RAIL, flex: 1, pt: '$2', pb: '$2', gap: 8 }), shrink: 0, items: "center", children: [toggle ? (_jsx(YStack, { pb: 8, children: _jsx(Toggle, { open: open, onPress: toggle }) })) : null, _jsx(Mark, { label: "Chat", on: mode === 'chat', onPress: onChat, "data-slot": "rail-chat", children: _jsx(MessageSquare, { size: 18, "aria-hidden": true }) }), _jsx(Mark, { label: "Dev", on: mode === 'dev' && !here, onPress: onDev, "data-slot": "rail-dev", children: _jsx(Code2, { size: 18, "aria-hidden": true }) }), _jsx(Mark, { label: "Studio", on: mode === 'studio', onPress: onStudio, "data-slot": "rail-studio", children: _jsx(WandSparkles, { size: 18, "aria-hidden": true }) }), _jsx(Mark, { label: "Tabs", on: mode === 'tabs', onPress: onTabs, "data-slot": "rail-tabs", children: _jsx(SquareTerminal, { size: 18, "aria-hidden": true }) }), flat ? null : (_jsxs(_Fragment, { children: [_jsx(Mark, { label: "Library", on: here === 'library', onPress: to(RAISED.library), "data-slot": "rail-library", children: _jsx(Library, { size: 18, "aria-hidden": true }) }), _jsx(Mark, { label: "Scheduled tasks", on: here === 'scheduled', onPress: to(RAISED.scheduled), "data-slot": "rail-scheduled", children: _jsx(Clock, { size: 18, "aria-hidden": true }) }), _jsx(Mark, { label: "Customize", on: here === 'customize', onPress: to(RAISED.customize), "data-slot": "rail-customize", children: _jsx(Puzzle, { size: 18, "aria-hidden": true }) })] })), _jsx(BetasDialog, { open: betas, onOpenChange: setBetas, go: out }), rest.length ? (_jsx(DropdownMenu, { placement: flat ? 'top-start' : 'right-start', minWidth: 220, trigger: _jsx(XStack, { render: "button", "aria-label": "More", ...{ title: 'More' }, "data-slot": "rail-more", width: MARK, height: MARK, rounded: "$4", items: "center", justify: "center", cursor: "pointer", opacity: 0.62, hoverStyle: { bg: '$hover', opacity: 1 }, pressStyle: { bg: '$raised' }, focusVisibleStyle: { outlineWidth: 2, outlineStyle: 'solid', outlineColor: '$outlineColor' }, $touchable: { width: 44, height: 44 }, children: _jsx(Ellipsis, { size: 18, "aria-hidden": true }) }), items: rest })) : null, _jsx(YStack, { flex: 1 }), _jsx(Me, { host: host, narrow: true, above: flat, onOrg: onOrg })] }));
86
102
  }
package/dist/Shell.js CHANGED
@@ -1069,7 +1069,7 @@ function Frame({ mode, children, navigate, back, forward, }) {
1069
1069
  const shortcut = (event) => {
1070
1070
  if (summons(event)) {
1071
1071
  event.preventDefault();
1072
- setFinding(true);
1072
+ setFinding((open) => !open);
1073
1073
  }
1074
1074
  };
1075
1075
  window.addEventListener("keydown", shortcut);
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The apps in beta: every Hanzo app that exists or is announced and that the
3
+ * Hanzo app does not offer everyone yet, once, for every list that names them —
4
+ * the app's Betas list (Betas.tsx) and hanzo.ai/beta's.
5
+ *
6
+ * Each is a principal switch on the cloud, `beta.<id>` (hanzo-inc/cloud
7
+ * apps/flags betas.go carries the same ids), decided per caller: a SuperAdmin
8
+ * grants one to a person from their CRM record. `opens` is the one predicate a
9
+ * caller asks of that answer.
10
+ *
11
+ * `href` is where the app opens once granted: its own host, or its page in the
12
+ * page on the developer platform. An app with none is not built yet as an app; asking for it says you
13
+ * want it.
14
+ *
15
+ * Only what no one has today: nothing hanzo.ai or platform.hanzo.ai already
16
+ * offers every user is listed here, so no switch hides anything a person has.
17
+ */
18
+ export declare const GROUPS: readonly ['Business', 'Data', 'Agents', 'Build & run', 'Security', 'Creative'];
19
+ export type Group = (typeof GROUPS)[number];
20
+ export interface Beta {
21
+ /** Stable, lowercase, one word: the switch is `beta.<id>`. */
22
+ id: string;
23
+ name: string;
24
+ /** What it is, in one line. */
25
+ line: string;
26
+ group: Group;
27
+ /** Where it opens once granted; absent while it is not built as an app. */
28
+ href?: string;
29
+ }
30
+ export declare const BETAS: readonly Beta[];
31
+ /** The switch of one app in beta. */
32
+ export declare const key: (id: string) => string;
33
+ /**
34
+ * THE predicate: whether a reader may open the app `id`. `flags` is what
35
+ * GET /v1/flag/platform answered for them; `sudo` is a SuperAdmin (who sees every
36
+ * app) or a page served off a developer's own machine. Nothing else opens one.
37
+ */
38
+ export declare const opens: (id: string, flags: Record<string, boolean>, sudo: boolean) => boolean;
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The apps in beta: every Hanzo app that exists or is announced and that the
3
+ * Hanzo app does not offer everyone yet, once, for every list that names them —
4
+ * the app's Betas list (Betas.tsx) and hanzo.ai/beta's.
5
+ *
6
+ * Each is a principal switch on the cloud, `beta.<id>` (hanzo-inc/cloud
7
+ * apps/flags betas.go carries the same ids), decided per caller: a SuperAdmin
8
+ * grants one to a person from their CRM record. `opens` is the one predicate a
9
+ * caller asks of that answer.
10
+ *
11
+ * `href` is where the app opens once granted: its own host, or its page in the
12
+ * page on the developer platform. An app with none is not built yet as an app; asking for it says you
13
+ * want it.
14
+ *
15
+ * Only what no one has today: nothing hanzo.ai or platform.hanzo.ai already
16
+ * offers every user is listed here, so no switch hides anything a person has.
17
+ */
18
+ export const GROUPS = ['Business', 'Data', 'Agents', 'Build & run', 'Security', 'Creative'];
19
+ /** The developer platform, where a module in beta opens at its own address for anyone who has it. */
20
+ const PLATFORM = 'https://platform.hanzo.ai';
21
+ export const BETAS = [
22
+ { id: 'crm', name: 'CRM', line: 'Companies, contacts and deals, per org', group: 'Business', href: `${PLATFORM}/crm` },
23
+ { id: 'erp', name: 'ERP', line: 'Items, sales, purchasing, accounting and HR', group: 'Business', href: `${PLATFORM}/erp` },
24
+ { id: 'commerce', name: 'Commerce', line: 'Catalog, cart, checkout and orders', group: 'Business', href: 'https://commerce-admin.hanzo.ai/admin' },
25
+ { id: 'books', name: 'Books', line: 'Chart of accounts, ledger and bank reconciliation', group: 'Business' },
26
+ { id: 'finance', name: 'Finance', line: 'One ledger for your money operations', group: 'Business', href: 'https://finance.hanzo.ai' },
27
+ { id: 'treasury', name: 'Treasury & Ledger', line: 'Reserves, payouts and a double-entry journal', group: 'Business' },
28
+ { id: 'dataroom', name: 'Data Room', line: 'Share documents and see who opened them', group: 'Business', href: 'https://dataroom.hanzo.ai' },
29
+ { id: 'sign', name: 'Sign', line: 'Send a PDF, get it signed, keep the record', group: 'Business', href: 'https://esign.hanzo.ai' },
30
+ { id: 'company', name: 'Company', line: 'Formation, documents and filings', group: 'Business', href: `${PLATFORM}/company` },
31
+ { id: 'cms', name: 'CMS', line: 'Pages, posts, media and navigation', group: 'Business', href: 'https://cms.hanzo.ai/admin' },
32
+ { id: 'helpdesk', name: 'Help Center', line: 'Tickets, teams and SLAs', group: 'Business', href: 'https://help.hanzo.ai' },
33
+ { id: 'marketing', name: 'Marketing', line: 'Campaigns, audiences and email sequences', group: 'Business', href: 'https://marketing.hanzo.ai' },
34
+ { id: 'ads', name: 'Ads', line: 'Paid campaigns, launched and paused in one place', group: 'Business', href: 'https://ads.hanzo.ai' },
35
+ { id: 'social', name: 'Social', line: 'Schedule posts across your social accounts', group: 'Business', href: 'https://social.hanzo.ai' },
36
+ { id: 'tel', name: 'Telecom', line: 'Phone numbers, calls and messages', group: 'Business', href: `${PLATFORM}/tel` },
37
+ { id: 'risk', name: 'Risk', line: 'AML monitoring and fraud on one engine', group: 'Business', href: 'https://aml.hanzo.ai' },
38
+ { id: 'idv', name: 'Identity Verification', line: 'KYC, KYB and AML vendors behind one interface', group: 'Business' },
39
+ { id: 'base', name: 'Base', line: 'Data, users, files and live updates per org', group: 'Data', href: 'https://base.hanzo.ai' },
40
+ { id: 'analytics', name: 'Analytics', line: 'Privacy-first product analytics', group: 'Data', href: 'https://analytics.hanzo.ai' },
41
+ { id: 'insights', name: 'Insights', line: 'Funnels, session replay, flags and experiments', group: 'Data', href: 'https://insights.hanzo.ai' },
42
+ { id: 'search', name: 'Search', line: 'Keyword and meaning in one query', group: 'Data', href: `${PLATFORM}/search` },
43
+ { id: 'world', name: 'World', line: 'A live map of every model and AI workload', group: 'Data', href: 'https://world.hanzo.ai' },
44
+ { id: 'crawl', name: 'Crawl', line: 'Turn a website into clean Markdown', group: 'Data', href: `${PLATFORM}/crawl` },
45
+ { id: 'operative', name: 'Operative', line: 'A desktop in a container, driven by a model', group: 'Agents' },
46
+ { id: 'tasks', name: 'Tasks', line: 'Durable execution for long-running work', group: 'Agents', href: `${PLATFORM}/tasks` },
47
+ { id: 'functions', name: 'Functions', line: 'Serverless functions, no servers to run', group: 'Build & run', href: `${PLATFORM}/functions` },
48
+ { id: 'visor', name: 'Visor', line: 'Provision and watch VMs and bare metal', group: 'Build & run', href: 'https://visor.hanzo.ai' },
49
+ { id: 'edge', name: 'Edge', line: 'Compute close to your users', group: 'Build & run', href: `${PLATFORM}/edge` },
50
+ { id: 'registry', name: 'Registry', line: 'A container registry signed in with Hanzo', group: 'Build & run', href: `${PLATFORM}/registry` },
51
+ { id: 'ci', name: 'CI', line: 'From source to a signed image', group: 'Build & run', href: 'https://ci.hanzo.ai' },
52
+ { id: 'kms', name: 'KMS', line: 'Secrets held per org, never in code', group: 'Security', href: 'https://kms.hanzo.ai' },
53
+ { id: 'guard', name: 'Guard', line: 'Stops prompt injection and leaks in model calls', group: 'Security' },
54
+ { id: 'sentinel', name: 'Sentinel', line: 'Errors and slow requests, caught early', group: 'Security', href: 'https://sentry.hanzo.ai' },
55
+ { id: 'authz', name: 'Authz', line: 'Fine-grained authorization, Zanzibar style', group: 'Security', href: `${PLATFORM}/authz` },
56
+ { id: 'hsm', name: 'HSM', line: 'Hardware-backed keys across cloud KMSs', group: 'Security', href: `${PLATFORM}/hsm` },
57
+ { id: 'oto', name: 'Oto', line: 'Sound effects and Foley for film and games', group: 'Creative' },
58
+ ];
59
+ /** The switch of one app in beta. */
60
+ export const key = (id) => `beta.${id}`;
61
+ /**
62
+ * THE predicate: whether a reader may open the app `id`. `flags` is what
63
+ * GET /v1/flag/platform answered for them; `sudo` is a SuperAdmin (who sees every
64
+ * app) or a page served off a developer's own machine. Nothing else opens one.
65
+ */
66
+ export const opens = (id, flags, sudo) => sudo || flags[key(id)] === true;
@@ -153,12 +153,14 @@ export declare function home(): string | null;
153
153
  */
154
154
  export declare function role(o: string | null): string | null;
155
155
  /**
156
- * Who the token names — its display name and email — or null. The IAM user says
156
+ * Who the token names — its display name (the username when it has none; `display`
157
+ * is the display name alone) and email — or null. The IAM user says
157
158
  * the same once userinfo answers; this is the answer before it, so a signed-in
158
159
  * reader is never drawn as a stranger while it is asked.
159
160
  */
160
161
  export declare function named(): {
161
162
  name: string;
163
+ display: string;
162
164
  email: string;
163
165
  } | null;
164
166
  /**
@@ -385,7 +385,8 @@ export function role(o) {
385
385
  return null;
386
386
  }
387
387
  /**
388
- * Who the token names — its display name and email — or null. The IAM user says
388
+ * Who the token names — its display name (the username when it has none; `display`
389
+ * is the display name alone) and email — or null. The IAM user says
389
390
  * the same once userinfo answers; this is the answer before it, so a signed-in
390
391
  * reader is never drawn as a stranger while it is asked.
391
392
  */
@@ -394,7 +395,7 @@ export function named() {
394
395
  if (!c)
395
396
  return null;
396
397
  const text = (k) => (typeof c[k] === 'string' ? c[k] : '');
397
- return { name: text('displayName') || text('name'), email: text('email') };
398
+ return { name: text('displayName') || text('name'), display: text('displayName'), email: text('email') };
398
399
  }
399
400
  /**
400
401
  * The reader's account as IAM keys a membership: `<home org>/<username>`, or null.
@@ -5,3 +5,5 @@
5
5
  * characters with an ellipsis; `fallback` when nothing readable is left.
6
6
  */
7
7
  export declare function tidy(title: string | null | undefined, max?: number, fallback?: string): string;
8
+ /** A name's first word, its first letter capitalised ("z" → "Z", "ada lovelace" → "Ada"), or '' for none. */
9
+ export declare function given(name: string): string;
package/dist/lib/title.js CHANGED
@@ -34,3 +34,8 @@ export function tidy(title, max = 80, fallback = 'Untitled') {
34
34
  return fallback;
35
35
  return t.length > max ? `${t.slice(0, max - 1).trimEnd()}…` : t;
36
36
  }
37
+ /** A name's first word, its first letter capitalised ("z" → "Z", "ada lovelace" → "Ada"), or '' for none. */
38
+ export function given(name) {
39
+ const w = name.trim().split(/\s+/)[0] ?? '';
40
+ return w ? w[0].toUpperCase() + w.slice(1) : '';
41
+ }
package/dist/seek.d.ts CHANGED
@@ -15,32 +15,25 @@ export interface Group<T extends Hit = Hit> {
15
15
  }
16
16
  /** The most rows one group shows for a query. */
17
17
  export declare const CAP = 5;
18
- /** The chats an empty field lists. */
19
- export declare const RECENT = 8;
18
+ /** The rows Recent lists on an empty field. */
19
+ export declare const RECENT = 6;
20
20
  /**
21
21
  * How well a row answers a query: 0 drops it, more ranks it higher. Every word
22
22
  * of the query must be found, in the label or the row's other words; each counts
23
23
  * most where the label starts with it, then where a word of the label does, then
24
- * anywhere in the label, then in the other words alone. The label read whole
25
- * outranks everything.
24
+ * anywhere in the label, then in the other words, then spelled out in order
25
+ * within a word of the label (a typo's fuzz). The label read whole outranks
26
+ * everything.
26
27
  */
27
28
  export declare function fit(row: Hit, query: string): number;
28
29
  /** The rows a query keeps, best first, ties in the order they came, at most `cap`. */
29
30
  export declare function rank<T extends Hit>(rows: T[], query: string, cap?: number): T[];
30
31
  /**
31
- * What the palette lists. An empty field: the newest chats, under Recent chats.
32
- * A query: Chats, then each other group in its order, each cut to its best
33
- * `CAP`, a group with nothing left out.
32
+ * What the palette lists. An empty field: the newest `RECENT` of `recent` under
33
+ * Recent, then `idle` as it is. A query: every group of `all` cut to its best
34
+ * `CAP`, a group with nothing left out, and the group with the best answer
35
+ * first, ties in the order given.
34
36
  */
35
- export declare function find<T extends Hit>(query: string, chats: T[], groups: Group<T>[]): Group<T>[];
36
- /**
37
- * The same search for any product: an empty field lists the newest `RECENT` of
38
- * `recent` under its label (a product's own recents: Chat's conversations, Dev's
39
- * runs), and a query ranks every group in the order given.
40
- */
41
- export declare function findIn<T extends Hit>(query: string, recent: {
42
- label: string;
43
- items: T[];
44
- }, groups: Group<T>[]): Group<T>[];
37
+ export declare function find<T extends Hit>(query: string, recent: T[], idle: Group<T>[], all: Group<T>[]): Group<T>[];
45
38
  /** Whether a key press opens the palette: ⌘K or Ctrl+K, once a press, from anywhere, a field included. */
46
39
  export declare const summons: (e: Press) => boolean;
package/dist/seek.js CHANGED
@@ -3,14 +3,23 @@
3
3
  // so a spec reads them as they are.
4
4
  /** The most rows one group shows for a query. */
5
5
  export const CAP = 5;
6
- /** The chats an empty field lists. */
7
- export const RECENT = 8;
6
+ /** The rows Recent lists on an empty field. */
7
+ export const RECENT = 6;
8
+ /** Whether `term`'s letters stand in `word` in order: "sttngs" in "settings". */
9
+ const spelled = (word, term) => {
10
+ let i = 0;
11
+ for (const c of word)
12
+ if (c === term[i])
13
+ i++;
14
+ return i === term.length;
15
+ };
8
16
  /**
9
17
  * How well a row answers a query: 0 drops it, more ranks it higher. Every word
10
18
  * of the query must be found, in the label or the row's other words; each counts
11
19
  * most where the label starts with it, then where a word of the label does, then
12
- * anywhere in the label, then in the other words alone. The label read whole
13
- * outranks everything.
20
+ * anywhere in the label, then in the other words, then spelled out in order
21
+ * within a word of the label (a typo's fuzz). The label read whole outranks
22
+ * everything.
14
23
  */
15
24
  export function fit(row, query) {
16
25
  const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
@@ -21,7 +30,17 @@ export function fit(row, query) {
21
30
  const more = (row.keywords ?? []).join(' ').toLowerCase();
22
31
  let total = label === terms.join(' ') ? 10 : 0;
23
32
  for (const t of terms) {
24
- const s = label.startsWith(t) ? 4 : words.some((w) => w.startsWith(t)) ? 3 : label.includes(t) ? 2 : more.includes(t) ? 1 : 0;
33
+ const s = label.startsWith(t)
34
+ ? 4
35
+ : words.some((w) => w.startsWith(t))
36
+ ? 3
37
+ : label.includes(t)
38
+ ? 2
39
+ : more.includes(t)
40
+ ? 1
41
+ : t.length > 2 && words.some((w) => w[0] === t[0] && spelled(w, t))
42
+ ? 0.5
43
+ : 0;
25
44
  if (!s)
26
45
  return 0;
27
46
  total += s;
@@ -38,24 +57,22 @@ export function rank(rows, query, cap = CAP) {
38
57
  .map((x) => x.row);
39
58
  }
40
59
  /**
41
- * What the palette lists. An empty field: the newest chats, under Recent chats.
42
- * A query: Chats, then each other group in its order, each cut to its best
43
- * `CAP`, a group with nothing left out.
60
+ * What the palette lists. An empty field: the newest `RECENT` of `recent` under
61
+ * Recent, then `idle` as it is. A query: every group of `all` cut to its best
62
+ * `CAP`, a group with nothing left out, and the group with the best answer
63
+ * first, ties in the order given.
44
64
  */
45
- export function find(query, chats, groups) {
46
- return findIn(query, { label: 'Recent chats', items: chats }, [{ id: 'chats', label: 'Chats', items: chats }, ...groups]);
47
- }
48
- /**
49
- * The same search for any product: an empty field lists the newest `RECENT` of
50
- * `recent` under its label (a product's own recents: Chat's conversations, Dev's
51
- * runs), and a query ranks every group in the order given.
52
- */
53
- export function findIn(query, recent, groups) {
65
+ export function find(query, recent, idle, all) {
54
66
  if (!query.trim()) {
55
- const newest = [...recent.items].sort((a, b) => (b.at ?? '').localeCompare(a.at ?? '')).slice(0, RECENT);
56
- return newest.length ? [{ id: 'recent', label: recent.label, items: newest }] : [];
67
+ const newest = [...recent].sort((a, b) => (b.at ?? '').localeCompare(a.at ?? '')).slice(0, RECENT);
68
+ return [{ id: 'recent', label: 'Recent', items: newest }, ...idle].filter((g) => g.items.length);
57
69
  }
58
- return groups.map((g) => ({ ...g, items: rank(g.items, query) })).filter((g) => g.items.length);
70
+ return all
71
+ .map((g) => ({ ...g, items: rank(g.items, query) }))
72
+ .filter((g) => g.items.length)
73
+ .map((g, i) => ({ g, i, best: fit(g.items[0], query) }))
74
+ .sort((a, b) => b.best - a.best || a.i - b.i)
75
+ .map((x) => x.g);
59
76
  }
60
77
  /** Whether a key press opens the palette: ⌘K or Ctrl+K, once a press, from anywhere, a field included. */
61
78
  export const summons = (e) => (e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey && !e.repeat && !e.isComposing && (e.key ?? '').toLowerCase() === 'k';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanzo/rooms",
3
- "version": "0.1.91",
3
+ "version": "0.1.92",
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",
@@ -49,7 +49,7 @@
49
49
  "peerDependencies": {
50
50
  "@hanzo/ai": ">=0.6.23",
51
51
  "@hanzo/appearance": ">=0.2.6",
52
- "@hanzo/build": ">=0.2.77",
52
+ "@hanzo/build": ">=0.2.79",
53
53
  "@hanzo/composer": ">=0.2.0",
54
54
  "@hanzo/event": ">=0.3.56",
55
55
  "@hanzo/gui": "^8.3.5",
@@ -68,7 +68,7 @@
68
68
  "devDependencies": {
69
69
  "@hanzo/ai": "0.6.23",
70
70
  "@hanzo/appearance": "0.2.8",
71
- "@hanzo/build": "0.2.77",
71
+ "@hanzo/build": "0.2.79",
72
72
  "@hanzo/composer": "0.2.0",
73
73
  "@hanzo/design": "0.5.34",
74
74
  "@hanzo/event": "0.3.56",