@hanzo/build 0.2.59 → 0.2.61
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -3
- package/lib/api/conversations.d.ts +20 -0
- package/lib/api/conversations.js +23 -0
- package/lib/api/pref.d.ts +10 -0
- package/lib/api/pref.js +7 -0
- package/lib/builder.js +2 -2
- package/lib/carry.d.ts +17 -0
- package/lib/carry.js +95 -0
- package/lib/gear.d.ts +51 -0
- package/lib/gear.js +123 -0
- package/lib/index.d.ts +22 -5
- package/lib/index.js +20 -5
- package/lib/landing.js +30 -10
- package/lib/mic.d.ts +10 -0
- package/lib/mic.js +95 -0
- package/lib/mind.d.ts +18 -1
- package/lib/mind.js +48 -5
- package/lib/panel.js +14 -7
- package/lib/plugin.d.ts +56 -0
- package/lib/plugin.js +1 -0
- package/lib/plus.d.ts +46 -0
- package/lib/plus.js +58 -0
- package/lib/prefs.d.ts +14 -1
- package/lib/prefs.js +13 -6
- package/lib/project.js +4 -7
- package/lib/prompt.d.ts +68 -19
- package/lib/prompt.js +170 -95
- package/lib/run.js +14 -44
- package/lib/side.d.ts +68 -0
- package/lib/side.js +195 -0
- package/lib/suggest.d.ts +202 -0
- package/lib/suggest.js +329 -0
- package/lib/suggestions.d.ts +20 -0
- package/lib/suggestions.js +170 -0
- package/package.json +2 -2
- package/lib/grip.d.ts +0 -40
- package/lib/grip.js +0 -108
package/lib/landing.js
CHANGED
|
@@ -16,10 +16,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
16
16
|
import { SizableText, XStack, YStack } from '@hanzo/gui';
|
|
17
17
|
import { Cloud, Monitor } from '@hanzogui/lucide-icons-2';
|
|
18
18
|
import { Button } from '@hanzo/ui';
|
|
19
|
-
import { ModeSelect } from '@hanzo/ui/agents';
|
|
20
19
|
import { BranchSelect, ChipSelect, FooterLink, RepoSelect } from '@hanzo/ui/product';
|
|
21
20
|
import { useLimits } from '@hanzo/ui/product/useLimits';
|
|
22
|
-
import { useDictation, useVoice } from '@hanzo/voice';
|
|
21
|
+
import { useDictation, useTalk, useVoice } from '@hanzo/voice';
|
|
23
22
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
24
23
|
import { start, unhonoured } from './api/coding.js';
|
|
25
24
|
import { asRepo, chosen, codebases, one } from './api/codebases.js';
|
|
@@ -36,7 +35,10 @@ import { useMind } from './mind.js';
|
|
|
36
35
|
import { usePrefs } from './prefs.js';
|
|
37
36
|
import { Publish } from './publish.js';
|
|
38
37
|
import { path } from './route.js';
|
|
38
|
+
import { useCarry } from './carry.js';
|
|
39
|
+
import { Suggestions, useSuggestions } from './suggestions.js';
|
|
39
40
|
import { MEASURE, Prompt, useEar } from './prompt.js';
|
|
41
|
+
import { Monitor as Device } from '@hanzogui/lucide-icons-2';
|
|
40
42
|
import { compose, Files, pick } from './tools.js';
|
|
41
43
|
import { useSpoken } from './voice.js';
|
|
42
44
|
/** One vocabulary for the mode, on New and in a workspace. */
|
|
@@ -77,7 +79,7 @@ export function Landing({ onStarted }) {
|
|
|
77
79
|
const host = useHost();
|
|
78
80
|
const t = useTarget();
|
|
79
81
|
const signed = Boolean(host.person);
|
|
80
|
-
const { prefs } = usePrefs();
|
|
82
|
+
const { prefs, save: savePrefs } = usePrefs();
|
|
81
83
|
const [stored, keep] = useKept(`hanzo.build.new.${host.org ?? 'none'}`, null);
|
|
82
84
|
// What was chosen here, over the person's coding defaults (Settings → Code), over
|
|
83
85
|
// the first run's. Field by field: a codebase or an issue handed over from its own
|
|
@@ -107,9 +109,14 @@ export function Landing({ onStarted }) {
|
|
|
107
109
|
}, []);
|
|
108
110
|
const places = usePlaces(t, signed);
|
|
109
111
|
// The model and effort Chat shares, and dictation into the ask.
|
|
110
|
-
const mind = useMind(t);
|
|
111
112
|
const { limits } = useLimits(signed ? (signal) => readLimits(t, signal) : null, t);
|
|
113
|
+
const mind = useMind(t, limits?.plan);
|
|
112
114
|
const voice = useVoice({ speech: useEar(t), language: useSpoken(), ...useDictation(draft, setDraft) });
|
|
115
|
+
const talk = useTalk({ baseUrl: t.api, token: () => t.token(), ...(t.org ? { org: t.org } : {}) });
|
|
116
|
+
// What the run carries beside the ask: skills, memory and the mode, as Chat's turns do.
|
|
117
|
+
const carry = useCarry(t, '', (tab) => host.go(path({ kind: 'customize', tab })));
|
|
118
|
+
const mode = carry.gear.gear.mode === 'plan' ? 'plan' : 'build';
|
|
119
|
+
const hints = useSuggestions(t);
|
|
113
120
|
// The chosen codebase's environment, so New can say when it has none yet.
|
|
114
121
|
const codebase = isForge(kept.repo) ? kept.repo.name : '';
|
|
115
122
|
// The org's repositories on the forge: what a run and an environment can name.
|
|
@@ -161,7 +168,7 @@ export function Landing({ onStarted }) {
|
|
|
161
168
|
return;
|
|
162
169
|
}
|
|
163
170
|
// A plan or a setup routed to a machine is refused here, in its own words, before it leaves the page.
|
|
164
|
-
const why = unhonoured(
|
|
171
|
+
const why = unhonoured(mode, place.id);
|
|
165
172
|
if (why) {
|
|
166
173
|
setNote(why);
|
|
167
174
|
return;
|
|
@@ -175,13 +182,13 @@ export function Landing({ onStarted }) {
|
|
|
175
182
|
setNote('');
|
|
176
183
|
try {
|
|
177
184
|
const run = await start(t, {
|
|
178
|
-
prompt: compose(draft.trim(), files),
|
|
185
|
+
prompt: compose([draft.trim(), carry.text].filter(Boolean).join('\n\n---\n'), files),
|
|
179
186
|
// The name alone. The org rides the request, and a slash is not a repo name.
|
|
180
187
|
// An empty base or place is not sent: the default branch, and the sandbox.
|
|
181
188
|
repo: repo?.name ?? hub?.full_name,
|
|
182
189
|
base: repo ? kept.branch : hub ? kept.branch || hub.default_branch : undefined,
|
|
183
190
|
targetId: place.id,
|
|
184
|
-
mode
|
|
191
|
+
mode,
|
|
185
192
|
model: mind.model,
|
|
186
193
|
effort: mind.effort,
|
|
187
194
|
issue,
|
|
@@ -237,7 +244,7 @@ export function Landing({ onStarted }) {
|
|
|
237
244
|
place: p,
|
|
238
245
|
})), [places.value, setUp]);
|
|
239
246
|
const branch = kept.branch || kept.repo?.default_branch || 'main';
|
|
240
|
-
const
|
|
247
|
+
const where = (_jsxs(XStack, { gap: 6, items: "center", flexWrap: "wrap", children: [_jsx(ChipSelect, { name: "Where the run runs", icon: place.id ? _jsx(Monitor, { size: 13 }) : _jsx(Cloud, { size: 13 }), label: place.id ? place.label : 'Cloud', chosen: placeItems.find((i) => i.place.id === place.id), items: placeItems, onChange: (i) => set({ place: i.place.id }), placeholder: "Search machines\u2026", loading: places.loading, error: places.error?.message ?? null, footer: _jsxs(YStack, { gap: "$0.5", children: [_jsx(SizableText, { size: "$1", color: "$ink", children: "Set up remote control" }), _jsxs(SizableText, { size: "$1", color: "$soft", children: ["Run", ' ', _jsx(SizableText, { size: "$1", color: "$ink", style: { fontFamily: 'var(--f-mono, ui-monospace, monospace)' }, children: "hanzo link" }), ' ', "on a machine: it joins this list, and a run there uses its checkout and credentials."] })] }) }), _jsx(RepoSelect, { open: picking, onOpenChange: setPicking, value: isForge(kept.repo) || isHub(kept.repo) ? kept.repo : null, onChange: (r) => {
|
|
241
248
|
const hub = r.github ? hubs.current.get((r.full_name ?? "").toLowerCase()) : undefined;
|
|
242
249
|
setIssue(0);
|
|
243
250
|
if (hub) {
|
|
@@ -260,10 +267,23 @@ export function Landing({ onStarted }) {
|
|
|
260
267
|
name: r.name,
|
|
261
268
|
});
|
|
262
269
|
},
|
|
263
|
-
}, placeholder: "Choose a project", disabled: !signed }, round), isForge(kept.repo) ? _jsx(BranchSelect, { value: branch, onChange: (b) => set({ branch: b }), load: loadBranches }) : null
|
|
270
|
+
}, placeholder: "Choose a project", disabled: !signed }, round), isForge(kept.repo) ? _jsx(BranchSelect, { value: branch, onChange: (b) => set({ branch: b }), load: loadBranches }) : null] }));
|
|
271
|
+
const head = files.length ? (_jsx(XStack, { gap: 6, items: "center", flexWrap: "wrap", children: _jsx(Files, { files: files, onFiles: setFiles }) })) : undefined;
|
|
272
|
+
// The machines a run can go to, as the `+` menu's Devices: the same choice as `where`'s.
|
|
273
|
+
const devices = {
|
|
274
|
+
rows: placeItems.map((i) => ({ id: i.place.id || 'sandbox', label: i.label, note: i.hint, icon: _jsx(Device, { size: 16 }), on: i.place.id === place.id })),
|
|
275
|
+
pick: (id) => set({ place: id === 'sandbox' ? '' : id }),
|
|
276
|
+
loading: places.loading,
|
|
277
|
+
error: places.error?.message ?? null,
|
|
278
|
+
};
|
|
264
279
|
return (_jsxs(YStack, { flex: 1, minH: 0, minW: 0, items: "center", justify: "center", pb: "$10", overflowY: "auto", overflowX: "hidden", children: [_jsxs(YStack, { width: "100%", maxW: MEASURE, gap: "$5", children: [_jsx(SizableText, { render: "h1", fontSize: "$7", lineHeight: "$7", fontWeight: "700", color: "$ink", style: { textAlign: 'center' }, children: prefs.callName ? `What’s up next, ${prefs.callName}?` : 'What’s up next?' }), _jsxs(YStack, { gap: "$2", children: [stranger ? (_jsxs(XStack, { items: "center", justify: "space-between", gap: "$3", px: "$3", py: "$2", rounded: "$10", borderWidth: 1, borderColor: "$borderColor", role: "alert", children: [_jsx(SizableText, { size: "$2", color: "$ink", flex: 1, minW: 0, children: `${codebase} is not one of this organization’s repositories.` }), _jsx(Button, { size: "sm", variant: "outline", onPress: () => setPicking(true), children: "Choose a repository" })] })) : env.value && (env.value.state === 'proposed' || unset(env.value)) && !place.id ? (_jsxs(XStack, { items: "center", justify: "space-between", gap: "$3", px: "$3", py: "$2", rounded: "$10", borderWidth: 1, borderColor: "$borderColor", children: [_jsx(SizableText, { size: "$2", color: "$ink", flex: 1, minW: 0, children: env.value.state === 'proposed'
|
|
265
280
|
? `A proposed environment for ${codebase} is waiting for review.`
|
|
266
|
-
: `${codebase} has no environment yet, so every run starts it bare.` }), env.value.state === 'proposed' && env.value.session ? (_jsx(Button, { size: "sm", variant: "outline", onPress: () => host.go(env.value.session), children: "Review" })) : (_jsx(Button, { size: "sm", onPress: () => setOffering(true), children: "Set up environment" }))] })) : null, note || unhonoured(
|
|
281
|
+
: `${codebase} has no environment yet, so every run starts it bare.` }), env.value.state === 'proposed' && env.value.session ? (_jsx(Button, { size: "sm", variant: "outline", onPress: () => host.go(env.value.session), children: "Review" })) : (_jsx(Button, { size: "sm", onPress: () => setOffering(true), children: "Set up environment" }))] })) : null, note || unhonoured(mode, place.id) || mind.note ? (_jsx(SizableText, { size: "$1", color: "$soft", role: "status", children: note || unhonoured(mode, place.id) || mind.note })) : null, _jsx(Prompt, { framed: true, value: draft, onChange: setDraft, onSend: () => void send(), busy: busy, label: "Describe a task or ask a question", head: head, plus: {
|
|
282
|
+
upload: () => pick(files, setFiles, setNote),
|
|
283
|
+
skills: carry.skills,
|
|
284
|
+
devices,
|
|
285
|
+
memory: { on: carry.gear.gear.memory, set: (on) => carry.gear.set({ memory: on }) },
|
|
286
|
+
}, on: carry.on, slash: carry.skills, mode: { value: carry.gear.gear.mode, set: (m) => carry.gear.set({ mode: m }) }, where: where, below: _jsx(Suggestions, { rows: hints.rows, onPick: setDraft, onDismiss: hints.dismiss }), mind: mind, limits: limits, onUpgrade: () => host.go(path({ kind: 'screen', screen: 'plans' })), voice: voice, language: prefs.language ?? null, onLanguage: (id) => void savePrefs({ language: id }), talk: talk })] })] }), _jsx(Publish, { source: adding, onClose: () => setAdding(null) }), codebase ? (_jsx(SetupDialog, { repo: codebase, homes: homes, open: offering, onOpenChange: setOffering, onStart: setup, onRepo: choose, onSaved: () => {
|
|
267
287
|
setOffering(false);
|
|
268
288
|
env.reload();
|
|
269
289
|
} })) : null] }));
|
package/lib/mic.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type Machine } from '@hanzo/voice';
|
|
2
|
+
export interface DictationProps {
|
|
3
|
+
voice: Machine;
|
|
4
|
+
disabled?: boolean;
|
|
5
|
+
/** The language dictation listens in, or null for Auto (the browser's own). */
|
|
6
|
+
language?: string | null;
|
|
7
|
+
onLanguage?: (id: string | null) => void;
|
|
8
|
+
}
|
|
9
|
+
/** The mic and its chevron, one control on the composer's row. */
|
|
10
|
+
export declare function Dictation({ voice, disabled, language, onLanguage }: DictationProps): import("react").JSX.Element;
|
package/lib/mic.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Dictation: the mic, and beside it a chevron that opens its menu — the
|
|
4
|
+
* microphones this browser has, the chosen one's live level, and the language
|
|
5
|
+
* dictation listens in (Auto is the browser's own).
|
|
6
|
+
*
|
|
7
|
+
* The microphones are listed as the browser names them, the system's own
|
|
8
|
+
* input first; dictation opens that one. The meter listens only while the menu
|
|
9
|
+
* is open, and only where the person has already let the page use a
|
|
10
|
+
* microphone: opening a menu never asks for permission.
|
|
11
|
+
*/
|
|
12
|
+
import { SizableText, XStack, YStack } from '@hanzo/gui';
|
|
13
|
+
import { Check, ChevronDown, Mic as MicIcon } from '@hanzogui/lucide-icons-2';
|
|
14
|
+
import { Control } from '@hanzo/composer';
|
|
15
|
+
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, } from '@hanzo/ui';
|
|
16
|
+
import { Voice } from '@hanzo/voice';
|
|
17
|
+
import { useEffect, useRef, useState } from 'react';
|
|
18
|
+
import { LANGUAGES } from './voice.js';
|
|
19
|
+
/** The mic is a plain mark until it is pointed at. */
|
|
20
|
+
const PLAIN = { bg: 'transparent', color: 'var(--foreground)', hoverStyle: { bg: '$hover' } };
|
|
21
|
+
/** The microphones this browser lists; labels only once the page may use one. */
|
|
22
|
+
function useInputs(open) {
|
|
23
|
+
const [inputs, setInputs] = useState([]);
|
|
24
|
+
const [allowed, setAllowed] = useState(false);
|
|
25
|
+
const [using, setUsing] = useState('');
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (!open || typeof navigator === 'undefined' || !navigator.mediaDevices?.enumerateDevices)
|
|
28
|
+
return;
|
|
29
|
+
let live = true;
|
|
30
|
+
void navigator.mediaDevices.enumerateDevices().then((all) => {
|
|
31
|
+
if (!live)
|
|
32
|
+
return;
|
|
33
|
+
const mics = all.filter((d) => d.kind === 'audioinput' && d.deviceId !== 'default' && d.deviceId !== 'communications');
|
|
34
|
+
setInputs(mics);
|
|
35
|
+
// The system's own input: the group the browser's "default" entry belongs to, else the first.
|
|
36
|
+
setUsing(all.find((d) => d.kind === 'audioinput' && d.deviceId === 'default')?.groupId || mics[0]?.groupId || '');
|
|
37
|
+
setAllowed(mics.some((d) => d.label));
|
|
38
|
+
});
|
|
39
|
+
return () => {
|
|
40
|
+
live = false;
|
|
41
|
+
};
|
|
42
|
+
}, [open]);
|
|
43
|
+
return { inputs, allowed, using };
|
|
44
|
+
}
|
|
45
|
+
/** The microphone's level while the menu is open, drawn without re-rendering. */
|
|
46
|
+
function Meter({ on }) {
|
|
47
|
+
const bar = useRef(null);
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (!on)
|
|
50
|
+
return;
|
|
51
|
+
let stream = null;
|
|
52
|
+
let ctx = null;
|
|
53
|
+
let frame = 0;
|
|
54
|
+
let live = true;
|
|
55
|
+
void navigator.mediaDevices
|
|
56
|
+
.getUserMedia({ audio: true })
|
|
57
|
+
.then((s) => {
|
|
58
|
+
if (!live)
|
|
59
|
+
return s.getTracks().forEach((t) => t.stop());
|
|
60
|
+
stream = s;
|
|
61
|
+
ctx = new AudioContext();
|
|
62
|
+
const tap = ctx.createAnalyser();
|
|
63
|
+
tap.fftSize = 512;
|
|
64
|
+
ctx.createMediaStreamSource(s).connect(tap);
|
|
65
|
+
const buf = new Uint8Array(tap.fftSize);
|
|
66
|
+
const draw = () => {
|
|
67
|
+
tap.getByteTimeDomainData(buf);
|
|
68
|
+
let peak = 0;
|
|
69
|
+
for (const v of buf)
|
|
70
|
+
peak = Math.max(peak, Math.abs(v - 128));
|
|
71
|
+
if (bar.current)
|
|
72
|
+
bar.current.style.transform = `scaleX(${Math.min(1, (peak / 128) * 2.5)})`;
|
|
73
|
+
frame = requestAnimationFrame(draw);
|
|
74
|
+
};
|
|
75
|
+
draw();
|
|
76
|
+
})
|
|
77
|
+
.catch(() => { });
|
|
78
|
+
return () => {
|
|
79
|
+
live = false;
|
|
80
|
+
cancelAnimationFrame(frame);
|
|
81
|
+
stream?.getTracks().forEach((t) => t.stop());
|
|
82
|
+
void ctx?.close();
|
|
83
|
+
};
|
|
84
|
+
}, [on]);
|
|
85
|
+
return (_jsxs(XStack, { items: "center", gap: 10, px: 8, py: 8, "data-slot": "mic-meter", "aria-hidden": true, children: [_jsx(MicIcon, { size: 14, color: "$hint" }), _jsx(YStack, { flex: 1, height: 4, rounded: 2, bg: "$hover", overflow: "hidden", children: _jsx(YStack, { ref: bar, height: 4, width: "100%", bg: "$ink",
|
|
86
|
+
// inline-style: the level is written per frame onto the node; a style prop would mint a class per value.
|
|
87
|
+
style: { transform: 'scaleX(0)', transformOrigin: 'left' } }) })] }));
|
|
88
|
+
}
|
|
89
|
+
/** The mic and its chevron, one control on the composer's row. */
|
|
90
|
+
export function Dictation({ voice, disabled = false, language = null, onLanguage }) {
|
|
91
|
+
const [open, setOpen] = useState(false);
|
|
92
|
+
const { inputs, allowed, using } = useInputs(open);
|
|
93
|
+
const spoken = LANGUAGES.find((l) => l.id === language);
|
|
94
|
+
return (_jsxs(XStack, { "data-slot": "dictation", items: "center", rounded: 8, borderWidth: 1, borderColor: open ? '$borderColor' : 'transparent', hoverStyle: { borderColor: '$borderColor' }, shrink: 0, children: [_jsx(Control, { asChild: true, size: 30, ...PLAIN, children: _jsx(Voice, { voice: voice, disabled: disabled, says: { idle: 'Dictate', listening: 'Dictating — click to stop' }, children: (state) => _jsx(MicIcon, { size: 16, fill: state === 'idle' ? 'none' : 'currentColor' }) }) }), _jsxs(DropdownMenu, { open: open, onOpenChange: setOpen, children: [_jsx(DropdownMenuTrigger, { asChild: true, disabled: disabled, children: _jsx(XStack, { render: "button", "aria-label": "Dictation settings", "data-slot": "dictation-menu", width: 16, height: 30, items: "center", justify: "center", rounded: 6, cursor: "pointer", bg: open ? '$hover' : 'transparent', hoverStyle: { bg: '$hover' }, "$max-sm": { width: 28, height: 44 }, children: _jsx(ChevronDown, { size: 12, color: "$hint" }) }) }), _jsxs(DropdownMenuContent, { align: "end", minW: 200, "data-slot": "dictation-panel", children: [_jsx(Meter, { on: open && allowed }), inputs.length === 0 ? (_jsx(DropdownMenuLabel, { children: _jsx(SizableText, { fontSize: 14, color: "$hint", children: "No devices found" }) })) : (inputs.map((d, i) => (_jsx(DropdownMenuLabel, { children: _jsxs(XStack, { items: "center", gap: 8, children: [_jsx(SizableText, { fontSize: 14, color: "$ink", numberOfLines: 1, flex: 1, children: d.label || `Microphone ${i + 1}` }), d.groupId === using ? _jsx(Check, { size: 14, color: "var(--primary)", "aria-label": "In use" }) : null] }) }, d.deviceId || i)))), onLanguage ? (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), _jsx(DropdownMenuLabel, { children: _jsx(SizableText, { fontSize: 13, color: "$hint", children: "Dictation settings" }) }), _jsxs(DropdownMenuSub, { children: [_jsx(DropdownMenuSubTrigger, { "data-slot": "dictation-language", children: _jsxs(XStack, { flex: 1, items: "center", gap: 8, children: [_jsx(SizableText, { fontSize: 14, color: "$ink", flex: 1, children: "Language" }), _jsx(SizableText, { fontSize: 14, color: "$hint", children: spoken?.label ?? 'Auto' })] }) }), _jsx(DropdownMenuSubContent, { minW: 160, children: [{ id: '', label: 'Auto' }, ...LANGUAGES].map((l) => (_jsx(DropdownMenuItem, { onSelect: () => onLanguage(l.id || null), children: _jsxs(XStack, { flex: 1, items: "center", gap: 8, children: [_jsx(SizableText, { fontSize: 14, color: "$ink", flex: 1, children: l.label }), (language ?? '') === l.id ? _jsx(Check, { size: 14, color: "var(--primary)", "aria-hidden": true }) : null] }) }, l.id || 'auto'))) })] })] })) : null] })] })] }));
|
|
95
|
+
}
|
package/lib/mind.d.ts
CHANGED
|
@@ -12,6 +12,10 @@ export interface Choice {
|
|
|
12
12
|
effort: Effort;
|
|
13
13
|
}
|
|
14
14
|
export declare const FIRST: Choice;
|
|
15
|
+
/** What Free answers on: the catalog's free lane, Enso's first. `free` is an alias of it. */
|
|
16
|
+
export declare const FREE = "enso-free";
|
|
17
|
+
/** Whether a plan grants only the free lane. An unknown plan is not taken for Free. */
|
|
18
|
+
export declare const isFree: (plan: string | null | undefined) => boolean;
|
|
15
19
|
/** The kept choice, Enso and Medium where nothing is kept. Stable while the stored text is. */
|
|
16
20
|
export declare function kept(): Choice;
|
|
17
21
|
/** Keep a change, in this browser, for every surface that reads it. */
|
|
@@ -22,6 +26,10 @@ export declare function useChoice(): readonly [Choice, (patch: Partial<Choice>)
|
|
|
22
26
|
export declare function reasons(model: string, catalog: readonly Model[]): boolean;
|
|
23
27
|
/** A model's name as the catalog spells it; Enso for the router. */
|
|
24
28
|
export declare function nameOf(model: string, catalog: readonly Model[]): string;
|
|
29
|
+
/** An id said as a name: its last path part, each word capitalised (`enso-flash` → Enso Flash). */
|
|
30
|
+
export declare function spelled(id: string): string;
|
|
31
|
+
/** The ids that are the router under another name: the picker's Enso row stands for all of them. */
|
|
32
|
+
export declare const ROUTER_IDS: ReadonlySet<string>;
|
|
25
33
|
/** `GET /v1/models` for a target, asked once and shared; a refusal is not kept, so the next ask goes again. */
|
|
26
34
|
export declare function catalog(t: Target): Promise<Model[]>;
|
|
27
35
|
/** The catalog as a chooser draws it, and the list a kept pick is judged against. */
|
|
@@ -44,6 +52,8 @@ export interface Catalog {
|
|
|
44
52
|
* the reason in the chooser — never the platform's own words.
|
|
45
53
|
*/
|
|
46
54
|
export declare function useCatalog(t: Target): Catalog;
|
|
55
|
+
/** Whether `model` is one this plan may send: on Free, the catalog's free class alone. */
|
|
56
|
+
export declare function grants(free: boolean, model: string, catalog: readonly Model[]): boolean;
|
|
47
57
|
/** The chip's whole state: the catalog, what answers next, and how to change either. */
|
|
48
58
|
export interface Mind {
|
|
49
59
|
model: string;
|
|
@@ -53,8 +63,15 @@ export interface Mind {
|
|
|
53
63
|
error: string | null;
|
|
54
64
|
/** A list shown from before, because this read failed. */
|
|
55
65
|
note: string;
|
|
66
|
+
/** The plan grants only the free lane: anything else is an upgrade, never a pick. */
|
|
67
|
+
free: boolean;
|
|
56
68
|
/** A premium pick holds for this screen; any other is kept. */
|
|
57
69
|
pick: (id: string) => void;
|
|
58
70
|
pace: (effort: Effort) => void;
|
|
59
71
|
}
|
|
60
|
-
|
|
72
|
+
/**
|
|
73
|
+
* The model and effort for the plan `plan` names (`GET /v1/ai/limits` `plan`).
|
|
74
|
+
* On Free the default is `FREE` and a kept pick outside the free lane is set
|
|
75
|
+
* aside rather than sent — it comes back once the plan grants it.
|
|
76
|
+
*/
|
|
77
|
+
export declare function useMind(t: Target, plan?: string | null): Mind;
|
package/lib/mind.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* lists as not reasoning offers none; Enso and a model the catalog does not
|
|
17
17
|
* describe offer all three.
|
|
18
18
|
*/
|
|
19
|
-
import {
|
|
19
|
+
import { parseModels } from '@hanzo/ui/models/catalog';
|
|
20
20
|
import { useEffect, useState, useSyncExternalStore } from 'react';
|
|
21
21
|
import { Refusal } from './api/call.js';
|
|
22
22
|
import { ENSO, models as read } from './api/models.js';
|
|
@@ -27,6 +27,10 @@ export const EFFORTS = [
|
|
|
27
27
|
{ id: 'high', label: 'High', note: 'Slow and careful, for hard problems' },
|
|
28
28
|
];
|
|
29
29
|
export const FIRST = { model: ENSO, effort: 'medium' };
|
|
30
|
+
/** What Free answers on: the catalog's free lane, Enso's first. `free` is an alias of it. */
|
|
31
|
+
export const FREE = 'enso-free';
|
|
32
|
+
/** Whether a plan grants only the free lane. An unknown plan is not taken for Free. */
|
|
33
|
+
export const isFree = (plan) => plan === 'free';
|
|
30
34
|
const KEY = 'hanzo.mind';
|
|
31
35
|
const CHANGE = 'hanzo:mind';
|
|
32
36
|
const effortOf = (v) => (EFFORTS.some((e) => e.id === v) ? v : FIRST.effort);
|
|
@@ -103,9 +107,22 @@ export function reasons(model, catalog) {
|
|
|
103
107
|
export function nameOf(model, catalog) {
|
|
104
108
|
if (model === ENSO)
|
|
105
109
|
return 'Enso';
|
|
110
|
+
if (model === 'free')
|
|
111
|
+
return nameOf(FREE, catalog);
|
|
106
112
|
const row = catalog.find((m) => m.id === model);
|
|
107
|
-
return
|
|
113
|
+
return row?.name ?? spelled(model);
|
|
108
114
|
}
|
|
115
|
+
/** An id said as a name: its last path part, each word capitalised (`enso-flash` → Enso Flash). */
|
|
116
|
+
export function spelled(id) {
|
|
117
|
+
const tail = id.split('/').pop() ?? id;
|
|
118
|
+
return tail
|
|
119
|
+
.split(/[-_]/)
|
|
120
|
+
.filter(Boolean)
|
|
121
|
+
.map((w) => w[0].toUpperCase() + w.slice(1))
|
|
122
|
+
.join(' ');
|
|
123
|
+
}
|
|
124
|
+
/** The ids that are the router under another name: the picker's Enso row stands for all of them. */
|
|
125
|
+
export const ROUTER_IDS = new Set([ENSO, 'enso', 'hanzo/enso', 'free']);
|
|
109
126
|
// The catalog, once per platform for the page: Chat and Dev read the same list.
|
|
110
127
|
const catalogs = new Map();
|
|
111
128
|
const settled = new Map();
|
|
@@ -179,9 +196,35 @@ export function useCatalog(t) {
|
|
|
179
196
|
note: '',
|
|
180
197
|
};
|
|
181
198
|
}
|
|
182
|
-
|
|
199
|
+
/** Whether `model` is one this plan may send: on Free, the catalog's free class alone. */
|
|
200
|
+
export function grants(free, model, catalog) {
|
|
201
|
+
if (!free)
|
|
202
|
+
return true;
|
|
203
|
+
return catalog.find((m) => m.id === model)?.class === 'free';
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* The model and effort for the plan `plan` names (`GET /v1/ai/limits` `plan`).
|
|
207
|
+
* On Free the default is `FREE` and a kept pick outside the free lane is set
|
|
208
|
+
* aside rather than sent — it comes back once the plan grants it.
|
|
209
|
+
*/
|
|
210
|
+
export function useMind(t, plan) {
|
|
183
211
|
const [choice, change] = useChoice();
|
|
184
212
|
const { models, list, loading, error, note } = useCatalog(t);
|
|
185
|
-
const
|
|
186
|
-
|
|
213
|
+
const free = isFree(plan);
|
|
214
|
+
const [picked, pick] = usePick(choice.model, (id) => change({ model: id }), list);
|
|
215
|
+
const model = free && !grants(true, picked, models) ? FREE : picked;
|
|
216
|
+
return {
|
|
217
|
+
model,
|
|
218
|
+
effort: choice.effort,
|
|
219
|
+
models,
|
|
220
|
+
loading,
|
|
221
|
+
error,
|
|
222
|
+
note,
|
|
223
|
+
free,
|
|
224
|
+
pick: (id) => {
|
|
225
|
+
if (grants(free, id, models))
|
|
226
|
+
pick(id);
|
|
227
|
+
},
|
|
228
|
+
pace: (effort) => change({ effort }),
|
|
229
|
+
};
|
|
187
230
|
}
|
package/lib/panel.js
CHANGED
|
@@ -17,15 +17,18 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
17
17
|
* (`frames`) — and offers any other address in a new window instead of a frame
|
|
18
18
|
* the site would refuse.
|
|
19
19
|
*
|
|
20
|
-
* The column the panel stands in is the host's
|
|
21
|
-
*
|
|
20
|
+
* The column the panel stands in is the host's (side.tsx `useSplit`): its
|
|
21
|
+
* width and edge. The strip reads it for full screen (Escape returns) and, while
|
|
22
|
+
* the panel has the middle column's room, the way back to it. On a phone the
|
|
23
|
+
* panel is the whole window instead (`sheet`).
|
|
22
24
|
*/
|
|
23
25
|
import { SizableText, XStack, YStack } from '@hanzo/gui';
|
|
24
|
-
import { ArrowLeft, ArrowRight, ChevronRight, Columns2, ExternalLink, Globe, Maximize2, Minus, MoreHorizontal, Pin, Plus, RotateCcw, RotateCw, SquarePlus, X } from '@hanzogui/lucide-icons-2';
|
|
26
|
+
import { ArrowLeft, ArrowRight, ChevronRight, Columns2, ExternalLink, Globe, Maximize2, Minimize2, Minus, MoreHorizontal, PanelLeftOpen, Pin, Plus, RotateCcw, RotateCw, SquarePlus, X } from '@hanzogui/lucide-icons-2';
|
|
25
27
|
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, } from '@hanzo/ui';
|
|
26
28
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
27
29
|
import { createPortal } from 'react-dom';
|
|
28
30
|
import { useBrowse, useBrowser } from './host.js';
|
|
31
|
+
import { useSide } from './side.js';
|
|
29
32
|
import { destination, PAGE } from './tabs.js';
|
|
30
33
|
const MAC = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
|
|
31
34
|
/** The tabs' shortcut, as this platform writes it. */
|
|
@@ -357,6 +360,9 @@ function Strip({ tab, on, side, index, label, icon, ...rest }) {
|
|
|
357
360
|
/** The panel: the strip, and the chosen tab. */
|
|
358
361
|
export function Panel({ side, kinds, onHide, sheet = false, bar, menu, label = 'Side panel', suggested, ...acts }) {
|
|
359
362
|
const tab = side.tabs.find((t) => t.id === side.at) ?? null;
|
|
363
|
+
// The column it stands in; a phone's sheet is the whole window already.
|
|
364
|
+
const stands = useSide();
|
|
365
|
+
const column = sheet ? null : stands;
|
|
360
366
|
// The chosen tab is in view in a strip that scrolls: a tab just opened stands at its end.
|
|
361
367
|
useEffect(() => {
|
|
362
368
|
if (side.at)
|
|
@@ -370,14 +376,15 @@ export function Panel({ side, kinds, onHide, sheet = false, bar, menu, label = '
|
|
|
370
376
|
const blank = () => side.open({ kind: PAGE, title: BLANK, url: '' });
|
|
371
377
|
const poppable = tab?.kind === PAGE && (tab.body !== undefined || Boolean(tab.url));
|
|
372
378
|
const plus = (_jsx(XStack, { render: "button", role: "button", "aria-label": "Open a tab", width: 28, height: 28, ...quiet(), onPress: closed.length ? undefined : blank, children: _jsx(Plus, { size: 16 }) }));
|
|
373
|
-
const body = (_jsxs(YStack, { role: "complementary", "aria-label": label, "data-slot": "side", flex: 1, minW: 0, minH: 0, overflow: "hidden", style: { borderRadius: 'inherit' }, children: [_jsxs(XStack, { "data-slot": "side-strip", height: STRIP, px: "$2", gap: "$1.5", items: "center", borderBottomWidth: 1, borderColor: "$borderColor", shrink: 0, children: [_jsx(XStack, { role: "tablist", "aria-label": "Tabs", shrink: 1, minW: 0, gap: "$1", items: "center", overflowX: "auto", overflowY: "hidden", "$platform-web": { scrollbarWidth: 'none' }, children: side.tabs.map((t, i) => (_jsx(Leaf, { tab: t, on: t.id === side.at, side: side, index: i, label: title(t), icon: mark(t) }, t.id))) }), closed.length ? (_jsx(DropdownMenu, { trigger: plus, items: [
|
|
379
|
+
const body = (_jsxs(YStack, { role: "complementary", "aria-label": label, "data-slot": "side", flex: 1, minW: 0, minH: 0, overflow: "hidden", style: { borderRadius: 'inherit' }, children: [_jsxs(XStack, { "data-slot": "side-strip", height: STRIP, px: "$2", gap: "$1.5", items: "center", borderBottomWidth: 1, borderColor: "$borderColor", shrink: 0, children: [column?.hidden && !column.full ? (_jsxs(XStack, { render: "button", role: "button", "data-slot": "side-show", onPress: column.show, height: 32, px: "$2", gap: "$1.5", ...quiet(), children: [_jsx(PanelLeftOpen, { size: 16 }), _jsx(SizableText, { fontSize: 13, color: "$ink", children: `Show ${column.main}` })] })) : null, _jsx(XStack, { role: "tablist", "aria-label": "Tabs", shrink: 1, minW: 0, gap: "$1", items: "center", overflowX: "auto", overflowY: "hidden", "$platform-web": { scrollbarWidth: 'none' }, children: side.tabs.map((t, i) => (_jsx(Leaf, { tab: t, on: t.id === side.at, side: side, index: i, label: title(t), icon: mark(t) }, t.id))) }), closed.length ? (_jsx(DropdownMenu, { trigger: plus, items: [
|
|
374
380
|
...closed.map((k) => ({ key: k.id, label: k.label, onSelect: () => side.open({ kind: k.id, title: k.label }) })),
|
|
375
381
|
{ key: PAGE, label: 'Page', description: 'An address, framed here', onSelect: blank },
|
|
376
|
-
] })) : (plus), _jsx(XStack, { flex: 1 }), menu, _jsx(XStack, { render: "button", role: "button", "aria-label": "Open this tab in a new window", "aria-disabled": !poppable || undefined, onPress: poppable ? () => popOut(tab) : undefined, width: 28, height: 28, ...quiet(false, !poppable), children: _jsx(Maximize2, { size: 14 }) }), _jsx(XStack, { render: "button", role: "button", "data-slot": "side-toggle", "aria-label": "Hide tabs", "aria-expanded": true, "aria-keyshortcuts": SHORTCUTS, ...{ title: `Hide tabs (${KEYS})` }, onPress: onHide, width: 32, height: 32, ...quiet(true), children: _jsx(Columns2, { size: 16 }) })] }), bar, _jsx(YStack, { id: "side-body", role: "tabpanel", "aria-labelledby": tab ? `side-tab-${tab.id}` : undefined,
|
|
382
|
+
] })) : (plus), _jsx(XStack, { flex: 1 }), menu, _jsx(XStack, { render: "button", role: "button", "aria-label": "Open this tab in a new window", "aria-disabled": !poppable || undefined, onPress: poppable ? () => popOut(tab) : undefined, width: 28, height: 28, ...quiet(false, !poppable), children: _jsx(ExternalLink, { size: 14 }) }), column ? (_jsx(XStack, { render: "button", role: "button", "data-slot": "side-full", "aria-label": column.full ? 'Exit full screen' : 'Full screen', "aria-pressed": column.full, "aria-keyshortcuts": column.full ? 'Escape' : undefined, ...{ title: column.full ? 'Exit full screen (Esc)' : 'Full screen' }, onPress: () => column.fill(!column.full), width: 28, height: 28, ...quiet(column.full), children: column.full ? _jsx(Minimize2, { size: 14 }) : _jsx(Maximize2, { size: 14 }) })) : null, _jsx(XStack, { render: "button", role: "button", "data-slot": "side-toggle", "aria-label": "Hide tabs", "aria-expanded": true, "aria-keyshortcuts": SHORTCUTS, ...{ title: `Hide tabs (${KEYS})` }, onPress: onHide, width: 32, height: 32, ...quiet(true), children: _jsx(Columns2, { size: 16 }) })] }), bar, _jsx(YStack, { id: "side-body", role: "tabpanel", "aria-labelledby": tab ? `side-tab-${tab.id}` : undefined,
|
|
377
383
|
// What a tab lays over itself (a door's "Connecting…") stays inside it, under the strip.
|
|
378
384
|
position: "relative", flex: 1, minH: 0, overflowY: bleed ? 'hidden' : 'auto', overflowX: "hidden", px: bleed ? 0 : '$3', py: bleed ? 0 : '$3', children: !tab ? (_jsx(YStack, { flex: 1, items: "center", justify: "center", p: "$5", children: _jsx(SizableText, { fontSize: 13, color: "$soft", children: "No tabs open." }) })) : tab.kind === PAGE ? (_jsx(Page, { tab: tab, onGo: (url) => side.go(tab.id, url), onBack: () => side.back(tab.id), onForward: () => side.forward(tab.id), onTitle: (t) => side.rename(tab.id, t), onPin: (on) => (on ? side.pin(tab.id) : side.unpin(tab.id)), suggested: suggested, ...acts }, tab.id)) : kind ? (kind.render()) : (_jsx(SizableText, { size: "$2", color: "$soft", children: "This tab is not offered here." })) })] }));
|
|
379
385
|
if (!sheet || typeof document === 'undefined')
|
|
380
386
|
return body;
|
|
381
|
-
// A phone:
|
|
382
|
-
|
|
387
|
+
// A phone: the panel over the whole window, clear of the notch and the home
|
|
388
|
+
// indicator. The strip's toggle puts it away.
|
|
389
|
+
return createPortal(_jsx(YStack, { "data-slot": "side-sheet", position: "fixed", t: 0, r: 0, b: 0, l: 0, z: 60, bg: "$background", style: { paddingTop: 'env(safe-area-inset-top)', paddingBottom: 'env(safe-area-inset-bottom)' }, children: body }), document.body);
|
|
383
390
|
}
|
package/lib/plugin.d.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A plugin: one thing the composer can do beyond words, picked from `+` ›
|
|
3
|
+
* Plugins or by typing `@`. Chat and Dev offer the same list (`usePlugins`,
|
|
4
|
+
* plugins/), each one running on Hanzo's own services.
|
|
5
|
+
*
|
|
6
|
+
* A plugin acts in one of two ways, or both:
|
|
7
|
+
*
|
|
8
|
+
* open picked, it shows something of its own (a sketch pad, a picker)
|
|
9
|
+
* and hands its result back to the composer: a file, the draft.
|
|
10
|
+
* prepare while it is on in the conversation (Gear.plugins), it is asked
|
|
11
|
+
* just before each message goes, and what it answers rides that
|
|
12
|
+
* message: words for the leading system turn, files, or an answer
|
|
13
|
+
* of its own that replaces the model's (`reply`).
|
|
14
|
+
*
|
|
15
|
+
* A plugin whose service is not there for this person is not listed: `usePlugins`
|
|
16
|
+
* answers only the ones that run.
|
|
17
|
+
*/
|
|
18
|
+
import type { ReactNode } from 'react';
|
|
19
|
+
import type { Target } from './api/call.ts';
|
|
20
|
+
/** What a plugin may do to the composer it was picked from. */
|
|
21
|
+
export interface Composer {
|
|
22
|
+
target: Target;
|
|
23
|
+
draft: string;
|
|
24
|
+
setDraft: (text: string) => void;
|
|
25
|
+
/** Hold files with the next message, as the paperclip does. */
|
|
26
|
+
attach: (files: File[]) => void;
|
|
27
|
+
/** Draw a dialog over the page; null takes it down. */
|
|
28
|
+
show: (node: ReactNode | null) => void;
|
|
29
|
+
/** Move the app (a path of the host's, or an address on another origin). */
|
|
30
|
+
go: (href: string) => void;
|
|
31
|
+
}
|
|
32
|
+
/** The message about to go. */
|
|
33
|
+
export interface Turn {
|
|
34
|
+
text: string;
|
|
35
|
+
files: File[];
|
|
36
|
+
/** The conversation's id, or '' for one not begun. */
|
|
37
|
+
thread: string;
|
|
38
|
+
}
|
|
39
|
+
/** What a plugin adds to the message. */
|
|
40
|
+
export interface Prep {
|
|
41
|
+
/** Appended to the leading system turn. */
|
|
42
|
+
system?: string;
|
|
43
|
+
/** Files to send beside the person's. */
|
|
44
|
+
files?: File[];
|
|
45
|
+
/** An answer of the plugin's own, in markdown; the model is not asked. */
|
|
46
|
+
reply?: string;
|
|
47
|
+
}
|
|
48
|
+
export interface Plugin {
|
|
49
|
+
id: string;
|
|
50
|
+
name: string;
|
|
51
|
+
/** One line: what it does. */
|
|
52
|
+
note: string;
|
|
53
|
+
icon: ReactNode;
|
|
54
|
+
open?: (c: Composer) => void;
|
|
55
|
+
prepare?: (turn: Turn, c: Composer, signal: AbortSignal) => Promise<Prep>;
|
|
56
|
+
}
|
package/lib/plugin.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/lib/plus.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
/** One thing a shelf holds. `on` marks what the conversation already has. */
|
|
3
|
+
export interface Row {
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
icon?: ReactNode;
|
|
7
|
+
/** A few dim words beside the label, where they help. */
|
|
8
|
+
note?: string;
|
|
9
|
+
on?: boolean;
|
|
10
|
+
}
|
|
11
|
+
export interface Link {
|
|
12
|
+
label: string;
|
|
13
|
+
onPress: () => void;
|
|
14
|
+
}
|
|
15
|
+
export interface Shelf {
|
|
16
|
+
rows: Row[];
|
|
17
|
+
pick: (id: string) => void;
|
|
18
|
+
loading?: boolean;
|
|
19
|
+
error?: string | null;
|
|
20
|
+
/** A search field over the rows, with this placeholder. */
|
|
21
|
+
search?: string;
|
|
22
|
+
/** Said when there are no rows and nothing is loading. */
|
|
23
|
+
empty?: string;
|
|
24
|
+
create?: Link;
|
|
25
|
+
manage?: Link;
|
|
26
|
+
browse?: Link;
|
|
27
|
+
}
|
|
28
|
+
export interface Switch {
|
|
29
|
+
on: boolean;
|
|
30
|
+
set: (on: boolean) => void;
|
|
31
|
+
}
|
|
32
|
+
export interface PlusProps {
|
|
33
|
+
/** Upload or add files (Ctrl+U). */
|
|
34
|
+
upload?: () => void;
|
|
35
|
+
agents?: Shelf;
|
|
36
|
+
skills?: Shelf;
|
|
37
|
+
connectors?: Shelf;
|
|
38
|
+
design?: Shelf;
|
|
39
|
+
plugins?: Shelf;
|
|
40
|
+
devices?: Shelf;
|
|
41
|
+
research?: Switch;
|
|
42
|
+
memory?: Switch;
|
|
43
|
+
disabled?: boolean;
|
|
44
|
+
}
|
|
45
|
+
/** The `+` and its menu. */
|
|
46
|
+
export declare function Plus(p: PlusProps): import("react").JSX.Element;
|
package/lib/plus.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* The composer's `+`: files, then what the conversation works with — agents,
|
|
4
|
+
* skills, connectors, a design system, plugins, a device — then the Research
|
|
5
|
+
* and Memory switches. A row is drawn only when the surface hands it a shelf:
|
|
6
|
+
* a shelf is what is there (`rows`), what picking one does, and the ways to
|
|
7
|
+
* make, manage and find more. A row with nothing behind it is not drawn.
|
|
8
|
+
*
|
|
9
|
+
* On a laptop each shelf opens beside the menu; on a phone it is a page of the
|
|
10
|
+
* same menu, with Back.
|
|
11
|
+
*/
|
|
12
|
+
import { SizableText, useMedia, XStack } from '@hanzo/gui';
|
|
13
|
+
import { Blocks, Bot, Briefcase, Brain, Monitor, Palette, Paperclip, Plug, Plus as PlusIcon, ScrollText, Telescope, } from '@hanzogui/lucide-icons-2';
|
|
14
|
+
import { DropdownMenu, DropdownMenuBack, DropdownMenuContent, DropdownMenuEmpty, DropdownMenuItem, DropdownMenuLoading, DropdownMenuPage, DropdownMenuSearch, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, } from '@hanzo/ui';
|
|
15
|
+
import { Check } from '@hanzogui/lucide-icons-2';
|
|
16
|
+
import { useMemo, useState } from 'react';
|
|
17
|
+
const ICON = 16;
|
|
18
|
+
/** The shelves, in the menu's order, with their names and marks. */
|
|
19
|
+
const SHELVES = [
|
|
20
|
+
{ key: 'agents', label: 'Agents', icon: _jsx(Bot, { size: ICON }) },
|
|
21
|
+
{ key: 'skills', label: 'Skills', icon: _jsx(ScrollText, { size: ICON }) },
|
|
22
|
+
{ key: 'connectors', label: 'Connectors', icon: _jsx(Blocks, { size: ICON }) },
|
|
23
|
+
{ key: 'design', label: 'Design system', icon: _jsx(Palette, { size: ICON }) },
|
|
24
|
+
{ key: 'plugins', label: 'Plugins', icon: _jsx(Plug, { size: ICON }) },
|
|
25
|
+
{ key: 'devices', label: 'Devices', icon: _jsx(Monitor, { size: ICON }) },
|
|
26
|
+
];
|
|
27
|
+
/** A row's anatomy: its mark, its label, a dim note, and a check when it is on. */
|
|
28
|
+
function Line({ icon, label, note, on, tail }) {
|
|
29
|
+
return (_jsxs(XStack, { flex: 1, minW: 0, items: "center", gap: 10, children: [icon ? (_jsx(XStack, { width: ICON, height: ICON, items: "center", justify: "center", shrink: 0, "aria-hidden": true, children: icon })) : null, _jsx(SizableText, { fontSize: 14, color: "$ink", numberOfLines: 1, shrink: 0, children: label }), note ? (_jsx(SizableText, { fontSize: 13, color: "$hint", numberOfLines: 1, flex: 1, minW: 0, children: note })) : (_jsx(XStack, { flex: 1 })), tail, on ? _jsx(Check, { size: ICON, color: "var(--primary)", "aria-hidden": true }) : null] }));
|
|
30
|
+
}
|
|
31
|
+
/** A shelf's rows, its status, then its ways to more. */
|
|
32
|
+
function Contents({ shelf, name, close }) {
|
|
33
|
+
const [q, setQ] = useState('');
|
|
34
|
+
const needle = q.trim().toLowerCase();
|
|
35
|
+
const rows = needle ? shelf.rows.filter((r) => `${r.label} ${r.note ?? ''}`.toLowerCase().includes(needle)) : shelf.rows;
|
|
36
|
+
const links = [shelf.create, shelf.manage, shelf.browse].filter((l) => Boolean(l));
|
|
37
|
+
return (_jsxs(_Fragment, { children: [shelf.search ? _jsx(DropdownMenuSearch, { value: q, onValueChange: setQ, placeholder: shelf.search, "aria-label": `Search ${name.toLowerCase()}` }) : null, shelf.loading && !shelf.rows.length ? _jsxs(DropdownMenuLoading, { children: ["Reading ", name.toLowerCase()] }) : null, shelf.error ? _jsx(DropdownMenuEmpty, { children: shelf.error }) : null, !shelf.loading && !shelf.error && !rows.length ? _jsx(DropdownMenuEmpty, { children: needle ? 'Nothing matches' : (shelf.empty ?? `No ${name.toLowerCase()} yet`) }) : null, rows.map((r) => (_jsx(DropdownMenuItem, { preventCloseOnSelect: true, onSelect: () => shelf.pick(r.id), "aria-checked": r.on ?? false, "data-slot": "plus-row", children: _jsx(Line, { icon: r.icon, label: r.label, note: r.note, on: r.on }) }, r.id))), links.length ? _jsx(DropdownMenuSeparator, {}) : null, links.map((l, i) => (_jsx(DropdownMenuItem, { onSelect: () => {
|
|
38
|
+
close();
|
|
39
|
+
l.onPress();
|
|
40
|
+
}, children: _jsx(Line, { icon: l === shelf.manage ? _jsx(Briefcase, { size: ICON }) : _jsx(PlusIcon, { size: ICON }), label: l.label }) }, l.label)))] }));
|
|
41
|
+
}
|
|
42
|
+
/** The `+` and its menu. */
|
|
43
|
+
export function Plus(p) {
|
|
44
|
+
const media = useMedia();
|
|
45
|
+
const wide = media.sm;
|
|
46
|
+
const [open, setOpen] = useState(false);
|
|
47
|
+
const close = () => setOpen(false);
|
|
48
|
+
const shelves = useMemo(() => SHELVES.filter((s) => p[s.key]), [p]);
|
|
49
|
+
const switches = [
|
|
50
|
+
p.research ? { key: 'research', label: 'Research', icon: _jsx(Telescope, { size: ICON }), s: p.research } : null,
|
|
51
|
+
p.memory ? { key: 'memory', label: 'Memory', icon: _jsx(Brain, { size: ICON }), s: p.memory } : null,
|
|
52
|
+
].filter((x) => x !== null);
|
|
53
|
+
const main = (_jsxs(_Fragment, { children: [p.upload ? (_jsx(DropdownMenuItem, { "data-slot": "plus-upload", onSelect: () => {
|
|
54
|
+
close();
|
|
55
|
+
p.upload?.();
|
|
56
|
+
}, children: _jsx(Line, { icon: _jsx(Paperclip, { size: ICON }), label: "Upload or add files", tail: _jsx(DropdownMenuShortcut, { children: "Ctrl+U" }) }) })) : null, p.upload && shelves.length ? _jsx(DropdownMenuSeparator, {}) : null, shelves.map((s) => wide ? (_jsxs(DropdownMenuSub, { children: [_jsx(DropdownMenuSubTrigger, { "data-slot": `plus-${s.key}`, children: _jsx(Line, { icon: s.icon, label: s.label }) }), _jsx(DropdownMenuSubContent, { minW: 200, maxW: 340, maxH: 420, style: { overflowY: 'auto' }, children: _jsx(Contents, { shelf: p[s.key], name: s.label, close: close }) })] }, s.key)) : (_jsx(DropdownMenuItem, { page: s.key, "data-slot": `plus-${s.key}`, children: _jsx(Line, { icon: s.icon, label: s.label }) }, s.key))), switches.length && (p.upload || shelves.length) ? _jsx(DropdownMenuSeparator, {}) : null, switches.map((w) => (_jsx(DropdownMenuItem, { preventCloseOnSelect: true, onSelect: () => w.s.set(!w.s.on), role: 'menuitemcheckbox', "aria-checked": w.s.on, "data-slot": `plus-${w.key}`, children: _jsx(Line, { icon: w.icon, label: w.label, on: w.s.on }) }, w.key)))] }));
|
|
57
|
+
return (_jsxs(DropdownMenu, { open: open, onOpenChange: setOpen, ...(wide ? null : { defaultPage: 'main' }), children: [_jsx(DropdownMenuTrigger, { asChild: true, disabled: p.disabled, children: _jsx(XStack, { render: "button", "data-slot": "plus", "aria-label": "Add files, agents, skills and more", width: 32, height: 32, rounded: 8, items: "center", justify: "center", shrink: 0, cursor: p.disabled ? 'default' : 'pointer', opacity: p.disabled ? 0.4 : 1, bg: open ? '$hover' : 'transparent', hoverStyle: { bg: '$hover' }, focusVisibleStyle: { outlineColor: '$outlineColor', outlineWidth: 2, outlineStyle: 'solid' }, "$max-sm": { width: 44, height: 44, rounded: 22 }, children: _jsx(PlusIcon, { size: 18, color: "$ink" }) }) }), _jsx(DropdownMenuContent, { align: "start", minW: 240, "data-slot": "plus-menu", children: wide ? (main) : (_jsxs(_Fragment, { children: [_jsx(DropdownMenuPage, { id: "main", children: main }), shelves.map((s) => (_jsxs(DropdownMenuPage, { id: s.key, children: [_jsx(DropdownMenuBack, { children: s.label }), _jsx(Contents, { shelf: p[s.key], name: s.label, close: close })] }, s.key)))] })) })] }));
|
|
58
|
+
}
|
package/lib/prefs.d.ts
CHANGED
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
* The last document read is kept in this browser, per org, so the page is
|
|
8
8
|
* drawn the person's way before the platform answers. A save shows at once and
|
|
9
9
|
* is taken back, key by key, if the platform refuses it.
|
|
10
|
+
*
|
|
11
|
+
* A host mounts it too, above the builder (the Hanzo app, for Chat's side
|
|
12
|
+
* panel), and the builder's own then reads through that one: one document, one
|
|
13
|
+
* read. The look (`Look`) is the builder's alone: a host has its own.
|
|
10
14
|
*/
|
|
11
15
|
import { type ReactNode } from 'react';
|
|
12
16
|
import { type Patch, type Prefs } from './api/pref.ts';
|
|
@@ -20,11 +24,20 @@ export interface PrefState {
|
|
|
20
24
|
}
|
|
21
25
|
export declare function PrefsProvider({ children }: {
|
|
22
26
|
children: ReactNode;
|
|
23
|
-
}): import("react").JSX.Element;
|
|
27
|
+
}): string | number | bigint | boolean | import("react").JSX.Element | Iterable<ReactNode> | Promise<string | number | bigint | boolean | Iterable<ReactNode> | import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>> | import("react").ReactPortal | null | undefined> | null | undefined;
|
|
28
|
+
/** The settings; with `false`, null where nothing holds them (the side panel drawn without a host: hanzo.team's rooms). */
|
|
24
29
|
export declare function usePrefs(): PrefState;
|
|
30
|
+
export declare function usePrefs(need: false): PrefState | null;
|
|
25
31
|
/** The type scale each text size is drawn at. Medium is the design system's own. */
|
|
26
32
|
export declare const SCALE: {
|
|
27
33
|
readonly small: 0.9;
|
|
28
34
|
readonly medium: 1;
|
|
29
35
|
readonly large: 1.15;
|
|
30
36
|
};
|
|
37
|
+
/**
|
|
38
|
+
* The saved theme handed to the host, and the text size and motion put on the
|
|
39
|
+
* page. A stylesheet rather than a property on <html>: the design system's own
|
|
40
|
+
* root re-applies its appearance on mount, and a rule marked important outlasts
|
|
41
|
+
* that, while the builder is on the page and no longer.
|
|
42
|
+
*/
|
|
43
|
+
export declare function Look(): import("react").JSX.Element | null;
|