@hanzo/build 0.2.50 → 0.2.52

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/lib/project.js CHANGED
@@ -3,6 +3,11 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
3
3
  * A project's workspace, in the same window: the chat on the left, what it
4
4
  * built on the right.
5
5
  *
6
+ * A project is a repository on the forge, addressed `<org>/<repo>`; the site it
7
+ * publishes to, when it has one, is the org's project row on that repository
8
+ * (or of the same name). A deployed site with no repository on the forge is
9
+ * opened by its slug (Artifacts), and works the same way.
10
+ *
6
11
  * bar mark · project · history · chat toggle | Preview Files Code Layers |
7
12
  * Share · Publish
8
13
  * left the project's runs as a conversation, suggestions, and the ask
@@ -41,10 +46,11 @@ import { Button, Dialog, DialogContent, DialogTitle } from '@hanzo/ui';
41
46
  import { HanzoMark } from '@hanzo/ui/product';
42
47
  import { useEffect, useMemo, useRef, useState } from 'react';
43
48
  import { read as pushed } from './api/changes.js';
49
+ import { codebases, one } from './api/codebases.js';
44
50
  import { start } from './api/coding.js';
45
51
  import { blob, tree } from './api/git.js';
46
52
  import { declare } from './api/platform.js';
47
- import { deployments, name as repoName, ours, templates } from './api/projects.js';
53
+ import { address, deployments, name as repoName, ours, templates } from './api/projects.js';
48
54
  import { list, message, stop } from './api/sessions.js';
49
55
  import { decode, outcome, pull, said, who } from './api/turn.js';
50
56
  import { verdict as record } from './api/verdict.js';
@@ -54,8 +60,8 @@ import { Grip } from './grip.js';
54
60
  import { useHost, useTarget } from './host.js';
55
61
  import { MODES } from './landing.js';
56
62
  import { Out } from './out.js';
57
- import { pane as cut } from './pane.js';
58
63
  import { Publish } from './publish.js';
64
+ import { path } from './route.js';
59
65
  import { Attach, compose, Dictate, Files } from './tools.js';
60
66
  const LIVE = new Set(['running', 'paused']);
61
67
  /** How often a project being published is read again, ms. */
@@ -70,6 +76,20 @@ const CHAT_CEIL = 640;
70
76
  const GUTTER = 12;
71
77
  /** What the work keeps beside the chat however wide the chat was asked to be, px. */
72
78
  const WORK = 400 + 2 * GUTTER;
79
+ /** Whether the site `p` is the repository `r`'s: built from it, or named as it is. */
80
+ export function siteOf(p, r) {
81
+ const want = `${r.org}/${r.name}`.toLowerCase();
82
+ return (p.repo !== '' && address(p.repo).toLowerCase() === want) || p.slug === r.name.toLowerCase();
83
+ }
84
+ /** A site's slug for a repository's name: lowercase letters, digits and hyphens, at most 40. */
85
+ export function slugOf(name) {
86
+ return name
87
+ .toLowerCase()
88
+ .replace(/[^a-z0-9]+/g, '-')
89
+ .replace(/^-+|-+$/g, '')
90
+ .slice(0, 40)
91
+ .replace(/-+$/, '');
92
+ }
73
93
  /** One run in the conversation: what was asked, and what it came to. */
74
94
  function Turn({ run, open, onOpen, project }) {
75
95
  const t = useTarget();
@@ -83,12 +103,18 @@ function Turn({ run, open, onOpen, project }) {
83
103
  });
84
104
  } }), note ? (_jsx(SizableText, { size: "$1", color: "$soft", children: note })) : null, !open ? (_jsx(XStack, { render: "button", onPress: onOpen, hitSlop: 8, children: _jsx(SizableText, { size: "$1", color: "$soft", textDecorationLine: "underline", children: "Show steps" }) })) : null] }), children: _jsx(SizableText, { size: "$2", color: "$ink", children: run.status === 'done' ? 'Done.' : run.status === 'error' ? 'This run hit an error.' : run.status === 'stopped' ? 'Stopped.' : 'Working…' }) })] }));
85
105
  }
