@hanzo/rooms 0.1.91 → 0.1.93

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/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;