@hanzo/build 0.2.52 → 0.2.54

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.
Files changed (96) hide show
  1. package/README.md +103 -49
  2. package/lib/api/billing.d.ts +39 -0
  3. package/lib/api/billing.js +41 -0
  4. package/lib/api/coding.d.ts +4 -1
  5. package/lib/api/coding.js +2 -0
  6. package/lib/api/environment.d.ts +12 -0
  7. package/lib/api/environment.js +39 -1
  8. package/lib/api/github.d.ts +24 -4
  9. package/lib/api/github.js +60 -16
  10. package/lib/api/limits.d.ts +69 -0
  11. package/lib/api/limits.js +86 -0
  12. package/lib/api/links.d.ts +42 -0
  13. package/lib/api/links.js +65 -0
  14. package/lib/api/pref.d.ts +5 -3
  15. package/lib/api/pref.js +0 -3
  16. package/lib/api/turn.d.ts +10 -1
  17. package/lib/api/turn.js +16 -4
  18. package/lib/api/work.d.ts +75 -38
  19. package/lib/api/work.js +132 -44
  20. package/lib/builder.d.ts +0 -10
  21. package/lib/builder.js +12 -52
  22. package/lib/choice.d.ts +21 -0
  23. package/lib/choice.js +40 -1
  24. package/lib/customize/agents.d.ts +1 -1
  25. package/lib/customize/agents.js +21 -19
  26. package/lib/customize/connectors.d.ts +1 -1
  27. package/lib/customize/connectors.js +80 -31
  28. package/lib/customize/index.d.ts +2 -0
  29. package/lib/customize/index.js +23 -19
  30. package/lib/customize/load.d.ts +9 -0
  31. package/lib/customize/load.js +22 -0
  32. package/lib/customize/plugins.d.ts +1 -1
  33. package/lib/customize/plugins.js +21 -19
  34. package/lib/customize/say.d.ts +1 -0
  35. package/lib/customize/say.js +38 -0
  36. package/lib/customize/skills.d.ts +1 -1
  37. package/lib/customize/skills.js +23 -17
  38. package/lib/customize/ui.d.ts +28 -11
  39. package/lib/customize/ui.js +29 -21
  40. package/lib/data.d.ts +13 -0
  41. package/lib/data.js +70 -0
  42. package/lib/desk.d.ts +6 -1
  43. package/lib/desk.js +43 -19
  44. package/lib/environment.d.ts +11 -3
  45. package/lib/environment.js +53 -13
  46. package/lib/foot.js +10 -6
  47. package/lib/forge.js +139 -71
  48. package/lib/index.d.ts +28 -2
  49. package/lib/index.js +26 -2
  50. package/lib/landing.d.ts +0 -10
  51. package/lib/landing.js +70 -38
  52. package/lib/merge.d.ts +223 -0
  53. package/lib/merge.js +380 -0
  54. package/lib/meter.d.ts +31 -0
  55. package/lib/meter.js +45 -0
  56. package/lib/mind.d.ts +60 -0
  57. package/lib/mind.js +187 -0
  58. package/lib/nav.d.ts +11 -0
  59. package/lib/nav.js +75 -0
  60. package/lib/panel.d.ts +46 -0
  61. package/lib/panel.js +116 -0
  62. package/lib/pick.d.ts +8 -0
  63. package/lib/pick.js +18 -1
  64. package/lib/plan.d.ts +78 -0
  65. package/lib/plan.js +133 -0
  66. package/lib/project.js +16 -6
  67. package/lib/prompt.d.ts +75 -0
  68. package/lib/prompt.js +125 -0
  69. package/lib/prose.js +14 -10
  70. package/lib/reply.d.ts +36 -0
  71. package/lib/reply.js +124 -0
  72. package/lib/route.d.ts +16 -0
  73. package/lib/route.js +40 -0
  74. package/lib/run.js +87 -52
  75. package/lib/section.d.ts +1 -1
  76. package/lib/section.js +7 -11
  77. package/lib/settings/billing.js +16 -9
  78. package/lib/settings/code.js +15 -12
  79. package/lib/settings/credits.d.ts +9 -0
  80. package/lib/settings/credits.js +110 -0
  81. package/lib/settings/plan.d.ts +17 -0
  82. package/lib/settings/plan.js +108 -0
  83. package/lib/settings/usage.js +11 -119
  84. package/lib/shelf.js +3 -3
  85. package/lib/standing.d.ts +44 -0
  86. package/lib/standing.js +64 -0
  87. package/lib/sync.js +22 -8
  88. package/lib/tabs.d.ts +53 -0
  89. package/lib/tabs.js +197 -0
  90. package/lib/tools.d.ts +2 -9
  91. package/lib/tools.js +20 -25
  92. package/lib/transcript.d.ts +10 -1
  93. package/lib/transcript.js +25 -10
  94. package/lib/voice.d.ts +2 -13
  95. package/lib/voice.js +7 -73
  96. package/package.json +20 -3