86
- export function Project({ slug }) {
106
+ export function Project({ at }) {
87
107
  const host = useHost();
88
108
  const t = useTarget();
89
109
  const signed = Boolean(host.person);
90
110
  const all = useProjects(t, signed);
91
- const project = all.value.find((p) => p.slug === slug) ?? null;
111
+ // The repository the address names, and the forge's record of it.
112
+ const named = 'slug' in at ? null : at;
113
+ const forge = useRead(named && signed ? () => one(t, named.name) : null, null, [t, signed, named?.name]);
114
+ const project = all.value.find((p) => (named ? siteOf(p, named) : 'slug' in at && p.slug === at.slug)) ?? null;
115
+ // The site this workspace publishes to: the project's, or the repository's name as one.
116
+ const slug = project?.slug ?? ('slug' in at ? at.slug : slugOf(named?.name ?? ''));
117
+ const title = project?.name ?? named?.name ?? slug;
92
118
  // A project that has never served reads `building` while it is published, and
93
119
  // is read again until it is live or its build failed.
94
120
  const building = project?.status === 'building';
@@ -113,12 +139,15 @@ export function Project({ slug }) {
113
139
  const stalled = building && ran > STALL;
114
140
  // Publish again: the project's repository, built at its branch, the way Publish builds it.
115
141
  const [redo, setRedo] = useState({ busy: false, error: '' });
142
+ // The repository's clone address: the forge's for a repository, the site's own otherwise.
143
+ const clone = named ? (forge.value?.clone ?? '') : (project?.repo ?? '');
144
+ const home = named ? (forge.value?.branch ?? 'main') : project?.branch || 'main';
116
145
  const retry = async () => {
117
- if (!project?.repo || redo.busy)
146
+ if (!clone || redo.busy)
118
147
  return;
119
148
  setRedo({ busy: true, error: '' });
120
149
  try {
121
- await declare(t, { repo: project.repo, ref: project.branch || 'main', name: slug, project: slug, mode: 'branch' });
150
+ await declare(t, { repo: clone, ref: home, name: slug, project: slug, mode: 'branch' });
122
151
  setNow(Date.now());
123
152
  again();
124
153
  setRedo({ busy: false, error: '' });
@@ -127,11 +156,14 @@ export function Project({ slug }) {
127
156
  setRedo({ busy: false, error: e.message });
128
157
  }
129
158
  };
130
- const listed = useRead(signed ? () => list(t, { kind: 'coding', project: slug, limit: 50 }) : null, [], [t, signed, slug]);
159
+ // The site's runs, or the org's when the repository has no site yet.
160
+ const sited = !named || Boolean(project);
161
+ const listed = useRead(signed ? () => list(t, sited ? { kind: 'coding', project: slug, limit: 50 } : { kind: 'coding', limit: 50 }) : null, [], [t, signed, slug, sited]);
131
162
  // Only the runs on this project's own repository: a record can be moved into
132
163
  // any project, and a run's branch is what Files, Code and Publish read.
133
- const runs = { ...listed, value: listed.value.filter((r) => ours(r.repo, project?.repo ?? '')) };
134
- const ordered = useMemo(() => [...runs.value].reverse(), [listed.value, project?.repo]);
164
+ const mine = (r) => (named ? ours(r.repo, `${named.org}/${named.name}`) && Boolean(r.repo) : ours(r.repo, project?.repo ?? ''));
165
+ const runs = { ...listed, value: listed.value.filter(mine) };
166
+ const ordered = useMemo(() => [...runs.value].reverse(), [listed.value, project?.repo, named?.org, named?.name]);
135
167
  const [chosen, setChosen] = useState(null);
136
168
  const current = chosen ?? runs.value[0]?.id ?? null;
137
169
  const run = useRun(t, current);
@@ -149,6 +181,12 @@ export function Project({ slug }) {
149
181
  // Below md the chat and the work take turns, and Chat is one more view to switch back to.
150
182
  const [pane, setPane] = useState('chat');
151
183
  const wide = useMedia().md;
184
+ // A workspace too narrow for the chat at its floor and the work at its room
185
+ // (a tablet beside the host's column) shows the work, and the chat when asked:
186
+ // never both cut. Asked here is for this visit; the kept choice is the wide one's.
187
+ const cramped = wide && room > 0 && room < CHAT_FLOOR + WORK;
188
+ const [asked, setAsked] = useState(false);
189
+ const shut = cramped ? !asked : collapsed;
152
190
  const [dock, setDock] = useKept('hanzo.build.dock', 36);
153
191
  const [dismissed, setDismissed] = useKept(`hanzo.build.hints.${slug}`, false);
154
192
  const [mode, setMode] = useKept('hanzo.build.mode', 'build');
@@ -164,6 +202,7 @@ export function Project({ slug }) {
164
202
  const [history, setHistory] = useState(false);
165
203
  const [publish, setPublish] = useState(null);
166
204
  const [switcher, setSwitcher] = useState(false);
205
+ const repos = useRead(switcher && signed ? () => codebases(t) : null, [], [t, switcher, signed]);
167
206
  const frame = useRef(null);
168
207
  // What was published may be new files at the same address: the project is read
169
208
  // for its address, and the frame loads again.
@@ -196,8 +235,8 @@ export function Project({ slug }) {
196
235
  // Files is drawn again after a merge, from what the forge holds now.
197
236
  const [round, setRound] = useState(0);
198
237
  // Files and Code: native git, at the branch the open run pushed (or the project's own).
199
- const repo = project?.repo ? repoName(project.repo) : '';
200
- const ref = run.record?.branch || project?.branch || 'main';
238
+ const repo = named ? named.name : project?.repo ? repoName(project.repo) : '';
239
+ const ref = run.record?.branch || home;
201
240
  const [files, setFiles] = useState([]);
202
241
  const [file, setFile] = useState(null);
203
242
  useEffect(() => {
@@ -294,9 +333,10 @@ export function Project({ slug }) {
294
333
  // A project with no repository yet sends neither, and its first run makes one.
295
334
  const next = await start(t, {
296
335
  prompt: compose(`${ask}${context}`, attached),
297
- project: slug,
336
+ // A repository with no site yet runs on the repository alone.
337
+ project: project?.slug,
298
338
  repo,
299
- base: project?.branch,
339
+ base: named ? forge.value?.branch : project?.branch,
300
340
  after: current ?? undefined,
301
341
  mode,
302
342
  });
@@ -337,10 +377,21 @@ export function Project({ slug }) {
337
377
  if (!signed) {
338
378
  return (_jsxs(YStack, { flex: 1, items: "center", justify: "center", gap: "$3", children: [_jsx(SizableText, { size: "$4", color: "$ink", children: "Sign in to open this project." }), _jsx(Button, { onPress: () => host.signIn?.(), children: "Sign in" })] }));
339
379
  }
340
- if (!all.loading && !project && !all.error) {
341
- return (_jsxs(YStack, { flex: 1, items: "center", justify: "center", gap: "$3", px: "$4", children: [_jsx(SizableText, { size: "$4", color: "$ink", children: host.org ? `${host.org} has no project named ${slug}.` : `There is no project named ${slug}.` }), _jsx(Button, { variant: "outline", onPress: () => host.go('-/artifacts'), children: "See what this organization has built" })] }));
380
+ // A repository of another organization is opened in that one.
381
+ if (named && host.org && named.org.toLowerCase() !== host.org.toLowerCase()) {
382
+ const member = host.memberships?.some((m) => m.toLowerCase() === named.org.toLowerCase()) && host.chooseOrg;
383
+ return (_jsxs(YStack, { flex: 1, items: "center", justify: "center", gap: "$3", px: "$4", children: [_jsx(SizableText, { size: "$4", color: "$ink", text: "center", children: `${named.org}/${named.name} is in ${named.org}, and you are working in ${host.org}.` }), member ? (_jsx(Button, { variant: "outline", onPress: () => host.chooseOrg?.(named.org), children: `Switch to ${named.org}` })) : (_jsx(Button, { variant: "outline", onPress: () => host.go(path({ kind: 'screen', screen: 'projects' })), children: "See this organization\u2019s projects" }))] }));
384
+ }
385
+ if (named ? !forge.loading && !forge.value : !all.loading && !project && !all.error) {
386
+ return (_jsxs(YStack, { flex: 1, items: "center", justify: "center", gap: "$3", px: "$4", children: [_jsx(SizableText, { size: "$4", color: "$ink", text: "center", children: named
387
+ ? forge.error && !/not found|404/i.test(forge.error.message)
388
+ ? forge.error.message
389
+ : `${named.org} has no repository named ${named.name}.`
390
+ : host.org
391
+ ? `${host.org} has no project named ${slug}.`
392
+ : `There is no project named ${slug}.` }), _jsx(Button, { variant: "outline", onPress: () => host.go(path({ kind: 'screen', screen: named ? 'projects' : 'artifacts' })), children: named ? 'See this organization’s projects' : 'See what this organization has built' })] }));
342
393
  }
343
- const chat = (_jsxs(YStack, { flex: 1, minH: 0, position: "relative", children: [_jsx(YStack, { flex: 1, minH: 0, overflow: "scroll", px: "$3", py: "$4", gap: "$4", children: ordered.length === 0 ? (_jsx(SizableText, { size: "$2", color: "$ink", px: "$2", children: runs.loading
394
+ const chat = (_jsxs(YStack, { "data-slot": "project-chat", flex: 1, minH: 0, position: "relative", children: [_jsx(YStack, { flex: 1, minH: 0, overflow: "scroll", px: "$3", pt: "$2", pb: "$4", gap: "$4", $md: { pr: 0 }, children: ordered.length === 0 ? (_jsx(SizableText, { size: "$2", color: "$ink", children: runs.loading
344
395
  ? 'Reading this project’s runs…'
345
396
  : project?.live
346
397
  ? `${project.name} is loaded — it is in the preview, and every run on it is under the clock above. Say what to change and it gets built.`
@@ -354,12 +405,14 @@ export function Project({ slug }) {
354
405
  ? `${project?.name} is loaded — the preview shows the ${starter.title} starter until your copy is published. Say what to change and it gets built.`
355
406
  : project
356
407
  ? `${project.name} is loaded and nothing is published yet. Say what to change and it gets built.`
357
- : `${slug} is loaded. Say what to change and it gets built.` })) : (ordered.map((r) => (_jsxs(YStack, { gap: "$2", children: [_jsx(Turn, { run: r, open: r.id === current, onOpen: () => setChosen(r.id), project: slug }), r.id === current ? (_jsxs(YStack, { gap: "$1", pl: "$2", borderLeftWidth: 1, borderColor: "$borderColor", children: [fold(run.events.map((e) => ({ kind: e.kind, actor: who(e.actor), seq: e.seq, id: e.id, text: said(e, run.record?.mode) })).filter((b) => b.text)).map((b) => (_jsx(SizableText, { size: "$1", color: "$soft", children: b.text }, b.key))), pr.href ? (_jsxs(XStack, { items: "center", gap: "$2", flexWrap: "wrap", children: [_jsxs(Out, { href: pr.href, label: `Open pull request ${pr.label}`, children: [_jsxs(SizableText, { size: "$1", color: "$ink", textDecorationLine: "underline", children: ["Pull request ", pr.label] }), _jsx(ExternalLink, { size: 11 })] }), _jsx(Merge, { session: r.id, open: proposing, onMerged: merged })] })) : null] })) : null] }, r.id)))) }), _jsxs(YStack, { px: "$3", pb: "$3", gap: "$2", children: [!dismissed && !running ? _jsx(Suggestions, { items: SUGGESTIONS, onPick: (s) => void send(s), onDismiss: () => setDismissed(true) }) : null, note ? (_jsx(SizableText, { size: "$1", color: "$soft", role: "status", children: note })) : null, picked.length ? _jsx(Attachments, { items: picked, onRemove: () => setPicked([]) }) : null, attached.length ? (_jsx(XStack, { gap: 6, flexWrap: "wrap", children: _jsx(Files, { files: attached, onFiles: setAttached }) })) : null, _jsxs(Composer, { ...cut, value: draft, onChange: setDraft, onSend: () => void (current && running ? steer(current) : send()), onStop: current
408
+ : named
409
+ ? `${named.org}/${named.name} is open on ${home}. Say what to change and it gets built there.`
410
+ : `${slug} is loaded. Say what to change and it gets built.` })) : (ordered.map((r) => (_jsxs(YStack, { gap: "$2", children: [_jsx(Turn, { run: r, open: r.id === current, onOpen: () => setChosen(r.id), project: slug }), r.id === current ? (_jsxs(YStack, { gap: "$1", pl: "$2", borderLeftWidth: 1, borderColor: "$borderColor", children: [fold(run.events.map((e) => ({ kind: e.kind, actor: who(e.actor), seq: e.seq, id: e.id, text: said(e, run.record?.mode) })).filter((b) => b.text)).map((b) => (_jsx(SizableText, { size: "$1", color: "$soft", children: b.text }, b.key))), pr.href ? (_jsxs(XStack, { items: "center", gap: "$2", flexWrap: "wrap", children: [_jsxs(Out, { href: pr.href, label: `Open pull request ${pr.label}`, children: [_jsxs(SizableText, { size: "$1", color: "$ink", textDecorationLine: "underline", children: ["Pull request ", pr.label] }), _jsx(ExternalLink, { size: 11 })] }), _jsx(Merge, { session: r.id, open: proposing, onMerged: merged })] })) : null] })) : null] }, r.id)))) }), _jsxs(YStack, { "data-slot": "project-ask", px: "$3", pb: "$3", gap: "$2", minW: 0, $md: { pr: 0 }, children: [!dismissed && !running ? _jsx(Suggestions, { items: SUGGESTIONS, onPick: (s) => void send(s), onDismiss: () => setDismissed(true) }) : null, note ? (_jsx(SizableText, { size: "$1", color: "$soft", role: "status", children: note })) : null, picked.length ? _jsx(Attachments, { items: picked, onRemove: () => setPicked([]) }) : null, attached.length ? (_jsx(XStack, { gap: 6, flexWrap: "wrap", children: _jsx(Files, { files: attached, onFiles: setAttached }) })) : null, _jsxs(Composer, { value: draft, onChange: setDraft, onSend: () => void (current && running ? steer(current) : send()), onStop: current
358
411
  ? () => void stop(t, current).then(() => setNote('Stop requested — the run keeps its work on its branch'), (e) => setNote(e.message))
359
- : undefined, busy: busy || (running && !draft.trim()), rows: 3, placeholder: running ? 'Steer this run' : 'Ask Hanzo for edits', label: "Ask Hanzo for edits", children: [_jsx(Attach, { files: attached, onFiles: setAttached, onNote: setNote }), _jsx(XStack, { flex: 1 }), _jsx(ModeSelect, { modes: MODES, value: mode, onChange: (m) => setMode(m), self: "center" }), _jsx(Dictate, { onText: (said) => setDraft((d) => (d ? `${d} ${said}` : said)), onNote: setNote })] })] }), wide && !collapsed ? (_jsx(Grip, { side: "left", span: across, floor: CHAT_FLOOR, ceil: most, reset: CHAT_WIDTH, onSpan: setSplit, label: "Resize the chat", r: -GUTTER, width: GUTTER })) : null] }));
412
+ : undefined, busy: busy || (running && !draft.trim()), rows: 3, placeholder: running ? 'Steer this run' : 'Ask Hanzo for edits', label: "Ask Hanzo for edits", children: [_jsx(Attach, { files: attached, onFiles: setAttached, onNote: setNote }), _jsx(XStack, { flex: 1 }), _jsx(ModeSelect, { modes: MODES, value: mode, onChange: (m) => setMode(m), self: "center" }), _jsx(Dictate, { onText: (said) => setDraft((d) => (d ? `${d} ${said}` : said)), onNote: setNote })] })] }), wide && !shut ? (_jsx(Grip, { side: "left", span: across, floor: CHAT_FLOOR, ceil: most, reset: CHAT_WIDTH, onSpan: setSplit, label: "Resize the chat", r: -GUTTER, width: GUTTER })) : null] }));
360
413
  // The preview stays loaded while another view is open, so coming back to it
361
414
  // is the page as it was left.
362
- const preview = (_jsxs(YStack, { "data-slot": "preview", flex: 1, minH: 0, minW: 0, gap: "$2", display: view === 'preview' ? 'flex' : 'none', children: [_jsxs(XStack, { "data-slot": "preview-bar", items: "center", gap: "$1.5", minW: 0, shrink: 0, "$max-md": { px: '$2' }, children: [_jsx(XStack, { render: "button", "aria-label": "Reload the preview", onPress: () => frame.current?.reload(), p: "$1.5", rounded: "$3", hoverStyle: { bg: '$hover' }, children: _jsx(RefreshCcw, { size: 16 }) }), _jsx(PageSelect, { pages: pages, value: page, onChange: setPage, shrink: 1 }), _jsx(XStack, { flex: 1 }), _jsx(Views, { views: DEVICES, value: device, onChange: (d) => setDevice(d), label: "Device", labels: "none" }), src ? (_jsx(Out, { href: src, label: "Open in a new tab", children: _jsx(XStack, { p: "$1.5", rounded: "$3", hoverStyle: { bg: '$hover' }, children: _jsx(ExternalLink, { size: 16 }) }) })) : null] }), _jsx(PreviewFrame, { ref: frame, src: src, title: `${project?.name ?? slug} preview`, device: device, onBridge: onBridge, empty: _jsxs(YStack, { items: "center", gap: "$2", p: "$6", children: [_jsx(SizableText, { size: "$3", color: "$ink", children: stalled
415
+ const preview = (_jsxs(YStack, { "data-slot": "preview", flex: 1, minH: 0, minW: 0, gap: "$2", display: view === 'preview' ? 'flex' : 'none', children: [_jsxs(XStack, { "data-slot": "preview-bar", items: "center", gap: "$1.5", minW: 0, shrink: 0, "$max-md": { px: '$2' }, children: [_jsx(XStack, { render: "button", "aria-label": "Reload the preview", onPress: () => frame.current?.reload(), p: "$1.5", rounded: "$3", hoverStyle: { bg: '$hover' }, children: _jsx(RefreshCcw, { size: 16 }) }), _jsx(PageSelect, { pages: pages, value: page, onChange: setPage, shrink: 1 }), _jsx(XStack, { flex: 1 }), _jsx(Views, { views: DEVICES, value: device, onChange: (d) => setDevice(d), label: "Device", labels: "none" }), src ? (_jsx(Out, { href: src, label: "Open in a new tab", children: _jsx(XStack, { p: "$1.5", rounded: "$3", hoverStyle: { bg: '$hover' }, children: _jsx(ExternalLink, { size: 16 }) }) })) : null] }), _jsx(PreviewFrame, { ref: frame, src: src, title: `${title} preview`, device: device, onBridge: onBridge, empty: _jsxs(YStack, { items: "center", gap: "$2", p: "$6", children: [_jsx(SizableText, { size: "$3", color: "$ink", children: stalled
363
416
  ? 'Publishing is taking too long'
364
417
  : building
365
418
  ? project?.forked
@@ -373,29 +426,34 @@ export function Project({ slug }) {
373
426
  ? 'Its page appears here when the build finishes.'
374
427
  : failed
375
428
  ? why
376
- : 'Publish this project and its page appears here.' }), (stalled || failed) && project?.repo ? (_jsx(Button, { variant: "outline", disabled: redo.busy, onPress: () => void retry(), children: "Publish again" })) : null, redo.error ? (_jsx(SizableText, { size: "$2", color: "$soft", text: "center", role: "alert", children: redo.error })) : null] }), toolbar: bridge ? (_jsx(XStack, { render: "button", "aria-pressed": editing, "aria-label": editing ? 'Stop picking elements' : 'Pick an element to edit', onPress: () => {
429
+ : 'Publish this project and its page appears here.' }), (stalled || failed) && clone ? (_jsx(Button, { variant: "outline", disabled: redo.busy, onPress: () => void retry(), children: "Publish again" })) : null, redo.error ? (_jsx(SizableText, { size: "$2", color: "$soft", text: "center", role: "alert", children: redo.error })) : null] }), toolbar: bridge ? (_jsx(XStack, { render: "button", "aria-pressed": editing, "aria-label": editing ? 'Stop picking elements' : 'Pick an element to edit', onPress: () => {
377
430
  const next = !editing;
378
431
  setEditing(next);
379
432
  frame.current?.post({ type: 'preview:editable', active: next });
380
433
  }, p: "$2", rounded: "$10", children: _jsx(MousePointerClick, { size: 16 }) })) : undefined })] }));
381
- const body = view === 'preview' ? null : view === 'files' ? (repo ? (_jsx(FileTree, { load: async (dir) => (await tree(t, repo, ref, dir)).map((e) => ({ path: e.path, kind: e.dir ? 'dir' : 'file' })), value: file, onSelect: (p) => void openFile(p), label: `${repo} at ${ref}` }, round)) : (_jsx(SizableText, { size: "$2", color: "$soft", p: "$4", children: "This project has no repository yet. Its first run creates one." }))) : view === 'code' ? (_jsx(FileTabs, { files: files, value: file, onSelect: setFile, onClose: (p) => {
434
+ const body = view === 'preview' ? null : view === 'files' ? (repo && root.error ? (_jsx(SizableText, { size: "$2", color: "$soft", p: "$4", role: "status", children: `Nothing to show for ${repo} at ${ref}: the forge answered “${root.error.message}”.` })) : repo ? (_jsx(FileTree, { load: async (dir) => (await tree(t, repo, ref, dir)).map((e) => ({ path: e.path, kind: e.dir ? 'dir' : 'file' })), value: file, onSelect: (p) => void openFile(p), label: `${repo} at ${ref}` }, round)) : (_jsx(SizableText, { size: "$2", color: "$soft", p: "$4", children: "This project has no repository yet. Its first run creates one." }))) : view === 'code' ? (_jsx(FileTabs, { files: files, value: file, onSelect: setFile, onClose: (p) => {
382
435
  setFiles((was) => was.filter((f) => f.path !== p));
383
436
  if (file === p)
384
437
  setFile(null);
385
438
  }, empty: _jsxs(SizableText, { size: "$2", color: "$soft", p: "$4", children: ["Open a file from Files. Code here is read at ", ref, "; ask in the chat to change it."] }) })) : (_jsx(YStack, { p: "$4", gap: "$2", children: picked.length ? (picked.map((p) => (_jsxs(SizableText, { size: "$2", color: "$ink", children: [p.label, " \u2014 ", p.id] }, p.id)))) : (_jsx(SizableText, { size: "$2", color: "$soft", children: bridge
386
439
  ? 'Pick an element in the preview to see it here and attach it to your next ask.'
387
440
  : 'Layers are read through the preview bridge, and this page does not run it. Its elements cannot be listed from here.' })) }));
388
- return (_jsxs(_Fragment, { children: [_jsxs(Workspace, { start: _jsxs(XStack, { items: "center", gap: "$2", minW: 0, shrink: 1, children: [_jsx(XStack, { render: "button", "aria-label": "All runs", onPress: () => host.go(''), p: "$1", shrink: 0, children: _jsx(HanzoMark, { size: 20 }) }), _jsx(ProjectChip, { name: project?.name ?? slug, onPress: () => setSwitcher(true) }), _jsx(XStack, { render: "button", "aria-label": "History", onPress: () => setHistory(true), p: "$1.5", rounded: "$3", shrink: 0, hoverStyle: { bg: '$hover' }, children: _jsx(Clock, { size: 16 }) }), _jsx(XStack, { render: "button", "aria-label": collapsed ? 'Show the chat' : 'Hide the chat', "aria-pressed": !collapsed, onPress: () => setCollapsed(!collapsed), p: "$1.5", rounded: "$3", shrink: 0, hoverStyle: { bg: '$hover' }, "$max-md": { display: 'none' }, children: _jsx(PanelLeft, { size: 16 }) })] }), middle: _jsx(Views, { views: [...VIEWS, CHAT], value: pane === 'chat' && !wide ? 'chat' : view, onChange: (v) => {
441
+ return (_jsxs(_Fragment, { children: [_jsxs(Workspace, { start: _jsxs(XStack, { items: "center", gap: "$2", minW: 0, shrink: 1, children: [_jsx(XStack, { render: "button", "aria-label": "All runs", onPress: () => host.go(''), p: "$1", shrink: 0, children: _jsx(HanzoMark, { size: 20 }) }), _jsx(ProjectChip, { name: title, onPress: () => setSwitcher(true) }), _jsx(XStack, { render: "button", "aria-label": "History", onPress: () => setHistory(true), p: "$1.5", rounded: "$3", shrink: 0, hoverStyle: { bg: '$hover' }, children: _jsx(Clock, { size: 16 }) }), _jsx(XStack, { render: "button", "aria-label": shut ? 'Show the chat' : 'Hide the chat', "aria-pressed": !shut, onPress: () => (cramped ? setAsked(!asked) : setCollapsed(!collapsed)), p: "$1.5", rounded: "$3", shrink: 0, hoverStyle: { bg: '$hover' }, "$max-md": { display: 'none' }, children: _jsx(PanelLeft, { size: 16 }) })] }), middle: _jsx(Views, { views: [CHAT, ...VIEWS], value: pane === 'chat' && !wide ? 'chat' : view, onChange: (v) => {
389
442
  if (v === 'chat')
390
443
  return setPane('chat');
391
444
  setView(v);
392
445
  setPane('view');
393
- }, label: "View" }), end: _jsxs(XStack, { items: "center", gap: "$2", children: [_jsx(XStack, { display: "none", $md: { display: 'flex' }, children: _jsxs(Button, { variant: "outline", size: "sm", onPress: () => void share(), children: [_jsx(Share2, { size: 14 }), " Share"] }) }), _jsxs(Button, { size: "sm", disabled: !project?.repo, onPress: () => project &&
394
- setPublish({ repo: project.repo, title: project.name, ref, name: project.slug, project: project.slug }), children: [_jsx(Upload, { size: 14 }), " Publish"] })] }), chat: chat, dock: _jsx(Console, { lines: lines, height: dock, onHeight: setDock, onClear: () => setPageLines([]), status: run.status || end.status || undefined, label: "Console" }), collapsed: collapsed, pane: pane, width: across, onLayout: (e) => setRoom(e.nativeEvent.layout.width), children: [preview, body] }), _jsx(Dialog, { open: history, onOpenChange: setHistory, children: _jsxs(DialogContent, { maxW: 480, children: [_jsxs(DialogTitle, { children: ["Runs on ", project?.name ?? slug] }), _jsx(YStack, { gap: "$1", children: runs.value.length === 0 ? (_jsx(SizableText, { size: "$2", color: "$soft", children: "No runs yet." })) : (runs.value.map((r) => (_jsxs(XStack, { render: "button", onPress: () => {
446
+ }, label: "View" }), end: _jsxs(XStack, { items: "center", gap: "$2", children: [_jsx(XStack, { display: "none", $md: { display: 'flex' }, children: _jsxs(Button, { variant: "outline", size: "sm", onPress: () => void share(), children: [_jsx(Share2, { size: 14 }), " Share"] }) }), _jsxs(Button, { size: "sm", "aria-label": "Publish", disabled: !clone || !slug, onPress: () => setPublish({ repo: clone, title, ref, name: slug, project: slug }), children: [_jsx(Upload, { size: 14 }), _jsx(SizableText, { size: "$2", color: "inherit", "$max-md": { display: 'none' }, children: "Publish" })] })] }), chat: chat, dock: _jsx(Console, { lines: lines, height: dock, onHeight: setDock, onClear: () => setPageLines([]), status: run.status || end.status || undefined, label: "Console" }), collapsed: shut, pane: pane, width: across, onLayout: (e) => setRoom(e.nativeEvent.layout.width), children: [preview, body] }), _jsx(Dialog, { open: history, onOpenChange: setHistory, children: _jsxs(DialogContent, { maxW: 480, children: [_jsxs(DialogTitle, { children: ["Runs on ", title] }), _jsx(YStack, { gap: "$1", children: runs.value.length === 0 ? (_jsx(SizableText, { size: "$2", color: "$soft", children: "No runs yet." })) : (runs.value.map((r) => (_jsxs(XStack, { render: "button", onPress: () => {
395
447
  setChosen(r.id);
396
448
  setHistory(false);
397
- }, justify: "space-between", px: "$2", py: "$1.5", rounded: "$3", hoverStyle: { bg: '$hover' }, children: [_jsx(SizableText, { size: "$2", color: "$ink", numberOfLines: 1, flex: 1, children: r.title || 'Untitled run' }), _jsx(SizableText, { size: "$1", color: "$soft", children: r.status })] }, r.id)))) })] }) }), _jsx(Dialog, { open: switcher, onOpenChange: setSwitcher, children: _jsxs(DialogContent, { maxW: 420, children: [_jsx(DialogTitle, { children: "Projects" }), _jsx(YStack, { gap: "$1", maxH: 420, overflow: "scroll", children: all.value.map((p) => (_jsx(XStack, { render: "button", onPress: () => {
398
- setSwitcher(false);
399
- host.go(p.slug);
400
- }, px: "$2", py: "$1.5", rounded: "$3", bg: p.slug === slug ? '$hover' : undefined, hoverStyle: { bg: '$hover' }, children: _jsx(SizableText, { size: "$2", color: "$ink", numberOfLines: 1, children: p.name }) }, p.slug))) })] }) }), _jsx(Publish, { source: publish, onClose: () => setPublish(null), onLive: fresh })] }));
449
+ }, justify: "space-between", px: "$2", py: "$1.5", rounded: "$3", hoverStyle: { bg: '$hover' }, children: [_jsx(SizableText, { size: "$2", color: "$ink", numberOfLines: 1, flex: 1, children: r.title || 'Untitled run' }), _jsx(SizableText, { size: "$1", color: "$soft", children: r.status })] }, r.id)))) })] }) }), _jsx(Dialog, { open: switcher, onOpenChange: setSwitcher, children: _jsxs(DialogContent, { maxW: 420, children: [_jsx(DialogTitle, { children: "Projects" }), _jsx(YStack, { gap: "$1", maxH: 420, overflow: "scroll", children: repos.error ? (_jsx(SizableText, { size: "$2", color: "$soft", children: repos.error.message })) : repos.loading && repos.value.length === 0 ? (_jsx(SizableText, { size: "$2", color: "$soft", children: "Reading the forge\u2026" })) : (repos.value.map((c) => {
450
+ const here = Boolean(named) && c.name === named?.name;
451
+ return (_jsx(XStack, { render: "button", "aria-label": `Open ${c.org}/${c.name}`, "aria-current": here ? 'page' : undefined, onPress: () => {
452
+ setSwitcher(false);
453
+ host.go(path({ kind: 'repo', org: c.org || host.org || '', name: c.name }));
454
+ }, px: "$2", py: "$1.5", rounded: "$3", bg: here ? '$hover' : undefined, hoverStyle: { bg: '$hover' }, children: _jsx(SizableText, { size: "$2", color: "$ink", numberOfLines: 1, children: c.name }) }, `${c.org}/${c.name}`));
455
+ })) }), _jsx(Button, { variant: "outline", size: "sm", onPress: () => {
456
+ setSwitcher(false);
457
+ host.go(path({ kind: 'screen', screen: 'projects' }));
458
+ }, children: "All projects" })] }) }), _jsx(Publish, { source: publish, onClose: () => setPublish(null), onLive: fresh })] }));
401
459
  }
package/lib/route.d.ts CHANGED
@@ -8,13 +8,15 @@
8
8
  * -/settings[/<section>] a section of Settings
9
9
  * -/customize[/<tab>] skills, connectors and plugins: what the agent brings to a run
10
10
  * -/plans the plans an organization can be on
11
- * <slug> a project's workspace
11
+ * <org>/<repo> a repository's workspace: a project is a repository on the forge
12
+ * <slug> a deployed site's workspace (Artifacts)
12
13
  *
13
14
  * Unambiguous by construction: a project slug is `^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?$`,
14
- * so it can hold neither the `_` a session id carries nor start with `-`.
15
- * Anything else is not an address here and reads as the empty state.
15
+ * so it can hold neither the `_` a session id carries nor start with `-`, and a
16
+ * repository is the one address with two segments, each starting with a letter
17
+ * or a digit. Anything else is not an address here and reads as the empty state.
16
18
  */
17
- export type Screen = 'artifacts' | 'templates' | 'codebases' | 'projects' | 'issues' | 'automations' | 'sync' | 'mcp' | 'plans';
19
+ export type Screen = 'artifacts' | 'templates' | 'projects' | 'issues' | 'automations' | 'sync' | 'mcp' | 'plans';
18
20
  /** Settings, one section each. Every setting the builder has lives on this page, none elsewhere. */
19
21
  export declare const SECTIONS: readonly ['general', 'account', 'privacy', 'billing', 'usage', 'capabilities', 'memory', 'code', 'environments', 'machines', 'keys', 'members', 'integrations', 'notifications'];
20
22
  export type Section = (typeof SECTIONS)[number];
@@ -38,12 +40,18 @@ export type Route = {
38
40
  } | {
39
41
  kind: 'project';
40
42
  slug: string;
43
+ } | {
44
+ kind: 'repo';
45
+ org: string;
46
+ name: string;
41
47
  } | {
42
48
  kind: 'automation';
43
49
  id: string;
44
50
  };
45
51
  export declare const SESSION: RegExp;
46
52
  export declare const SLUG: RegExp;
53
+ /** An organization or a repository name on the forge (api/codebases.ts `NAME`). */
54
+ export declare const NAME: RegExp;
47
55
  /** An automation's id, or `new` for one not yet written. */
48
56
  export declare const AUTOMATION: RegExp;
49
57
  export declare function route(path: string): Route;
package/lib/route.js CHANGED
@@ -19,9 +19,11 @@ export const SECTIONS = [
19
19
  export const TABS = ['skills', 'connectors', 'plugins', 'agents'];
20
20
  export const SESSION = /^sess_[0-9a-f]{32}$/;
21
21
  export const SLUG = /^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?$/;
22
+ /** An organization or a repository name on the forge (api/codebases.ts `NAME`). */
23
+ export const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
22
24
  /** An automation's id, or `new` for one not yet written. */
23
25
  export const AUTOMATION = /^(flow_[0-9a-f]{32}|new)$/;
24
- const SCREENS = ['artifacts', 'templates', 'codebases', 'projects', 'issues', 'automations', 'sync', 'mcp', 'plans'];
26
+ const SCREENS = ['artifacts', 'templates', 'projects', 'issues', 'automations', 'sync', 'mcp', 'plans'];
25
27
  export function route(path) {
26
28
  const p = path.replace(/^\/+|\/+$/g, '');
27
29
  if (SESSION.test(p))
@@ -44,6 +46,9 @@ export function route(path) {
44
46
  }
45
47
  if (SLUG.test(p))
46
48
  return { kind: 'project', slug: p };
49
+ const [org, name, ...rest] = p.split('/');
50
+ if (!rest.length && org && name && NAME.test(org) && NAME.test(name))
51
+ return { kind: 'repo', org, name };
47
52
  return { kind: 'new' };
48
53
  }
49
54
  /** The path for a route — the inverse of `route`. */
@@ -61,6 +66,8 @@ export function path(r) {
61
66
  return r.tab === 'skills' ? '-/customize' : `-/customize/${r.tab}`;
62
67
  case 'project':
63
68
  return r.slug;
69
+ case 'repo':
70
+ return `${r.org}/${r.name}`;
64
71
  case 'automation':
65
72
  return `-/automations/${r.id}`;
66
73
  }
package/lib/section.d.ts CHANGED
@@ -8,7 +8,7 @@ export declare const DOTS: Record<string, SessionStatus>;
8
8
  /** The org's coding runs, newest first, kept live by the org's feed. */
9
9
  export declare function useSessions(host: Host): Read<Session[]>;
10
10
  /**
11
- * New, Automations, the forge (Codebase, Projects, Issues), Artifacts, Templates, then the
11
+ * New, Automations, the forge (Projects, Issues), Artifacts, Templates, then the
12
12
  * runs. `children` sit between the places and the runs — where a host lists its
13
13
  * own. `onPick` fires after a move, so a host that draws its rail as a drawer
14
14
  * on a phone can close it.
package/lib/section.js CHANGED
@@ -11,7 +11,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
11
11
  * host's own `go`.
12
12
  */
13
13
  import { SizableText } from '@hanzo/gui';
14
- import { Blocks, CircleDot, FolderGit2, Kanban, LayoutTemplate, SlidersHorizontal, SquarePen, Workflow } from '@hanzogui/lucide-icons-2';
14
+ import { Blocks, CircleDot, FolderGit2, LayoutTemplate, SlidersHorizontal, SquarePen, Workflow } from '@hanzogui/lucide-icons-2';
15
15
  import { SidebarItem, SidebarSection, StatusDot } from '@hanzo/ui/chat';
16
16
  import { useMemo } from 'react';
17
17
  import { pinBoard } from './choice.js';
@@ -32,7 +32,7 @@ export function useSessions(host) {
32
32
  }
33
33
  const Note = ({ children }) => (_jsx(SizableText, { size: "$1", color: "$soft", px: "$2", py: "$1", children: children }));
34
34
  /**
35
- * New, Automations, the forge (Codebase, Projects, Issues), Artifacts, Templates, then the
35
+ * New, Automations, the forge (Projects, Issues), Artifacts, Templates, then the
36
36
  * runs. `children` sit between the places and the runs — where a host lists its
37
37
  * own. `onPick` fires after a move, so a host that draws its rail as a drawer
38
38
  * on a phone can close it.
@@ -45,7 +45,7 @@ export function DevSection({ host, onPick, label = 'Runs', children, }) {
45
45
  onPick?.();
46
46
  };
47
47
  const screen = r.kind === 'screen' ? r.screen : '';
48
- return (_jsxs(_Fragment, { children: [_jsx(SidebarItem, { icon: _jsx(SquarePen, { size: 16, "aria-hidden": true }), active: r.kind === 'new', onPress: () => go(''), children: "New run" }), _jsx(SidebarItem, { icon: _jsx(Workflow, { size: 16, "aria-hidden": true }), active: screen === 'automations' || r.kind === 'automation', onPress: () => go(path({ kind: 'screen', screen: 'automations' })), children: "Automations" }), _jsx(SidebarItem, { icon: _jsx(FolderGit2, { size: 16, "aria-hidden": true }), active: screen === 'codebases', onPress: () => go(path({ kind: 'screen', screen: 'codebases' })), children: "Codebase" }), _jsx(SidebarItem, { icon: _jsx(Kanban, { size: 16, "aria-hidden": true }), active: screen === 'projects', onPress: () => go(path({ kind: 'screen', screen: 'projects' })), children: "Projects" }), _jsx(SidebarItem, { icon: _jsx(CircleDot, { size: 16, "aria-hidden": true }), active: screen === 'issues', onPress: () => {
48
+ return (_jsxs(_Fragment, { children: [_jsx(SidebarItem, { icon: _jsx(SquarePen, { size: 16, "aria-hidden": true }), active: r.kind === 'new', onPress: () => go(''), children: "New run" }), _jsx(SidebarItem, { icon: _jsx(Workflow, { size: 16, "aria-hidden": true }), active: screen === 'automations' || r.kind === 'automation', onPress: () => go(path({ kind: 'screen', screen: 'automations' })), children: "Automations" }), _jsx(SidebarItem, { icon: _jsx(FolderGit2, { size: 16, "aria-hidden": true }), active: screen === 'projects' || screen === 'sync' || r.kind === 'repo', onPress: () => go(path({ kind: 'screen', screen: 'projects' })), children: "Projects" }), _jsx(SidebarItem, { icon: _jsx(CircleDot, { size: 16, "aria-hidden": true }), active: screen === 'issues', onPress: () => {
49
49
  pinBoard(host.org, '');
50
50
  go(path({ kind: 'screen', screen: 'issues' }));
51
51
  }, children: "Issues" }), _jsx(SidebarItem, { icon: _jsx(SlidersHorizontal, { size: 16, "aria-hidden": true }), active: r.kind === 'customize', onPress: () => go(path({ kind: 'customize', tab: 'connectors' })), children: "Customize" }), _jsx(SidebarItem, { icon: _jsx(Blocks, { size: 16, "aria-hidden": true }), active: screen === 'artifacts', onPress: () => go(path({ kind: 'screen', screen: 'artifacts' })), children: "Artifacts" }), _jsx(SidebarItem, { icon: _jsx(LayoutTemplate, { size: 16, "aria-hidden": true }), active: screen === 'templates', onPress: () => go(path({ kind: 'screen', screen: 'templates' })), children: "Templates" }), children, _jsx(SidebarSection, { label: label, children: !host.person ? (_jsx(Note, { children: "Sign in to see your runs." })) : sessions.value.length ? (sessions.value.map((s) => (_jsx(SidebarItem, { icon: _jsx(StatusDot, { status: DOTS[s.status] ?? 'idle' }), active: r.kind === 'run' && r.id === s.id, onPress: () => go(s.id), children: s.title || 'Untitled run' }, s.id)))) : (_jsx(Note, { children: sessions.error ? sessions.error.message : sessions.loading ? 'Reading…' : 'No runs yet. Say what to build and it lands here.' })) })] }));
@@ -1,9 +1,10 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /**
3
- * API keys: your own keys for calling the Hanzo API from code — one secret key
4
- * for a server, one publishable key for a page's source. Creating one ends the
5
- * one before it, and a secret key is shown once, when it is made. A limit
6
- * narrows what a key reaches; it never reaches further than you do.
3
+ * API keys: your keys for calling the Hanzo API from code, as many as you need —
4
+ * a secret key for a server, a publishable key for a page's source. Each is its
5
+ * own credential: creating one never touches another, and revoking one stops only
6
+ * that one. A secret key is shown once, when it is made. A limit narrows what a
7
+ * key reaches; it never reaches further than you do.
7
8
  */
8
9
  import { SizableText, XStack, YStack } from '@hanzo/gui';
9
10
  import { Button, Dialog, DialogContent, DialogTitle, Input } from '@hanzo/ui';
@@ -15,19 +16,15 @@ import { useRead } from '../data.js';
15
16
  import { useHost, useTarget } from '../host.js';
16
17
  import { Card, Field, Heading, Note, Once, Row, Soft } from './ui.js';
17
18
  const NAME = { secret: 'Secret key', publishable: 'Publishable key' };
18
- const FOR = {
19
- secret: 'For a server. It acts as you, so it never goes in a page.',
20
- publishable: 'Safe in a page’s source.',
21
- };
22
- /** The line under a key: what it is for, how it starts, what it reaches, when it was made. */
23
- function about(type, k) {
24
- if (!k)
25
- return `${FOR[type]} None yet.`;
19
+ /** The line under a key: its head, what it reaches, when it was made, and where it stands. */
20
+ function about(k) {
26
21
  const when = k.created ? new Date(k.created) : null;
27
22
  return [
28
- type === 'secret' && k.prefix ? `${k.prefix}…` : '',
23
+ k.type === 'secret' && k.prefix ? `${k.prefix}…` : '',
24
+ k.type === 'publishable' ? 'Publishable' : '',
29
25
  k.limit.length ? `Reaches ${k.limit.join(', ')}` : 'Unrestricted',
30
26
  when && !Number.isNaN(when.getTime()) ? `Created ${when.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}` : '',
27
+ k.status !== 'active' ? k.status[0].toUpperCase() + k.status.slice(1) : '',
31
28
  ]
32
29
  .filter(Boolean)
33
30
  .join(' · ');
@@ -43,24 +40,21 @@ export function Keys() {
43
40
  if (!signed) {
44
41
  return (_jsxs(YStack, { gap: "$5", children: [_jsx(Heading, { title: "API keys" }), _jsx(Soft, { children: "Sign in to see your API keys." })] }));
45
42
  }
46
- const held = (type) => list.value.find((k) => k.type === type);
47
- return (_jsxs(YStack, { gap: "$5", children: [_jsx(Heading, { title: "API keys", detail: "Your own keys for calling api.hanzo.ai from code. You hold one of each; creating one ends the one before it." }), list.error ? (_jsx(Soft, { children: list.error.message })) : list.loading && list.value.length === 0 ? (_jsx(Soft, { children: "Reading your keys\u2026" })) : (_jsx(Card, { children: KINDS.map((type, i) => {
48
- const k = held(type);
49
- return (_jsxs(YStack, { borderTopWidth: i ? 1 : 0, borderColor: "$borderColor", children: [_jsx(Row, { first: true, title: NAME[type], detail: about(type, k), trailing: _jsxs(XStack, { gap: "$1.5", shrink: 0, children: [k ? (_jsx(Button, { size: "sm", variant: "ghost", "aria-label": `Revoke the ${type} key`, onPress: () => setRevoking(type), children: "Revoke" })) : null, _jsx(Button, { size: "sm", variant: "outline", "aria-label": `${k ? 'Rotate' : 'Create'} the ${type} key`, onPress: () => setMaking(type), children: k ? 'Rotate' : 'Create' })] }) }), type === 'publishable' && k?.key ? (_jsxs(XStack, { items: "center", gap: "$2", px: "$3", pb: "$2.5", children: [_jsx(SizableText, { flex: 1, minW: 0, size: "$1", color: "$soft", numberOfLines: 1, "aria-label": "Publishable key", style: { fontFamily: 'var(--f-mono, ui-monospace, monospace)' }, children: k.key }), _jsx(CopyButton, { value: k.key, label: "Copy publishable key" })] })) : null] }, type));
50
- }) })), _jsx(Note, { children: note }), _jsx(Making, { type: making, rotating: making ? Boolean(held(making)) : false, onClose: () => setMaking(null), onMade: (m) => {
51
- setNote(`${NAME[m.type]} created`);
43
+ return (_jsxs(YStack, { gap: "$5", children: [_jsx(Heading, { title: "API keys", detail: "Your keys for calling api.hanzo.ai from code. Make one for each app; revoking one stops only that one." }), _jsx(XStack, { gap: "$2", children: KINDS.map((type) => (_jsx(Button, { size: "sm", variant: "outline", "aria-label": `Create a ${type} key`, onPress: () => setMaking(type), children: `Create ${NAME[type].toLowerCase()}` }, type))) }), list.error ? (_jsx(Soft, { children: list.error.message })) : list.loading && list.value.length === 0 ? (_jsx(Soft, { children: "Reading your keys\u2026" })) : list.value.length === 0 ? (_jsx(Soft, { children: "No keys yet." })) : (_jsx(Card, { children: list.value.map((k, i) => (_jsxs(YStack, { borderTopWidth: i ? 1 : 0, borderColor: "$borderColor", opacity: k.status === 'active' ? 1 : 0.55, children: [_jsx(Row, { first: true, title: k.name || NAME[k.type], detail: about(k), trailing: k.status === 'revoked' ? null : (_jsx(Button, { size: "sm", variant: "ghost", "aria-label": `Revoke ${k.name || k.id}`, onPress: () => setRevoking(k), children: "Revoke" })) }), k.type === 'publishable' && k.key ? (_jsxs(XStack, { items: "center", gap: "$2", px: "$3", pb: "$2.5", children: [_jsx(SizableText, { flex: 1, minW: 0, size: "$1", color: "$soft", numberOfLines: 1, "aria-label": "Publishable key", style: { fontFamily: 'var(--f-mono, ui-monospace, monospace)' }, children: k.key }), _jsx(CopyButton, { value: k.key, label: "Copy publishable key" })] })) : null] }, k.id))) })), _jsx(Note, { children: note }), _jsx(Making, { type: making, onClose: () => setMaking(null), onMade: (m) => {
44
+ setNote(`${m.name || NAME[m.type]} created`);
52
45
  list.reload();
53
- } }), _jsx(Confirm, { open: revoking !== null, onOpenChange: (o) => !o && setRevoking(null), title: `Revoke your ${revoking ?? 'secret'} key?`, says: "It stops working within a minute, and anything using it is refused until it has a new one.", act: "Revoke",
54
- // It acts only while it is open, which is while a type is chosen.
46
+ } }), _jsx(Confirm, { open: revoking !== null, onOpenChange: (o) => !o && setRevoking(null), title: `Revoke ${revoking?.name || 'this key'}?`, says: "It stops working within a minute, and anything using it is refused. Your other keys keep working.", act: "Revoke",
47
+ // It acts only while it is open, which is while a key is chosen.
55
48
  run: async () => {
56
- await revoke(t, revoking);
57
- setNote(`${NAME[revoking]} revoked`);
49
+ await revoke(t, revoking.id);
50
+ setNote(`${revoking.name || NAME[revoking.type]} revoked`);
58
51
  list.reload();
59
52
  } })] }));
60
53
  }
61
- /** Creating or rotating one key: an optional limit, then the key itself, once. */
62
- function Making({ type, rotating, onClose, onMade }) {
54
+ /** Creating one key: a name and an optional limit, then the key itself, once. */
55
+ function Making({ type, onClose, onMade }) {
63
56
  const t = useTarget();
57
+ const [name, setName] = useState('');
64
58
  const [limit, setLimit] = useState('');
65
59
  const [made, setMade] = useState(null);
66
60
  const [working, setWorking] = useState(false);
@@ -68,6 +62,7 @@ function Making({ type, rotating, onClose, onMade }) {
68
62
  const close = () => {
69
63
  if (working)
70
64
  return;
65
+ setName('');
71
66
  setLimit('');
72
67
  setMade(null);
73
68
  setNote('');
@@ -86,7 +81,7 @@ function Making({ type, rotating, onClose, onMade }) {
86
81
  }
87
82
  setWorking(true);
88
83
  try {
89
- const m = await mint(t, type, list);
84
+ const m = await mint(t, type, name, list);
90
85
  setMade(m);
91
86
  onMade(m);
92
87
  }
@@ -97,5 +92,5 @@ function Making({ type, rotating, onClose, onMade }) {
97
92
  setWorking(false);
98
93
  }
99
94
  };
100
- return (_jsx(Dialog, { open: type !== null, onOpenChange: (o) => !o && close(), children: _jsxs(DialogContent, { maxW: 480, showCloseButton: false, children: [_jsx(DialogTitle, { children: made ? `Your new ${made.type} key` : `${rotating ? 'Rotate' : 'Create'} your ${type ?? ''} key` }), made ? (_jsxs(YStack, { gap: "$3", children: [made.type === 'secret' ? (_jsx(Once, { value: made.key, label: "Secret key" })) : (_jsxs(XStack, { items: "center", gap: "$2", px: "$3", py: "$3", rounded: "$3", borderWidth: 1, borderColor: "$borderColor", bg: "$raised", children: [_jsx(SizableText, { flex: 1, minW: 0, size: "$2", color: "$ink", style: { fontFamily: 'var(--f-mono, ui-monospace, monospace)', wordBreak: 'break-all' }, children: made.key }), _jsx(CopyButton, { value: made.key, label: "Copy publishable key" })] })), made.limit.length ? (_jsxs(SizableText, { size: "$1", color: "$soft", children: ["It reaches ", made.limit.join(', '), " and nothing else."] })) : null, _jsx(XStack, { justify: "flex-end", children: _jsx(Button, { size: "sm", onPress: close, children: "Done" }) })] })) : (_jsxs(YStack, { gap: "$3", children: [rotating ? (_jsxs(SizableText, { size: "$2", color: "$soft", children: ["The ", type, " key you have now stops working."] })) : null, _jsx(Field, { label: "Limit", hint: "Optional. What it may reach, as kind:name \u2014 model:zen5, project:acme, product:commerce. Empty reaches whatever you do.", children: _jsx(Input, { value: limit, onChangeText: setLimit, placeholder: "model:zen5, project:acme", "aria-label": "Limit", autoCapitalize: "none" }) }), note ? (_jsx(SizableText, { size: "$1", color: "$soft", role: "status", children: note })) : null, _jsxs(XStack, { gap: "$2", justify: "flex-end", children: [_jsx(Button, { size: "sm", variant: "ghost", disabled: working, onPress: close, children: "Cancel" }), _jsx(Button, { size: "sm", disabled: working, onPress: () => void go(type), children: rotating ? 'Rotate' : 'Create' })] })] }))] }) }));
95
+ return (_jsx(Dialog, { open: type !== null, onOpenChange: (o) => !o && close(), children: _jsxs(DialogContent, { maxW: 480, showCloseButton: false, children: [_jsx(DialogTitle, { children: made ? `Your new ${made.type} key` : `Create a ${type ?? ''} key` }), made ? (_jsxs(YStack, { gap: "$3", children: [made.type === 'secret' ? (_jsx(Once, { value: made.key, label: "Secret key" })) : (_jsxs(XStack, { items: "center", gap: "$2", px: "$3", py: "$3", rounded: "$3", borderWidth: 1, borderColor: "$borderColor", bg: "$raised", children: [_jsx(SizableText, { flex: 1, minW: 0, size: "$2", color: "$ink", style: { fontFamily: 'var(--f-mono, ui-monospace, monospace)', wordBreak: 'break-all' }, children: made.key }), _jsx(CopyButton, { value: made.key, label: "Copy publishable key" })] })), made.limit.length ? (_jsxs(SizableText, { size: "$1", color: "$soft", children: ["It reaches ", made.limit.join(', '), " and nothing else."] })) : null, _jsx(XStack, { justify: "flex-end", children: _jsx(Button, { size: "sm", onPress: close, children: "Done" }) })] })) : (_jsxs(YStack, { gap: "$3", children: [_jsx(Field, { label: "Name", hint: "What it is for, so you can tell it from your other keys.", children: _jsx(Input, { value: name, onChangeText: setName, placeholder: "production", "aria-label": "Name" }) }), _jsx(Field, { label: "Limit", hint: "Optional. What it may reach, as kind:name \u2014 model:zen5, project:acme, product:train, read:* for read-only. Empty reaches whatever you do.", children: _jsx(Input, { value: limit, onChangeText: setLimit, placeholder: "model:zen5, project:acme", "aria-label": "Limit", autoCapitalize: "none" }) }), note ? (_jsx(SizableText, { size: "$1", color: "$soft", role: "status", children: note })) : null, _jsxs(XStack, { gap: "$2", justify: "flex-end", children: [_jsx(Button, { size: "sm", variant: "ghost", disabled: working, onPress: close, children: "Cancel" }), _jsx(Button, { size: "sm", disabled: working, onPress: () => void go(type), children: "Create" })] })] }))] }) }));
101
96
  }
package/lib/sync.js CHANGED
@@ -77,7 +77,7 @@ export function Sync() {
77
77
  }
78
78
  };
79
79
  const back = () => {
80
- host.go(path({ kind: 'screen', screen: 'codebases' }));
80
+ host.go(path({ kind: 'screen', screen: 'projects' }));
81
81
  };
82
82
  useEffect(() => {
83
83
  if (!syncing)
@@ -102,7 +102,7 @@ export function Sync() {
102
102
  window.clearInterval(id);
103
103
  };
104
104
  }, [syncing, t, host.org, host.go, picked]);
105
- return (_jsxs(YStack, { flex: 1, minH: 0, overflow: "scroll", children: [_jsxs(XStack, { px: "$6", pt: "$5", pb: "$2", gap: "$2", items: "center", children: [_jsx(XStack, { render: "button", "aria-label": "Back to codebases", onPress: back, children: _jsx(SizableText, { size: "$2", color: "$soft", children: "Codebase" }) }), _jsx(SizableText, { size: "$2", color: "$soft", children: "/" }), _jsx(SizableText, { size: "$2", color: "$ink", children: "Sync" })] }), _jsxs(YStack, { flex: 1, items: "center", justify: "center", px: "$6", pb: "$10", gap: "$5", children: [_jsxs(XStack, { justify: "center", gap: "$6", children: [_jsx(Step, { label: "Select repositories", done: step === 'access', current: step === 'pick' }), _jsx(Step, { label: "Repository access", done: false, current: step === 'access' })] }), _jsxs(YStack, { borderWidth: 1, borderColor: "$borderColor", rounded: "$5", bg: "$panel", px: "$6", py: "$6", gap: "$4", width: "100%", maxW: 460, children: [step === 'pick' && !owner ? (_jsxs(_Fragment, { children: [_jsxs(YStack, { gap: "$1", items: "center", children: [_jsx(SizableText, { size: "$5", color: "$ink", children: "Select repositories" }), _jsxs(SizableText, { size: "$2", color: "$soft", style: { textAlign: 'center' }, children: ["Choose repositories to bring into ", dest] })] }), _jsxs(Panel, { children: [_jsx(Input, { value: q, onChangeText: setQ, placeholder: "Search organizations", "aria-label": "Search organizations", disabled: !signed }), signed ? (_jsx(SizableText, { size: "$1", color: "$soft", children: `${orgs.length} ${orgs.length === 1 ? 'organization' : 'organizations'}` })) : null, list.error ? _jsx(Soft, { children: list.error.message }) : null, !signed ? (_jsx(Soft, { children: "Sign in to choose repositories the GitHub connection can read." })) : list.loading && rows.length === 0 && orgs.length === 0 ? (_jsx(Soft, { children: "Reading the connection\u2026" })) : shownOrgs.length === 0 ? (_jsx(Soft, { children: orgs.length === 0 ? 'No organizations on this connection yet.' : 'Nothing matches.' })) : (shownOrgs.map((a) => (_jsxs(XStack, { render: "button", "aria-label": a.blocked ? `${a.name} needs an organization admin` : `Open ${a.name}`, disabled: a.blocked, items: "center", gap: "$2", py: "$2", borderTopWidth: 1, borderColor: "$borderColor", onPress: () => {
105
+ return (_jsxs(YStack, { flex: 1, minH: 0, overflow: "scroll", children: [_jsxs(XStack, { px: "$6", pt: "$5", pb: "$2", gap: "$2", items: "center", children: [_jsx(XStack, { render: "button", "aria-label": "Back to projects", onPress: back, children: _jsx(SizableText, { size: "$2", color: "$soft", children: "Projects" }) }), _jsx(SizableText, { size: "$2", color: "$soft", children: "/" }), _jsx(SizableText, { size: "$2", color: "$ink", children: "Sync" })] }), _jsxs(YStack, { flex: 1, items: "center", justify: "center", px: "$6", pb: "$10", gap: "$5", children: [_jsxs(XStack, { justify: "center", gap: "$6", children: [_jsx(Step, { label: "Select repositories", done: step === 'access', current: step === 'pick' }), _jsx(Step, { label: "Repository access", done: false, current: step === 'access' })] }), _jsxs(YStack, { borderWidth: 1, borderColor: "$borderColor", rounded: "$5", bg: "$panel", px: "$6", py: "$6", gap: "$4", width: "100%", maxW: 460, children: [step === 'pick' && !owner ? (_jsxs(_Fragment, { children: [_jsxs(YStack, { gap: "$1", items: "center", children: [_jsx(SizableText, { size: "$5", color: "$ink", children: "Select repositories" }), _jsxs(SizableText, { size: "$2", color: "$soft", style: { textAlign: 'center' }, children: ["Choose repositories to bring into ", dest] })] }), _jsxs(Panel, { children: [_jsx(Input, { value: q, onChangeText: setQ, placeholder: "Search organizations", "aria-label": "Search organizations", disabled: !signed }), signed ? (_jsx(SizableText, { size: "$1", color: "$soft", children: `${orgs.length} ${orgs.length === 1 ? 'organization' : 'organizations'}` })) : null, list.error ? _jsx(Soft, { children: list.error.message }) : null, !signed ? (_jsx(Soft, { children: "Sign in to choose repositories the GitHub connection can read." })) : list.loading && rows.length === 0 && orgs.length === 0 ? (_jsx(Soft, { children: "Reading the connection\u2026" })) : shownOrgs.length === 0 ? (_jsx(Soft, { children: orgs.length === 0 ? 'No organizations on this connection yet.' : 'Nothing matches.' })) : (shownOrgs.map((a) => (_jsxs(XStack, { render: "button", "aria-label": a.blocked ? `${a.name} needs an organization admin` : `Open ${a.name}`, disabled: a.blocked, items: "center", gap: "$2", py: "$2", borderTopWidth: 1, borderColor: "$borderColor", onPress: () => {
106
106
  setOwner(a.name);
107
107
  setQ('');
108
108
  }, children: [_jsx(SizableText, { flex: 1, size: "$2", color: a.blocked ? '$soft' : '$ink', numberOfLines: 1, children: a.name }), a.blocked ? (_jsx(SizableText, { size: "$1", color: "$soft", children: "Needs organization admin" })) : (_jsx(ChevronRight, { size: 14 }))] }, a.name)))), _jsxs(XStack, { render: "button", "aria-label": "Add organization", items: "center", gap: "$2", py: "$2", onPress: () => void addOrg(), disabled: !signed, children: [_jsx(Plus, { size: 14 }), _jsx(SizableText, { size: "$2", color: "$ink", children: "Add organization" })] })] })] })) : null, step === 'pick' && owner ? (_jsxs(_Fragment, { children: [_jsxs(YStack, { gap: "$1", items: "center", children: [_jsx(SizableText, { size: "$5", color: "$ink", children: "Select repositories" }), _jsxs(SizableText, { size: "$2", color: "$soft", style: { textAlign: 'center' }, children: ["Choose repositories to bring into ", dest] })] }), _jsxs(Panel, { children: [_jsxs(XStack, { items: "center", gap: "$2", children: [_jsx(XStack, { render: "button", "aria-label": "All organizations", onPress: () => setOwner(''), children: _jsx(ChevronLeft, { size: 16 }) }), _jsx(SizableText, { flex: 1, size: "$2", color: "$ink", numberOfLines: 1, children: owner })] }), _jsx(Input, { value: q, onChangeText: setQ, placeholder: "Search repositories", "aria-label": "Search repositories" }), _jsxs(XStack, { justify: "space-between", items: "center", children: [_jsxs(SizableText, { size: "$1", color: "$soft", children: [ofOwner.length, " ", ofOwner.length === 1 ? 'repository' : 'repositories'] }), _jsx(XStack, { render: "button", "aria-label": "Select all", onPress: selectShown, disabled: shownRepos.length === 0, children: _jsx(SizableText, { size: "$1", color: "$ink", children: "Select all" }) })] }), shownRepos.length === 0 ? (_jsx(Soft, { children: "Nothing matches." })) : (shownRepos.map((r) => (_jsx(RepoRow, { repo: r, on: picked.includes(r.fullName), onPress: () => toggle(r.fullName) }, r.fullName)))), _jsxs(XStack, { render: "button", "aria-label": "Grant more repositories", items: "center", gap: "$2", py: "$2", onPress: () => void addOrg(), children: [_jsx(Plus, { size: 14 }), _jsx(SizableText, { size: "$2", color: "$ink", children: "Add repositories" })] })] })] })) : null, step === 'access' ? (_jsxs(_Fragment, { children: [_jsxs(YStack, { gap: "$1", items: "center", children: [_jsx(SizableText, { size: "$5", color: "$ink", children: "Repository access" }), _jsx(SizableText, { size: "$2", color: "$soft", style: { textAlign: 'center' }, children: "Review who can read these repositories" })] }), _jsxs(XStack, { gap: "$2", items: "center", children: [_jsxs(XStack, { flex: 1, items: "center", gap: "$2", px: "$3", py: "$2", rounded: "$3", borderWidth: 1, borderColor: "$borderColor", children: [_jsx(XStack, { render: "button", "aria-label": "Clear the selection", onPress: () => { setPicked([]); setStep('pick'); }, children: _jsx(X, { size: 12 }) }), _jsx(SizableText, { flex: 1, size: "$2", color: "$ink", numberOfLines: 1, children: more ? `${lead}, +${more} more` : lead })] }), _jsx(Button, { size: "sm", variant: "outline", onPress: () => setStep('pick'), children: "Edit" })] }), _jsxs(XStack, { justify: "space-between", gap: "$4", children: [_jsxs(YStack, { gap: "$1", children: [_jsx(SizableText, { size: "$1", color: "$soft", children: "Who gets access" }), _jsxs(SizableText, { size: "$2", color: "$ink", children: [host.person?.name || host.person?.email || 'You', " \u00B7 You"] })] }), _jsxs(YStack, { gap: "$1", items: "flex-end", children: [_jsx(SizableText, { size: "$1", color: "$soft", children: "Role" }), _jsx(SizableText, { size: "$2", color: "$ink", children: host.admin ? 'Repository admin' : 'Member' })] })] }), _jsxs(SizableText, { size: "$2", color: "$soft", style: { textAlign: 'center' }, children: ["Anyone with access to ", dest] })] })) : null, error ? _jsx(Soft, { children: error }) : null, step === 'pick' ? (_jsx(Button, { width: "100%", disabled: !owner || picked.length === 0, onPress: () => setStep('access'), children: "Continue" })) : (_jsxs(YStack, { gap: "$3", items: "center", width: "100%", children: [_jsx(Button, { width: "100%", disabled: busy || syncing || picked.length === 0, onPress: () => void send(), children: syncing || busy ? 'Syncing…' : `Sync ${picked.length} ${picked.length === 1 ? 'repository' : 'repositories'} to ${dest}` }), syncing ? null : (_jsx(XStack, { render: "button", "aria-label": "Sync later", onPress: back, children: _jsx(SizableText, { size: "$2", color: "$soft", children: "Sync later" }) }))] })), !signed ? (_jsx(Button, { width: "100%", onPress: () => host.signIn?.(), children: "Sign in" })) : null] })] })] }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanzo/build",
3
- "version": "0.2.50",
3
+ "version": "0.2.52",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0 OR MIT",
6
6
  "description": "Hanzo Build — say what you want, watch it built, deployed and running. The builder as a component, mounted by the Hanzo app at hanzo.ai.",