package/lib/reply.js ADDED
@@ -0,0 +1,124 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * What sits under an answer, the same in Chat and Dev: copy what was written,
4
+ * listen to it, and open what it made in the side panel. A surface adds its own
5
+ * after these (Dev's verdict).
6
+ *
7
+ * An answer's artifacts are its fenced blocks (`artifacts`): a page, a picture,
8
+ * a file it wrote out whole. One opens as a page tab in the side panel, rendered
9
+ * from its own bytes (`blob`).
10
+ */
11
+ import { SizableText, XStack } from '@hanzo/gui';
12
+ import { Check, Copy, PanelRight, Square, Volume2 } from '@hanzogui/lucide-icons-2';
13
+ import { mouth, speech } from '@hanzo/voice';
14
+ import { useEffect, useRef, useState } from 'react';
15
+ const FENCE = /```([^\n`]*)\n([\s\S]*?)```/g;
16
+ const MIME = {
17
+ html: 'text/html',
18
+ htm: 'text/html',
19
+ svg: 'image/svg+xml',
20
+ css: 'text/css',
21
+ csv: 'text/csv',
22
+ json: 'application/json',
23
+ md: 'text/markdown',
24
+ markdown: 'text/markdown',
25
+ txt: 'text/plain',
26
+ };
27
+ const suffix = (s) => {
28
+ const dot = s.lastIndexOf('.');
29
+ return dot > 0 ? s.slice(dot + 1).toLowerCase() : '';
30
+ };
31
+ /** An info string is a file name (a dot, no spaces) or a language. */
32
+ const called = (info, n) => {
33
+ const word = info.trim().split(/\s+/)[0] ?? '';
34
+ if (!word)
35
+ return `Block ${n}`;
36
+ if (word.includes('.') && suffix(word))
37
+ return word;
38
+ return `${word} block`;
39
+ };
40
+ /** Every fenced block in `text`, in order. */
41
+ export function artifacts(text) {
42
+ const out = [];
43
+ let n = 0;
44
+ for (const [, info = '', body = ''] of text.matchAll(FENCE)) {
45
+ n += 1;
46
+ const name = called(info, n);
47
+ out.push({ name, body, mime: MIME[suffix(name) || info.trim().toLowerCase().split(/\s+/)[0] || ''] ?? 'text/plain' });
48
+ }
49
+ return out;
50
+ }
51
+ /** Whether a browser draws it as a page or a picture rather than as source. */
52
+ export const renders = (a) => a.mime === 'text/html' || a.mime === 'image/svg+xml';
53
+ /** The artifact worth opening first: the last page or picture, else the last block. */
54
+ export function lead(list) {
55
+ return [...list].reverse().find(renders) ?? list[list.length - 1] ?? null;
56
+ }
57
+ /** An address for an artifact's own bytes, typed so a browser renders it. */
58
+ export const blob = (a) => URL.createObjectURL(new Blob([a.body], { type: a.mime }));
59
+ /** Markdown as a sentence a voice can read: code said as code, marks dropped. */
60
+ export function speakable(raw) {
61
+ return raw
62
+ .replace(/```[\s\S]*?```/g, ' [code snippet] ')
63
+ .replace(/`([^`]+)`/g, '$1')
64
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
65
+ .replace(/^#{1,6}\s+/gm, '')
66
+ .replace(/[*_]{1,3}([^*_]+)[*_]{1,3}/g, '$1')
67
+ .replace(/^>\s+/gm, '')
68
+ .replace(/^[-*_]{3,}\s*$/gm, '')
69
+ .replace(/^[\s]*[-+*]\s+/gm, '')
70
+ .replace(/\n+/g, '. ')
71
+ .replace(/\s+/g, ' ')
72
+ .trim();
73
+ }
74
+ let hush = null;
75
+ /** A reader on the platform's voice (`/v1/audio/speech`), one reply at a time on the page. */
76
+ export function speaker(t) {
77
+ return (text, done) => {
78
+ hush?.();
79
+ const said = speakable(text);
80
+ if (!said) {
81
+ done();
82
+ return () => { };
83
+ }
84
+ const lips = mouth({ speech: speech({ baseUrl: t.api, token: t.token }) });
85
+ const stop = () => {
86
+ lips.hush();
87
+ if (hush === stop)
88
+ hush = null;
89
+ };
90
+ hush = stop;
91
+ lips.say(said).then(done, done);
92
+ return stop;
93
+ };
94
+ }
95
+ /** One quiet control in the row. It rests dim and comes up under a pointer or focus. */
96
+ function Act({ label, onPress, children, lit = false }) {
97
+ return (_jsx(XStack, { render: "button", role: "button", "aria-label": label, onPress: onPress, items: "center", gap: "$1", px: "$1", height: 24, rounded: "$2", cursor: "pointer", opacity: lit ? 1 : 0.55, hoverStyle: { opacity: 1, bg: '$hover' }, focusStyle: { opacity: 1 }, children: children }));
98
+ }
99
+ /** Copy, Listen and Open under an answer; `children` follow them. */
100
+ export function Reply({ text, listen, onOpen, children }) {
101
+ const [took, setTook] = useState(false);
102
+ const [speaking, setSpeaking] = useState(false);
103
+ const stop = useRef(null);
104
+ useEffect(() => {
105
+ if (!took)
106
+ return;
107
+ const t = setTimeout(() => setTook(false), 1400);
108
+ return () => clearTimeout(t);
109
+ }, [took]);
110
+ useEffect(() => () => stop.current?.(), []);
111
+ return (_jsxs(XStack, { "data-slot": "reply", ml: "$-1", items: "center", gap: "$1.5", flexWrap: "wrap", children: [_jsx(Act, { label: took ? 'Copied' : 'Copy this answer', lit: took, onPress: () => void navigator.clipboard?.writeText(text).then(() => setTook(true), () => { }), children: took ? _jsx(Check, { size: 13 }) : _jsx(Copy, { size: 13 }) }), listen ? (_jsxs(Act, { label: speaking ? 'Stop reading' : 'Listen to this answer', lit: speaking, onPress: () => {
112
+ if (speaking) {
113
+ stop.current?.();
114
+ setSpeaking(false);
115
+ return;
116
+ }
117
+ setSpeaking(true);
118
+ stop.current = listen(text, () => setSpeaking(false));
119
+ }, children: [speaking ? _jsx(Square, { size: 12 }) : _jsx(Volume2, { size: 13 }), _jsx(SizableText, { size: "$1", color: "$ink", children: speaking ? 'Stop' : 'Listen' })] })) : null, onOpen ? (_jsxs(Act, { label: "Open in the side panel", onPress: onOpen, children: [_jsx(PanelRight, { size: 13 }), _jsx(SizableText, { size: "$1", color: "$ink", children: "Open" })] })) : null, children] }));
120
+ }
121
+ /** Which model answered, where the gateway says it was not the one asked for or a router. */
122
+ export function Served({ served, asked }) {
123
+ return (_jsx(SizableText, { size: "$1", color: "$soft", "data-slot": "served", children: `answered by ${served}${asked && asked !== served ? ` · ${asked} was unavailable` : ''}` }));
124
+ }
package/lib/route.d.ts CHANGED
@@ -15,6 +15,16 @@
15
15
  * so it can hold neither the `_` a session id carries nor start with `-`, and a
16
16
  * repository is the one address with two segments, each starting with a letter
17
17
  * or a digit. Anything else is not an address here and reads as the empty state.
18
+ *
19
+ * A host whose address bar shows real paths writes a route with `href` and
20
+ * reads one back with `parse` (hanzo.ai/dev/<href>):
21
+ *
22
+ * '' New
23
+ * /run/<id> one run
24
+ * /projects/<org>/<repo> a repository's workspace
25
+ * /projects/<slug> a deployed site's workspace
26
+ * /machines · /environments the rail's own words for -/settings/machines and -/settings/environments
27
+ * /<rest> every other address, its `-/` dropped: /projects, /customize/connectors, /settings/usage
18
28
  */
19
29
  export type Screen = 'artifacts' | 'templates' | 'projects' | 'issues' | 'automations' | 'sync' | 'mcp' | 'plans';
20
30
  /** Settings, one section each. Every setting the builder has lives on this page, none elsewhere. */
@@ -55,5 +65,11 @@ export declare const NAME: RegExp;
55
65
  /** An automation's id, or `new` for one not yet written. */
56
66
  export declare const AUTOMATION: RegExp;
57
67
  export declare function route(path: string): Route;
68
+ /** Every route that names no record, for a host that writes a page per address ahead of time. */
69
+ export declare const PLACES: readonly Route[];
70
+ /** A route as a real path under the host's mount: '' for New, `/run/<id>`, `/projects/<org>/<repo>`, `/projects/<slug>`, else its path without `-/`. */
71
+ export declare function href(r: Route): string;
72
+ /** The route a real path under the mount names — the inverse of `href`. A query or fragment is not part of it. */
73
+ export declare function parse(address: string): Route;
58
74
  /** The path for a route — the inverse of `route`. */
59
75
  export declare function path(r: Route): string;
package/lib/route.js CHANGED
@@ -51,6 +51,46 @@ export function route(path) {
51
51
  return { kind: 'repo', org, name };
52
52
  return { kind: 'new' };
53
53
  }
54
+ /** Every route that names no record, for a host that writes a page per address ahead of time. */
55
+ export const PLACES = [
56
+ { kind: 'new' },
57
+ { kind: 'automation', id: 'new' },
58
+ ...SCREENS.map((screen) => ({ kind: 'screen', screen })),
59
+ ...SECTIONS.map((section) => ({ kind: 'settings', section })),
60
+ ...TABS.map((tab) => ({ kind: 'customize', tab })),
61
+ ];
62
+ /** The places a real path names in the rail's words rather than the builder's: each rail row is a path of its own. */
63
+ const WORDS = new Map([
64
+ ['-/settings/machines', 'machines'],
65
+ ['-/settings/environments', 'environments'],
66
+ ]);
67
+ const NAMES = new Map([...WORDS].map(([p, word]) => [word, p]));
68
+ /** A route as a real path under the host's mount: '' for New, `/run/<id>`, `/projects/<org>/<repo>`, `/projects/<slug>`, else its path without `-/`. */
69
+ export function href(r) {
70
+ if (r.kind === 'run')
71
+ return `/run/${r.id}`;
72
+ if (r.kind === 'repo')
73
+ return `/projects/${r.org}/${r.name}`;
74
+ if (r.kind === 'project')
75
+ return `/projects/${r.slug}`;
76
+ const p = path(r);
77
+ return p && `/${WORDS.get(p) ?? p.slice(2)}`;
78
+ }
79
+ /** The route a real path under the mount names — the inverse of `href`. A query or fragment is not part of it. */
80
+ export function parse(address) {
81
+ const p = address.split(/[?#]/)[0].replace(/^\/+|\/+$/g, '');
82
+ const [head, one, two, ...rest] = p.split('/');
83
+ if (head === 'run')
84
+ return one && !two && SESSION.test(one) ? { kind: 'run', id: one } : { kind: 'new' };
85
+ if (head === 'projects' && one) {
86
+ if (rest.length)
87
+ return { kind: 'new' };
88
+ if (two)
89
+ return NAME.test(one) && NAME.test(two) ? { kind: 'repo', org: one, name: two } : { kind: 'new' };
90
+ return SLUG.test(one) ? { kind: 'project', slug: one } : { kind: 'new' };
91
+ }
92
+ return p ? route(NAMES.get(p) ?? `-/${p}`) : { kind: 'new' };
93
+ }
54
94
  /** The path for a route — the inverse of `route`. */
55
95
  export function path(r) {
56
96
  switch (r.kind) {
package/lib/run.js CHANGED
@@ -16,45 +16,46 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
16
16
  * (apps/coding steer.go), so what continues it is a new run: a paused run is
17
17
  * carried on, and a finished one followed up, by `POST /v1/agent/coding` from
18
18
  * the branch its work was kept on — the platform's own continue, done here —
19
- * with the model and effort the composer's foot shows, which New shares.
19
+ * with the model and effort the composer shows (mind.tsx), which Chat and New share.
20
20
  *
21
21
  * It reads as a chat: the person's ask opens it, each thing the agent said can
22
22
  * be copied and judged (a verdict is `build.verdict` on the event bus, as a
23
23
  * project's turns send it), and the title is the menu of what can be done to
24
24
  * the run. Share opens its story to the public build route.
25
25
  */
26
- import { SizableText, XStack, YStack } from '@hanzo/gui';
27
- import { ChevronDown, ChevronRight, Copy, ExternalLink, GitPullRequest, Globe, PanelRight, Pencil, Share } from '@hanzogui/lucide-icons-2';
26
+ import { SizableText, useMedia, XStack, YStack } from '@hanzo/gui';
27
+ import { ChevronDown, ChevronRight, Copy, ExternalLink, GitPullRequest, Globe, Pencil, Share } from '@hanzogui/lucide-icons-2';
28
28
  import { Button, Dialog, DialogContent, DialogTitle, DropdownMenu, Input } from '@hanzo/ui';
29
29
  import { Steer } from '@hanzo/ui/agents';
30
- import { Composer } from '@hanzo/ui/chat';
31
- import { ChipSelect } from '@hanzo/ui/product';
32
- import { ModelPicker } from '@hanzo/ui/models';
30
+ import { Failure } from '@hanzo/ui/chat';
33
31
  import { useLimits } from '@hanzo/ui/product/useLimits';
32
+ import { useDictation, useVoice } from '@hanzo/voice';
34
33
  import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
35
34
  import { approve, followUp, headline, retry, start } from './api/coding.js';
36
- import { ENSO, limits as readLimits, models } from './api/models.js';
37
- import { usePick } from './pick.js';
35
+ import { limits as readLimits } from './api/models.js';
38
36
  import { list, message, pause, publish, rename, resume, stop, story, took } from './api/sessions.js';
39
37
  import { answer, cards, outcome, pull, settled, steps } from './api/turn.js';
40
38
  import { verdict } from './api/verdict.js';
41
39
  import { useKept, useProjects, useRead, useRun } from './data.js';
42
- import { Desk } from './desk.js';
40
+ import { Desk, SEED } from './desk.js';
43
41
  import { Grip } from './grip.js';
44
42
  import { useHost, useTarget } from './host.js';
45
- import { EFFORTS } from './landing.js';
43
+ import { useMind } from './mind.js';
46
44
  import { Out } from './out.js';
47
45
  import { gap, pane } from './pane.js';
48
- import { usePrefs } from './prefs.js';
46
+ import { measure, Prompt, useEar } from './prompt.js';
47
+ import { useSpoken } from './voice.js';
48
+ import { speaker } from './reply.js';
49
+ import { PanelToggle } from './panel.js';
50
+ import { PAGE, useDeck, useKey, usePanel } from './tabs.js';
51
+ import { compose, Files, pick } from './tools.js';
49
52
  import { Transcript } from './transcript.js';
50
53
  const LIVE = new Set(['running', 'paused', '']);
51
- // What this browser kept is read as it is found: a stored value that is not a string is none.
52
- const text = (v) => (typeof v === 'string' ? v : '');
53
- /** The side pane's width, px: its default, the floor a drag holds, and what the transcript keeps beside it. */
54
+ /** The side panel's width, px: its default, the floor a drag holds, and what the transcript keeps beside it. */
54
55
  const DESK_WIDTH = 480;
55
56
  const DESK_FLOOR = 240;
56
57
  const TALK_FLOOR = 300;
57
- /** The most the side pane is drawn at before its row is measured, px. */
58
+ /** The most the side panel is drawn at before its row is measured, px. */
58
59
  const DESK_CEIL = 960;
59
60
  /**
60
61
  * How wide an element is drawn, px, read before the first paint and again as it
@@ -82,17 +83,22 @@ export function Run({ id }) {
82
83
  const signed = Boolean(host.person);
83
84
  const { detail, record, events, status, refused } = useRun(t, signed ? id : null);
84
85
  const [draft, setDraft] = useState('');
86
+ const [files, setFiles] = useState([]);
85
87
  const [note, setNote] = useState('');
86
88
  const [busy, setBusy] = useState(false);
87
89
  const [notify, setNotify] = useState(false);
88
- const [desk, setDesk] = useKept('hanzo.build.desk', true);
89
- // The side pane's width is the reader's, kept, and gives way so the
90
+ // The side panel: open or shut as Dev was left, its tabs as they were arranged.
91
+ const wide = useMedia().md;
92
+ const [desk, setDesk] = usePanel('dev', true, wide);
93
+ const side = useDeck('dev', SEED);
94
+ useKey(() => setDesk(!desk));
95
+ // The side panel's width is the reader's, kept, and gives way so the
90
96
  // transcript keeps its floor: bounded by the row it is drawn in once that is
91
97
  // measured, and by its own `maxWidth` until then.
92
- const [side, setSide] = useKept('hanzo.build.desk.span', DESK_WIDTH);
93
- const [room, measure] = useWidth();
98
+ const [reach, setReach] = useKept('hanzo.build.desk.span', DESK_WIDTH);
99
+ const [room, size] = useWidth();
94
100
  const most = room ? Math.max(DESK_FLOOR, room - TALK_FLOOR) : DESK_CEIL;
95
- const across = Math.round(Math.min(most, Math.max(DESK_FLOOR, typeof side === 'number' ? side : DESK_WIDTH)));
101
+ const across = Math.round(Math.min(most, Math.max(DESK_FLOOR, typeof reach === 'number' ? reach : DESK_WIDTH)));
96
102
  // A drag repaints the pane's width directly and tells React once, on letting go.
97
103
  const deskBox = useRef(null);
98
104
  // Stop was pressed here: the run keeps its work after it answers, and says so with its status.
@@ -101,13 +107,11 @@ export function Run({ id }) {
101
107
  const [name, setName] = useState('');
102
108
  const [copied, setCopied] = useState('');
103
109
  const [unfolded, setUnfolded] = useState(false);
104
- // The model and effort a run started here uses: New's choice, which a change here changes too.
105
- const { prefs } = usePrefs();
106
- const [chose, choose] = useKept(`hanzo.build.new.${host.org ?? 'none'}`, null);
107
- const catalog = useRead(signed ? () => models(t) : null, [], [t, signed]);
110
+ // The model and effort a run started here uses: the one choice Chat and New share.
111
+ const mind = useMind(t);
108
112
  const { limits } = useLimits(signed ? (signal) => readLimits(t, signal) : null, t);
109
- const [model, pickModel] = usePick(text(chose?.model) || prefs.code?.model || ENSO, (id) => choose({ ...chose, model: id }), catalog.value);
110
- const pace = EFFORTS.find((e) => e.id === chose?.effort) ?? EFFORTS.find((e) => e.id === prefs.code?.effort) ?? EFFORTS[1];
113
+ const voice = useVoice({ speech: useEar(t), language: useSpoken(), ...useDictation(draft, setDraft) });
114
+ const listen = useMemo(() => speaker(t), [t]);
111
115
  const mode = record?.mode ?? '';
112
116
  // A run that narrated no ask of its own opens with the one its title records, once it has said anything.
113
117
  const shown = useMemo(() => {
@@ -128,6 +132,15 @@ export function Run({ id }) {
128
132
  if (end.published)
129
133
  rebuilt();
130
134
  }, [end.published, rebuilt]);
135
+ // What a run publishes opens in the panel: the Preview, chosen and shown.
136
+ useEffect(() => {
137
+ if (!end.published)
138
+ return;
139
+ side.open({ kind: 'preview', title: 'Preview' });
140
+ setDesk(true);
141
+ // On each publish, not on each render.
142
+ // eslint-disable-next-line react-hooks/exhaustive-deps
143
+ }, [end.published]);
131
144
  const site = built.value.find((p) => p.slug === record?.project)?.live ?? '';
132
145
  const pr = pull(record?.pr ?? '', record?.repo ?? '');
133
146
  const live = LIVE.has(state);
@@ -207,8 +220,24 @@ export function Run({ id }) {
207
220
  setBusy(false);
208
221
  }
209
222
  };
210
- /** A new run's ask with the model and effort the foot shows. The router is the platform's default, so it is not named. */
211
- const tuned = (a) => ({ ...a, model: model === ENSO ? undefined : model, effort: pace.id });
223
+ /**
224
+ * A new run's ask with the model and effort the composer shows, Enso named as
225
+ * itself: a run that names none takes the person's saved coding default
226
+ * (apps/coding start.go), which is not what the composer shows.
227
+ */
228
+ const tuned = (a) => ({ ...a, model: mind.model, effort: mind.effort });
229
+ // A setup run's work IS its environment: the panel opens on it.
230
+ useEffect(() => {
231
+ if (mode === 'setup')
232
+ side.open({ kind: 'environment', title: 'Environment' });
233
+ // Once the run says what it is.
234
+ // eslint-disable-next-line react-hooks/exhaustive-deps
235
+ }, [mode]);
236
+ /** An answer's artifact, open in the panel as a page rendered from its own bytes. */
237
+ const look = (a) => {
238
+ side.open({ kind: PAGE, title: a.name, body: a.body, type: a.mime });
239
+ setDesk(true);
240
+ };
212
241
  /**
213
242
  * A new run from this one's work, opened. A paused run it carries on is let go
214
243
  * first: the new run works in the same sandbox, where nothing else may be at work.
@@ -221,16 +250,18 @@ export function Run({ id }) {
221
250
  const ask = how === 'approve' ? approve(earlier, said) : how === 'retry' ? retry(earlier, said) : followUp(earlier, said);
222
251
  const run = await start(t, tuned(ask));
223
252
  setDraft('');
253
+ setFiles([]);
224
254
  host.go(run.session);
225
255
  return '';
226
256
  };
227
- // The composer sends only words, and only while it is enabled.
257
+ // The composer sends words, with any files fenced after them, and only while it is enabled.
228
258
  const send = () => {
229
- const words = draft.trim();
259
+ const words = compose(draft.trim(), files);
230
260
  if (steering) {
231
261
  void act(async () => {
232
262
  await message(t, id, words);
233
263
  setDraft('');
264
+ setFiles([]);
234
265
  return 'Sent — recorded on this run';
235
266
  });
236
267
  return;
@@ -325,9 +356,11 @@ export function Run({ id }) {
325
356
  : finished
326
357
  ? 'Follow up — continues in a new run'
327
358
  : 'Reading this run…';
328
- // What a new run from here is made with; words that steer this run use neither.
329
- const tuning = signed && !steering ? (_jsxs(XStack, { flex: 1, items: "center", gap: "$2", justify: "flex-end", flexWrap: "wrap", rowGap: "$1", children: [_jsx(ModelPicker, { quiet: true, size: "sm", name: "Model", models: catalog.value, scope: "chat", limits: limits, value: model, onChange: pickModel, loading: catalog.loading, error: catalog.error?.message ?? null }), _jsx(ChipSelect, { quiet: true, name: "Effort", label: pace.label, chosen: pace, items: EFFORTS, onChange: (e) => choose({ ...chose, effort: e.id }), placement: "top-end", width: 180 })] })) : undefined;
330
- return (_jsxs(XStack, { ref: measure, "data-slot": "run-split", flex: 1, minH: 0, minW: 0, width: "100%", children: [_jsxs(YStack, { "data-slot": "run", ...pane, flex: 1, minH: 0, minW: 0, width: "100%", px: "$6", "$max-md": { px: '$4' }, children: [_jsxs(XStack, { pt: "$3", pb: "$2", gap: "$3", items: "center", minH: 44, children: [_jsxs(YStack, { flex: 1, minW: 0, items: "flex-start", children: [_jsx(DropdownMenu, { trigger: _jsxs(XStack, { render: "button", "aria-label": "Manage this run", items: "center", gap: "$1.5", maxW: "100%", px: "$1.5", mx: "$-1.5", py: "$0.5", rounded: "$2", hoverStyle: { bg: '$hover' }, children: [_jsx(SizableText, { render: "h1", size: "$5", color: "$ink", numberOfLines: 1, minW: 0, children: title }), _jsx(ChevronDown, { size: 14 })] }), items: manage }), _jsx(SizableText, { size: "$1", color: "$soft", numberOfLines: 1, maxW: "100%", children: [state || (detail.loading ? 'reading' : ''), record?.repo, record?.branch, record?.published ? 'shared' : ''].filter(Boolean).join(' · ') })] }), desk ? null : (_jsx(XStack, { render: "button", "aria-label": "Show the side pane", px: "$2", py: "$1", rounded: "$2", hoverStyle: { bg: '$hover' }, onPress: () => setDesk(true), "$max-md": { display: 'none' }, children: _jsx(PanelRight, { size: 16 }) })), pr.href ? (_jsx(Out, { href: pr.href, label: `Open pull request ${pr.label}`, children: _jsxs(XStack, { items: "center", gap: "$1.5", px: "$2.5", py: "$1.5", rounded: "$3", borderWidth: 1, borderColor: "$borderColor", hoverStyle: { bg: '$hover' }, children: [_jsx(GitPullRequest, { size: 14 }), _jsx(SizableText, { size: "$2", color: "$ink", children: pr.label }), _jsx(ExternalLink, { size: 12, opacity: 0.6 })] }) })) : null, sharing.length ? (_jsx(DropdownMenu, { trigger: _jsxs(XStack, { render: "button", "aria-label": "Share", items: "center", gap: "$1.5", px: "$2.5", py: "$1.5", rounded: "$3", borderWidth: 1, borderColor: "$borderColor", hoverStyle: { bg: '$hover' }, children: [_jsx(Share, { size: 14 }), _jsx(SizableText, { size: "$2", color: "$ink", children: "Share" })] }), items: sharing })) : null] }), _jsx(Transcript, { cards: shown,
359
+ const desk_ = (sheet) => (_jsx(Desk, { id: id, repo: record?.repo ?? '', branch: record?.branch ?? '', base: record?.base ?? '', environment: record?.environment ?? '', mode: mode, pr: pr, title: title, project: record?.project ?? '', sandbox: record?.sandbox ?? '', site: site, published: end.published, events: events, live: running, menu: [...manage, ...sharing], refused: signed ? refused || (detail.error ? detail.error.message : '') : 'Sign in to follow this run.', retry: signed ? 'Retry' : 'Sign in', onRetry: signed ? detail.reload : () => host.signIn?.(), side: side, sheet: sheet, onHide: () => setDesk(false) }));
360
+ return (_jsxs(XStack, { ref: size, "data-slot": "run-split", flex: 1, minH: 0, minW: 0, width: "100%", children: [_jsxs(YStack, { "data-slot": "run", ...pane, flex: 1, minH: 0, minW: 0, width: "100%", children: [_jsxs(XStack, { px: "$4", pt: "$3", pb: "$2", gap: "$2", items: "center", minH: 44, children: [_jsxs(YStack, { flex: 1, minW: 0, items: "flex-start", children: [_jsx(DropdownMenu, { trigger: _jsxs(XStack, { render: "button", "aria-label": "Manage this run", items: "center", gap: "$1.5", maxW: "100%", px: "$1.5", mx: "$-1.5", py: "$0.5", rounded: "$2", hoverStyle: { bg: '$hover' }, children: [_jsx(SizableText, { render: "h1", size: "$5", color: "$ink", numberOfLines: 1, minW: 0, children: title }), _jsx(ChevronDown, { size: 14 })] }), items: manage }), _jsx(SizableText, { size: "$1", color: "$soft", numberOfLines: 1, maxW: "100%", children: [state || (detail.loading ? 'reading' : ''), record?.repo, record?.branch, record?.published ? 'shared' : ''].filter(Boolean).join(' · ') })] }), pr.href ? (_jsx(Out, { href: pr.href, label: `Open pull request ${pr.label}`, children: _jsxs(XStack, { items: "center", gap: "$1.5", px: "$2.5", py: "$1.5", rounded: "$3", borderWidth: 1, borderColor: "$borderColor", hoverStyle: { bg: '$hover' }, children: [_jsx(GitPullRequest, { size: 14 }), _jsx(SizableText, { size: "$2", color: "$ink", children: pr.label }), _jsx(ExternalLink, { size: 12, opacity: 0.6 })] }) })) : null, sharing.length ? (_jsx(DropdownMenu, { trigger: _jsxs(XStack, { render: "button", "aria-label": "Share", items: "center", gap: "$1.5", px: "$2.5", py: "$1.5", rounded: "$3", borderWidth: 1, borderColor: "$borderColor", hoverStyle: { bg: '$hover' }, children: [_jsx(Share, { size: 14 }), _jsx(SizableText, { size: "$2", color: "$ink", children: "Share" })] }), items: sharing })) : null, _jsx(PanelToggle, { open: desk, onToggle: () => setDesk(!desk) })] }), _jsx(Transcript, { cards: shown, onOpen: look, onDiff: () => {
361
+ side.open({ kind: 'diff', title: 'Diff' });
362
+ setDesk(true);
363
+ }, listen: signed ? listen : undefined, end: failed ? (_jsx(Failure, { onRetry: busy ? undefined : () => void act(() => carry(again, 'retry')), children: end.problem ? 'This run stopped before it finished.' : 'This run stopped with an error. Its work so far is on its branch.' })) : null,
331
364
  // A paused run's agent was stopped where it stood; what it was running is cut off.
332
365
  live: running && !paused, header: hidden ? (_jsx(SizableText, { size: "$1", color: "$soft", children: `${hidden} earlier ${hidden === 1 ? 'turn is' : 'turns are'} not shown. The run’s Git tab has everything it pushed.` })) : null, onApprove: planned && earlier ? (p) => void act(() => carry(p, 'approve')) : undefined, approving: busy, onVerdict: (v) => verdict(t, id, record?.project ?? '', v), empty: signed ? (_jsx(SizableText, { size: "$2", color: "$soft", children: detail.error
333
366
  ? detail.error.message
@@ -337,29 +370,31 @@ export function Run({ id }) {
337
370
  ? setup
338
371
  ? 'Setting up environment'
339
372
  : 'Starting…'
340
- : 'Waiting for the run’s first step…' })) : (_jsxs(YStack, { gap: "$3", items: "flex-start", children: [_jsx(SizableText, { size: "$2", color: "$soft", children: "Sign in to follow this run." }), _jsx(Button, { size: "sm", onPress: () => host.signIn?.(), children: "Sign in" })] })) }), _jsxs(YStack, { pb: "$3", pt: "$2", gap: "$2", children: [end.problem ? (_jsx(SizableText, { size: "$1", color: "$soft", children: "The branch is pushed, and the pull request could not be opened." })) : null, note || (signed && refused) ? (_jsx(SizableText, { size: "$1", color: "$soft", role: "status", children: note || refused })) : null, failed ? (_jsx(XStack, { items: "center", gap: "$2", children: _jsx(Button, { size: "sm", disabled: busy, onPress: () => void act(() => carry(again, 'retry')), children: busy ? 'Starting…' : 'Try again' }) })) : null, plan.length ? (_jsxs(YStack, { borderWidth: 1, borderColor: "$borderColor", rounded: "$3", overflow: "hidden", children: [_jsxs(XStack, { render: "button", "aria-label": `Steps · ${plan.length}`, "aria-expanded": unfolded, onPress: () => setUnfolded(!unfolded), px: "$3", py: "$1.5", gap: "$2", items: "center", hoverStyle: { bg: '$hover' }, children: [unfolded ? _jsx(ChevronDown, { size: 14 }) : _jsx(ChevronRight, { size: 14 }), _jsx(SizableText, { size: "$2", color: "$ink", children: `Steps · ${plan.length}` }), current && !unfolded ? (_jsx(SizableText, { flex: 1, minW: 0, size: "$1", color: "$soft", numberOfLines: 1, style: { textAlign: 'right' }, children: current.name })) : null] }), unfolded
341
- ? plan.map((s) => {
342
- const on = s === current;
343
- return (_jsxs(XStack, { items: "center", gap: "$2", px: "$3", py: "$1.5", borderTopWidth: 1, borderColor: "$borderColor", children: [_jsx(SizableText, { size: "$2", color: s.done || on ? '$ink' : '$soft', children: s.done ? '✓' : on ? '●' : '○' }), _jsx(SizableText, { size: "$2", color: s.done ? '$soft' : '$ink', numberOfLines: 1, children: s.name })] }, s.name));
344
- })
345
- : null] })) : null, setup && running && !seen ? _jsx(Onboarding, { onDone: () => setSeen(true) }) : null, running ? (_jsxs(XStack, { items: "center", gap: "$2", px: "$3", py: "$2", rounded: "$10", borderWidth: 1, borderColor: "$borderColor", flexWrap: "wrap", rowGap: "$1", children: [_jsx(SizableText, { size: "$2", color: "$ink", flex: 1, minW: 160, children: paused
346
- ? kept.pushed
347
- ? 'Paused — its work so far is on its branch.'
348
- : 'Paused.'
349
- : setup
350
- ? span
351
- ? `Environment setup takes ~${span[0] === span[1] ? span[0] : `${span[0]}–${span[1]}`} minutes.`
352
- : 'Environment setup takes several minutes.'
353
- : 'This run is still working.' }), _jsx(Steer, { onCommand: command, withhold: [
354
- ...(paused ? ['pause'] : ['resume']),
355
- ...(busy || waiting ? ['pause', 'resume', 'stop'] : []),
356
- ] }), _jsx(Button, { size: "sm", disabled: notify, onPress: () => void ask(), children: notify ? 'You will be notified' : 'Notify me' })] })) : null, _jsx(Composer, { inline: true, ...pane, value: draft, onChange: setDraft, onSend: send, disabled: !signed || busy || waiting || !(running || finished), placeholder: placeholder, label: steering ? 'Steer this run' : 'Follow up on this run', foot: tuning }), _jsx(SizableText, { size: "$1", color: "$soft", style: { textAlign: 'center' }, children: "Hanzo is AI and can make mistakes." })] })] }), desk ? (_jsxs(YStack, { "data-slot": "desk", ref: (el) => {
373
+ : 'Waiting for the run’s first step…' })) : (_jsxs(YStack, { gap: "$3", items: "flex-start", children: [_jsx(SizableText, { size: "$2", color: "$soft", children: "Sign in to follow this run." }), _jsx(Button, { size: "sm", onPress: () => host.signIn?.(), children: "Sign in" })] })) }), _jsxs(YStack, { pb: "$3", pt: "$2", gap: "$2", children: [_jsxs(YStack, { ...measure, gap: "$2", children: [end.problem ? (_jsx(SizableText, { size: "$1", color: "$soft", children: "The branch is pushed, and the pull request could not be opened." })) : null, note || (signed && refused) ? (_jsx(SizableText, { size: "$1", color: "$soft", role: "status", children: note || refused })) : null, plan.length ? (_jsxs(YStack, { borderWidth: 1, borderColor: "$borderColor", rounded: "$3", overflow: "hidden", children: [_jsxs(XStack, { render: "button", "aria-label": `Steps · ${plan.length}`, "aria-expanded": unfolded, onPress: () => setUnfolded(!unfolded), px: "$3", py: "$1.5", gap: "$2", items: "center", hoverStyle: { bg: '$hover' }, children: [unfolded ? _jsx(ChevronDown, { size: 14 }) : _jsx(ChevronRight, { size: 14 }), _jsx(SizableText, { size: "$2", color: "$ink", children: `Steps · ${plan.length}` }), current && !unfolded ? (_jsx(SizableText, { flex: 1, minW: 0, size: "$1", color: "$soft", numberOfLines: 1, style: { textAlign: 'right' }, children: current.name })) : null] }), unfolded
374
+ ? plan.map((s) => {
375
+ const on = s === current;
376
+ return (_jsxs(XStack, { items: "center", gap: "$2", px: "$3", py: "$1.5", borderTopWidth: 1, borderColor: "$borderColor", children: [_jsx(SizableText, { size: "$2", color: s.done || on ? '$ink' : '$soft', children: s.done ? '✓' : on ? '●' : '○' }), _jsx(SizableText, { size: "$2", color: s.done ? '$soft' : '$ink', numberOfLines: 1, children: s.name })] }, s.name));
377
+ })
378
+ : null] })) : null, setup && running && !seen ? _jsx(Onboarding, { onDone: () => setSeen(true) }) : null, running ? (_jsxs(XStack, { items: "center", gap: "$2", px: "$3", py: "$2", rounded: "$10", borderWidth: 1, borderColor: "$borderColor", flexWrap: "wrap", rowGap: "$1", children: [_jsx(SizableText, { size: "$2", color: "$ink", flex: 1, minW: 160, children: paused
379
+ ? kept.pushed
380
+ ? 'Paused — its work so far is on its branch.'
381
+ : 'Paused.'
382
+ : setup
383
+ ? span
384
+ ? `Environment setup takes ~${span[0] === span[1] ? span[0] : `${span[0]}–${span[1]}`} minutes.`
385
+ : 'Environment setup takes several minutes.'
386
+ : 'This run is still working.' }), _jsx(Steer, { onCommand: command, withhold: [
387
+ ...(paused ? ['pause'] : ['resume']),
388
+ ...(busy || waiting ? ['pause', 'resume', 'stop'] : []),
389
+ ] }), _jsx(Button, { size: "sm", disabled: notify, onPress: () => void ask(), children: notify ? 'You will be notified' : 'Notify me' })] })) : null] }), _jsx(Prompt, { framed: true, value: draft, onChange: setDraft, onSend: send, ready: Boolean(draft.trim()), disabled: !signed || busy || waiting || !(running || finished), placeholder: placeholder, label: steering ? 'Steer this run' : 'Follow up on this run', head: files.length ? _jsx(XStack, { gap: "$1.5", flexWrap: "wrap", children: _jsx(Files, { files: files, onFiles: setFiles }) }) : undefined, onAttach: () => pick(files, setFiles, setNote),
390
+ // What a new run from here is made with; words that steer this run use neither.
391
+ mind: signed && !steering ? mind : null, limits: limits, voice: voice }), _jsx(SizableText, { size: "$1", color: "$soft", style: { textAlign: 'center' }, children: "Hanzo is AI and can make mistakes." })] })] }), desk && !wide ? desk_(true) : null, desk && wide ? (_jsxs(YStack, { "data-slot": "desk", ref: (el) => {
357
392
  deskBox.current = el instanceof HTMLElement ? el : null;
358
393
  }, ...pane,
359
394
  // A pane of its own beside the run's, the gutter between them.
360
- ml: gap, position: "relative", shrink: 0, minH: 0, display: "none", $md: { display: 'flex' }, style: { width: across, maxWidth: `calc(100% - ${TALK_FLOOR}px)`, minWidth: DESK_FLOOR }, children: [_jsx(Grip, { side: "right", span: across, floor: DESK_FLOOR, ceil: most, reset: Math.min(most, DESK_WIDTH), onSpan: (n) => deskBox.current?.style.setProperty('width', `${n}px`), onKeep: setSide, onShut: () => setDesk(false), label: "Resize the side pane",
395
+ ml: gap, position: "relative", shrink: 0, minH: 0, style: { width: across, maxWidth: `calc(100% - ${TALK_FLOOR}px)`, minWidth: DESK_FLOOR }, children: [_jsx(Grip, { side: "right", span: across, floor: DESK_FLOOR, ceil: most, reset: Math.min(most, DESK_WIDTH), onSpan: (n) => deskBox.current?.style.setProperty('width', `${n}px`), onKeep: setReach, onShut: () => setDesk(false), label: "Resize the side panel",
361
396
  // In the gutter between the two panes; `left` is measured inside the pane's 1px edge.
362
- l: `calc(-1 * ${gap} - ${pane.borderWidth}px)`, width: gap }), _jsx(Desk, { id: id, repo: record?.repo ?? '', branch: record?.branch ?? '', base: record?.base ?? '', environment: record?.environment ?? '', mode: mode, pr: pr, title: title, project: record?.project ?? '', sandbox: record?.sandbox ?? '', site: site, published: end.published, events: events, live: running, menu: [...manage, ...sharing], refused: signed ? refused || (detail.error ? detail.error.message : '') : 'Sign in to follow this run.', retry: signed ? 'Retry' : 'Sign in', onRetry: signed ? detail.reload : () => host.signIn?.(), onHide: () => setDesk(false) })] })) : null, _jsx(Dialog, { open: naming, onOpenChange: setNaming, children: _jsxs(DialogContent, { maxW: 480, children: [_jsx(DialogTitle, { children: "Rename this run" }), _jsx(Input, { autoFocus: true, value: name, onChangeText: setName, "aria-label": "The run\u2019s name", maxLength: 512, onKeyDown: (e) => {
397
+ l: `calc(-1 * ${gap} - ${pane.borderWidth}px)`, width: gap }), desk_(false)] })) : null, _jsx(Dialog, { open: naming, onOpenChange: setNaming, children: _jsxs(DialogContent, { maxW: 480, children: [_jsx(DialogTitle, { children: "Rename this run" }), _jsx(Input, { autoFocus: true, value: name, onChangeText: setName, "aria-label": "The run\u2019s name", maxLength: 512, onKeyDown: (e) => {
363
398
  if (e.key === 'Enter')
364
399
  save();
365
400
  } }), _jsxs(XStack, { gap: "$2", justify: "flex-end", children: [_jsx(Button, { size: "sm", variant: "outline", onPress: () => setNaming(false), children: "Cancel" }), _jsx(Button, { size: "sm", disabled: busy || !name.trim(), onPress: save, children: "Save" })] })] }) })] }));
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 (Projects, Issues), Artifacts, Templates, then the
11
+ * The builder's places, grouped as every rail groups them (nav.tsx), 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,12 +11,11 @@ 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, LayoutTemplate, SlidersHorizontal, SquarePen, Workflow } from '@hanzogui/lucide-icons-2';
15
14
  import { SidebarItem, SidebarSection, StatusDot } from '@hanzo/ui/chat';
16
15
  import { useMemo } from 'react';
17
- import { pinBoard } from './choice.js';
18
16
  import { useRecents } from './data.js';
19
- import { path, route } from './route.js';
17
+ import { nav } from './nav.js';
18
+ import { route } from './route.js';
20
19
  /** A run's status, as the rail's dot draws it. Anything else is idle. */
21
20
  export const DOTS = {
22
21
  running: 'running',
@@ -32,7 +31,7 @@ export function useSessions(host) {
32
31
  }
33
32
  const Note = ({ children }) => (_jsx(SizableText, { size: "$1", color: "$soft", px: "$2", py: "$1", children: children }));
34
33
  /**
35
- * New, Automations, the forge (Projects, Issues), Artifacts, Templates, then the
34
+ * The builder's places, grouped as every rail groups them (nav.tsx), then the
36
35
  * runs. `children` sit between the places and the runs — where a host lists its
37
36
  * own. `onPick` fires after a move, so a host that draws its rail as a drawer
38
37
  * on a phone can close it.
@@ -40,13 +39,10 @@ const Note = ({ children }) => (_jsx(SizableText, { size: "$1", color: "$soft",
40
39
  export function DevSection({ host, onPick, label = 'Runs', children, }) {
41
40
  const sessions = useSessions(host);
42
41
  const r = route(host.path);
43
- const go = (p) => {
44
- host.go(p);
42
+ // Every row, a place or a run, closes a drawer behind it.
43
+ const then = (press) => () => {
44
+ press?.();
45
45
  onPick?.();
46
46
  };
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 === '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
- pinBoard(host.org, '');
50
- go(path({ kind: 'screen', screen: 'issues' }));
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.' })) })] }));
47
+ return (_jsxs(_Fragment, { children: [nav(host, host.go).map((g) => (_jsx(SidebarSection, { label: g.label, children: g.links.map((l) => (_jsx(SidebarItem, { icon: l.icon, active: l.active, onPress: then(l.onPress), children: l.label }, l.id))) }, g.id))), 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: then(() => host.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.' })) })] }));
52
48
  }
@@ -1,18 +1,22 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /**
3
- * Billing: the plan this organization is on and the way to change it, the cards
4
- * it pays with, its invoices, and ending the plan. Changing plan is the Plans
5
- * screen; this page says what is true now.
3
+ * Billing: the plan this organization is on and the way to change it, its
4
+ * credits (`Credits`: the balance, grants, top-ups, auto-reload, this month's
5
+ * spend and its limit), the cards it pays with, its invoices, and ending the
6
+ * plan. Changing plan is the Plans screen; this page says what is true now.
6
7
  */
7
8
  import { SizableText, XStack, YStack } from '@hanzo/gui';
8
9
  import { CreditCard, Download, Plus, X } from '@hanzogui/lucide-icons-2';
9
10
  import { Button, Dialog, DialogContent, DialogTitle, Input, toast } from '@hanzo/ui';
10
11
  import { useEffect, useState } from 'react';
11
- import { cancel, chosen, current, detach, formatAddress, invoices, label, makeDefault, methods, money, pdf, reactivate, subscriptions, updateMethod, } from '../api/billing.js';
12
+ import { cancel, chosen, current, detach, formatAddress, invoices, label, makeDefault, methods, money, pdf, reactivate, subscriptions, tier, updateMethod, } from '../api/billing.js';
12
13
  import { useRead } from '../data.js';
13
14
  import { useHost, useTarget } from '../host.js';
15
+ import { Title } from '../meter.js';
16
+ import { label as named } from '../plan.js';
14
17
  import { path } from '../route.js';
15
18
  import { AddCard } from './card.js';
19
+ import { Credits } from './credits.js';
16
20
  import { Card, day, Field, Group, Heading, Note, Row, Soft } from './ui.js';
17
21
  export function Billing() {
18
22
  const host = useHost();
@@ -21,6 +25,7 @@ export function Billing() {
21
25
  const subs = useRead(signed ? () => subscriptions(t) : null, [], [t, signed]);
22
26
  const cards = useRead(signed ? () => methods(t) : null, [], [t, signed]);
23
27
  const bills = useRead(signed ? () => invoices(t) : null, [], [t, signed]);
28
+ const billed = useRead(signed ? () => tier(t) : null, null, [t, signed]);
24
29
  const [adding, setAdding] = useState(false);
25
30
  const [ending, setEnding] = useState(false);
26
31
  const [editingBilling, setEditingBilling] = useState(null);
@@ -31,6 +36,12 @@ export function Billing() {
31
36
  return (_jsxs(YStack, { gap: "$5", children: [_jsx(Heading, { title: "Billing" }), _jsx(Soft, { children: "Sign in to see this organization\u2019s billing." })] }));
32
37
  }
33
38
  const plan = current(subs.value);
39
+ // The plan by its family, `Max` with `20x` beside it; the subscription names
40
+ // it, else the rung billing serves.
41
+ const title = named(plan?.plan || billed.value?.plan, billed.value?.tier) ?? (plan ? { name: plan.name, tag: '' } : null);
42
+ // Nothing is said about the plan until both reads have answered: a page that
43
+ // has not asked yet is not a page on Free.
44
+ const reading = (subs.loading && !subs.value.length) || (billed.value === null && !billed.error && !subs.error);
34
45
  const empty = !cards.error && !cards.loading && cards.value.length === 0;
35
46
  const defaultCard = chosen(cards.value);
36
47
  // A done act is a toast; a refused one stays on the page, beside what was tried.
@@ -49,11 +60,7 @@ export function Billing() {
49
60
  setWorking(false);
50
61
  }
51
62
  };
52
- return (_jsxs(YStack, { gap: "$6", children: [_jsx(Heading, { title: "Billing", detail: `The plan ${host.org ?? 'this organization'} is on, the cards it pays with, and its invoices.` }), _jsxs(XStack, { items: "center", gap: "$3", px: "$4", py: "$4", borderWidth: 1, borderColor: "$borderColor", rounded: "$3", flexWrap: "wrap", children: [_jsxs(YStack, { flex: 1, minW: 200, gap: "$1", children: [_jsx(SizableText, { size: "$5", color: "$ink", children: subs.loading && !subs.value.length ? 'Reading your plan…' : plan ? `${plan.name} plan` : 'Free plan' }), _jsx(SizableText, { size: "$2", color: "$soft", children: subs.error
53
- ? subs.error.message
54
- : plan
55
- ? terms(plan)
56
- : 'No paid plan. Usage is paid from the balance.' })] }), _jsx(Button, { size: "sm", variant: "outline", onPress: () => host.go(path({ kind: 'screen', screen: 'plans' })), children: "Adjust plan" })] }), _jsxs(Group, { title: "Payment", detail: "The card a plan renews on and a top-up charges.", action: host.admin && (cards.error || cards.value.length > 0) ? (_jsxs(Button, { size: "sm", variant: "outline", onPress: () => setAdding(true), children: [_jsx(Plus, { size: 14 }), " Add card"] })) : undefined, children: [cards.error ? (_jsx(Soft, { children: cards.error.message })) : cards.loading && !cards.value.length ? (_jsx(Soft, { children: "Reading cards\u2026" })) : empty ? (_jsxs(XStack, { items: "center", gap: "$3", px: "$4", py: "$4", borderWidth: 1, borderColor: "$borderColor", rounded: "$3", flexWrap: "wrap", children: [_jsx(CreditCard, { size: 20 }), _jsxs(YStack, { flex: 1, minW: 180, gap: "$1", children: [_jsx(SizableText, { size: "$3", color: "$ink", children: "No card on file." }), _jsx(SizableText, { size: "$2", color: "$soft", children: host.admin ? 'Add one to pay for a plan or a top-up. Adding it charges nothing.' : 'An org admin adds cards.' })] }), host.admin ? (_jsxs(Button, { variant: "primary", onPress: () => setAdding(true), children: [_jsx(Plus, { size: 16 }), " Add card"] })) : null] })) : (_jsx(Card, { children: cards.value.map((m, i) => (_jsx(Row, { first: i === 0, leading: _jsx(CreditCard, { size: 16 }), title: label(m), detail: [
63
+ return (_jsxs(YStack, { gap: "$6", children: [_jsx(Heading, { title: "Billing", detail: `The plan ${host.org ?? 'this organization'} is on, the cards it pays with, and its invoices.` }), _jsxs(XStack, { items: "center", gap: "$3", px: "$4", py: "$4", borderWidth: 1, borderColor: "$borderColor", rounded: "$3", flexWrap: "wrap", children: [_jsxs(YStack, { flex: 1, minW: 200, gap: "$1", "data-slot": "billing-plan", children: [reading ? (_jsx(SizableText, { size: "$5", color: "$ink", children: "Reading your plan\u2026" })) : (_jsx(Title, { label: title ?? { name: 'Free', tag: '' }, size: "$5" })), _jsx(SizableText, { size: "$2", color: "$soft", children: subs.error ? subs.error.message : plan ? terms(plan) : title ? 'Your plan.' : 'No paid plan. Usage is paid from credits.' })] }), _jsx(Button, { size: "sm", variant: "outline", onPress: () => host.go(path({ kind: 'screen', screen: 'plans' })), children: "Adjust plan" })] }), _jsx(Credits, { target: t, admin: host.admin, onCard: () => setAdding(true) }), _jsxs(Group, { title: "Payment", detail: "The card a plan renews on and a top-up charges.", action: host.admin && (cards.error || cards.value.length > 0) ? (_jsxs(Button, { size: "sm", variant: "outline", onPress: () => setAdding(true), children: [_jsx(Plus, { size: 14 }), " Add card"] })) : undefined, children: [cards.error ? (_jsx(Soft, { children: cards.error.message })) : cards.loading && !cards.value.length ? (_jsx(Soft, { children: "Reading cards\u2026" })) : empty ? (_jsxs(XStack, { items: "center", gap: "$3", px: "$4", py: "$4", borderWidth: 1, borderColor: "$borderColor", rounded: "$3", flexWrap: "wrap", children: [_jsx(CreditCard, { size: 20 }), _jsxs(YStack, { flex: 1, minW: 180, gap: "$1", children: [_jsx(SizableText, { size: "$3", color: "$ink", children: "No card on file." }), _jsx(SizableText, { size: "$2", color: "$soft", children: host.admin ? 'Add one to pay for a plan or a top-up. Adding it charges nothing.' : 'An org admin adds cards.' })] }), host.admin ? (_jsxs(Button, { variant: "primary", onPress: () => setAdding(true), children: [_jsx(Plus, { size: 16 }), " Add card"] })) : null] })) : (_jsx(Card, { children: cards.value.map((m, i) => (_jsx(Row, { first: i === 0, leading: _jsx(CreditCard, { size: 16 }), title: label(m), detail: [
57
64
  m.expires ? `Expires ${m.expires}` : '',
58
65
  m.default ? 'Default' : '',
59
66
  m.billingAddress ? formatAddress(m.billingAddress) : '',