@hanzo/rooms 0.1.0
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/LICENSE-APACHE +205 -0
- package/LICENSE-MIT +21 -0
- package/README.md +23 -0
- package/dist/Account.d.ts +8 -0
- package/dist/Account.js +58 -0
- package/dist/Beluga.d.ts +12 -0
- package/dist/Beluga.js +152 -0
- package/dist/Billing.d.ts +1 -0
- package/dist/Billing.js +31 -0
- package/dist/Board.d.ts +9 -0
- package/dist/Board.js +447 -0
- package/dist/Boxes.d.ts +1 -0
- package/dist/Boxes.js +173 -0
- package/dist/CalScheduler.d.ts +15 -0
- package/dist/CalScheduler.js +196 -0
- package/dist/Chat.d.ts +35 -0
- package/dist/Chat.js +1049 -0
- package/dist/Column.d.ts +15 -0
- package/dist/Column.js +88 -0
- package/dist/Contacts.d.ts +1 -0
- package/dist/Contacts.js +301 -0
- package/dist/Directory.d.ts +24 -0
- package/dist/Directory.js +356 -0
- package/dist/Drive.d.ts +2 -0
- package/dist/Drive.js +466 -0
- package/dist/Find.d.ts +7 -0
- package/dist/Find.js +82 -0
- package/dist/Guide.d.ts +6 -0
- package/dist/Guide.js +272 -0
- package/dist/Home.d.ts +39 -0
- package/dist/Home.js +306 -0
- package/dist/Inbox.d.ts +9 -0
- package/dist/Inbox.js +193 -0
- package/dist/Market.d.ts +3 -0
- package/dist/Market.js +149 -0
- package/dist/Me.d.ts +27 -0
- package/dist/Me.js +251 -0
- package/dist/Meet.d.ts +9 -0
- package/dist/Meet.js +370 -0
- package/dist/ModeMark.d.ts +8 -0
- package/dist/ModeMark.js +18 -0
- package/dist/OrgSwitcher.d.ts +31 -0
- package/dist/OrgSwitcher.js +120 -0
- package/dist/Orgs.d.ts +10 -0
- package/dist/Orgs.js +582 -0
- package/dist/Profile.d.ts +4 -0
- package/dist/Profile.js +67 -0
- package/dist/Prose.d.ts +5 -0
- package/dist/Prose.js +111 -0
- package/dist/ProviderMark.d.ts +17 -0
- package/dist/ProviderMark.js +277 -0
- package/dist/Recents.d.ts +10 -0
- package/dist/Recents.js +91 -0
- package/dist/RightPane.d.ts +13 -0
- package/dist/RightPane.js +426 -0
- package/dist/Room.d.ts +8 -0
- package/dist/Room.js +50 -0
- package/dist/Scene.d.ts +12 -0
- package/dist/Scene.js +105 -0
- package/dist/Settings.d.ts +46 -0
- package/dist/Settings.js +321 -0
- package/dist/Setup.d.ts +1 -0
- package/dist/Setup.js +36 -0
- package/dist/Share.d.ts +3 -0
- package/dist/Share.js +130 -0
- package/dist/Shared.d.ts +1 -0
- package/dist/Shared.js +113 -0
- package/dist/Shell.d.ts +63 -0
- package/dist/Shell.js +1225 -0
- package/dist/Start.d.ts +1 -0
- package/dist/Start.js +96 -0
- package/dist/Support.d.ts +14 -0
- package/dist/Support.js +122 -0
- package/dist/Talk.d.ts +4 -0
- package/dist/Talk.js +107 -0
- package/dist/Upgrade.d.ts +11 -0
- package/dist/Upgrade.js +75 -0
- package/dist/Usage.d.ts +1 -0
- package/dist/Usage.js +21 -0
- package/dist/Vibe.d.ts +2 -0
- package/dist/Vibe.js +188 -0
- package/dist/Work.d.ts +12 -0
- package/dist/Work.js +256 -0
- package/dist/bots/Bots.d.ts +2 -0
- package/dist/bots/Bots.js +456 -0
- package/dist/bots/Lights.d.ts +8 -0
- package/dist/bots/Lights.js +13 -0
- package/dist/bots/api.d.ts +120 -0
- package/dist/bots/api.js +75 -0
- package/dist/bots/face.d.ts +5 -0
- package/dist/bots/face.js +13 -0
- package/dist/bots/ink.d.ts +7 -0
- package/dist/bots/ink.js +29 -0
- package/dist/bots/kit.d.ts +113 -0
- package/dist/bots/kit.js +43 -0
- package/dist/bots/runtime.d.ts +4 -0
- package/dist/bots/runtime.js +326 -0
- package/dist/card.d.ts +52 -0
- package/dist/card.js +52 -0
- package/dist/cast.d.ts +143 -0
- package/dist/cast.js +251 -0
- package/dist/connect.d.ts +29 -0
- package/dist/connect.js +64 -0
- package/dist/contacts-store.d.ts +111 -0
- package/dist/contacts-store.js +276 -0
- package/dist/conversations.d.ts +54 -0
- package/dist/conversations.js +98 -0
- package/dist/copy.d.ts +23 -0
- package/dist/copy.js +39 -0
- package/dist/crew.d.ts +36 -0
- package/dist/crew.js +304 -0
- package/dist/dev.d.ts +3 -0
- package/dist/dev.js +102 -0
- package/dist/enso.d.ts +70 -0
- package/dist/enso.js +112 -0
- package/dist/failure.d.ts +57 -0
- package/dist/failure.js +143 -0
- package/dist/ground.d.ts +32 -0
- package/dist/ground.js +32 -0
- package/dist/host.d.ts +76 -0
- package/dist/host.js +76 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +12 -0
- package/dist/kept.d.ts +5 -0
- package/dist/kept.js +27 -0
- package/dist/lib/account.d.ts +49 -0
- package/dist/lib/account.js +229 -0
- package/dist/lib/ai.d.ts +46 -0
- package/dist/lib/ai.js +140 -0
- package/dist/lib/api.d.ts +43 -0
- package/dist/lib/api.js +62 -0
- package/dist/lib/bare.d.ts +9 -0
- package/dist/lib/bare.js +7 -0
- package/dist/lib/coding.d.ts +105 -0
- package/dist/lib/coding.js +87 -0
- package/dist/lib/destination.d.ts +102 -0
- package/dist/lib/destination.js +260 -0
- package/dist/lib/durable.d.ts +66 -0
- package/dist/lib/durable.js +107 -0
- package/dist/lib/first.d.ts +11 -0
- package/dist/lib/first.js +23 -0
- package/dist/lib/home.d.ts +61 -0
- package/dist/lib/home.js +54 -0
- package/dist/lib/host.d.ts +104 -0
- package/dist/lib/host.js +132 -0
- package/dist/lib/hostname.d.ts +1 -0
- package/dist/lib/hostname.js +14 -0
- package/dist/lib/hydrated.d.ts +1 -0
- package/dist/lib/hydrated.js +20 -0
- package/dist/lib/integrations.d.ts +21 -0
- package/dist/lib/integrations.js +1 -0
- package/dist/lib/intent.d.ts +6 -0
- package/dist/lib/intent.js +71 -0
- package/dist/lib/invite.d.ts +155 -0
- package/dist/lib/invite.js +245 -0
- package/dist/lib/limits.d.ts +55 -0
- package/dist/lib/limits.js +119 -0
- package/dist/lib/list.d.ts +9 -0
- package/dist/lib/list.js +11 -0
- package/dist/lib/mark.d.ts +13 -0
- package/dist/lib/mark.js +85 -0
- package/dist/lib/mix.d.ts +22 -0
- package/dist/lib/mix.js +22 -0
- package/dist/lib/models.d.ts +27 -0
- package/dist/lib/models.js +106 -0
- package/dist/lib/org.d.ts +109 -0
- package/dist/lib/org.js +140 -0
- package/dist/lib/path.d.ts +9 -0
- package/dist/lib/path.js +29 -0
- package/dist/lib/plans.d.ts +193 -0
- package/dist/lib/plans.js +310 -0
- package/dist/lib/prose.d.ts +13 -0
- package/dist/lib/prose.js +62 -0
- package/dist/lib/reach.d.ts +17 -0
- package/dist/lib/reach.js +70 -0
- package/dist/lib/rooms.json +23 -0
- package/dist/lib/served.d.ts +20 -0
- package/dist/lib/served.js +92 -0
- package/dist/lib/session.d.ts +211 -0
- package/dist/lib/session.js +493 -0
- package/dist/lib/share.d.ts +131 -0
- package/dist/lib/share.js +216 -0
- package/dist/lib/signout.d.ts +14 -0
- package/dist/lib/signout.js +40 -0
- package/dist/lib/tags.d.ts +27 -0
- package/dist/lib/tags.js +46 -0
- package/dist/lib/tap.d.ts +17 -0
- package/dist/lib/tap.js +9 -0
- package/dist/lib/tier.d.ts +115 -0
- package/dist/lib/tier.js +207 -0
- package/dist/lib/tiers.d.ts +9 -0
- package/dist/lib/tiers.js +141 -0
- package/dist/lib/todo.d.ts +125 -0
- package/dist/lib/todo.js +379 -0
- package/dist/look.d.ts +1 -0
- package/dist/look.js +21 -0
- package/dist/meters.d.ts +8 -0
- package/dist/meters.js +31 -0
- package/dist/model.d.ts +4 -0
- package/dist/model.js +44 -0
- package/dist/network.d.ts +64 -0
- package/dist/network.js +216 -0
- package/dist/open.d.ts +81 -0
- package/dist/open.js +149 -0
- package/dist/pane.d.ts +92 -0
- package/dist/pane.js +231 -0
- package/dist/progress.d.ts +46 -0
- package/dist/progress.js +51 -0
- package/dist/recent.d.ts +67 -0
- package/dist/recent.js +161 -0
- package/dist/rooms.d.ts +131 -0
- package/dist/rooms.js +195 -0
- package/dist/roster.d.ts +28 -0
- package/dist/roster.js +70 -0
- package/dist/span.d.ts +14 -0
- package/dist/span.js +50 -0
- package/dist/speech.d.ts +44 -0
- package/dist/speech.js +229 -0
- package/dist/stars.d.ts +16 -0
- package/dist/stars.js +75 -0
- package/dist/tabs.d.ts +26 -0
- package/dist/tabs.js +25 -0
- package/dist/team.d.ts +11 -0
- package/dist/team.gen.d.ts +10 -0
- package/dist/team.gen.js +11 -0
- package/dist/team.js +30 -0
- package/dist/tools.d.ts +35 -0
- package/dist/tools.js +143 -0
- package/dist/tours.d.ts +24 -0
- package/dist/tours.js +311 -0
- package/dist/waiting.d.ts +15 -0
- package/dist/waiting.js +17 -0
- package/dist/where.d.ts +54 -0
- package/dist/where.js +14 -0
- package/package.json +108 -0
package/dist/Inbox.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
// The inbox: what arrived, on whichever network carried it.
|
|
4
|
+
//
|
|
5
|
+
// IT READS LIKE MAIL, because that is what it is. The list stands beside what is
|
|
6
|
+
// open rather than being replaced by it: picking a message used to swap the
|
|
7
|
+
// whole pane, so choosing the next one meant going back first, and the list —
|
|
8
|
+
// the thing that tells you which to read — was never on screen while you read.
|
|
9
|
+
// Two columns is how every mail client has answered this, and the reason is the
|
|
10
|
+
// same here.
|
|
11
|
+
//
|
|
12
|
+
// ONE READING, MANY TRANSPORTS. `channel` is measured by the server, so the
|
|
13
|
+
// mark beside each face is a fact about the message. Nothing here enumerates
|
|
14
|
+
// the networks; `network.tsx` names the ones it can draw and falls back for the
|
|
15
|
+
// rest, so a fifth transport appears the day the platform reports one.
|
|
16
|
+
//
|
|
17
|
+
// IT HOLDS WHAT OTHER NETWORKS CARRY IN, and only that. The rooms this org
|
|
18
|
+
// talks in are places on this platform, read and answered on /home
|
|
19
|
+
// (`rooms.ts`); an arrival is a thread you REPLY to on the transport it came
|
|
20
|
+
// in on, and `conversations.ts` keys the two apart so one selection slot never
|
|
21
|
+
// holds two id spaces.
|
|
22
|
+
//
|
|
23
|
+
// IT IS TWO-WAY NOW, and the note that said otherwise had outlived the route it
|
|
24
|
+
// described. `@hanzo/ai`'s channels client still opens "INBOUND ONLY … a client
|
|
25
|
+
// that offered send() would be inventing a route", and the platform publishes
|
|
26
|
+
// `POST /v1/channels/{channel}/send` — measured on api.hanzo.ai, with the
|
|
27
|
+
// envelope's outbound projection spelled in its own description. So the reply
|
|
28
|
+
// goes through `client.http` here rather than through a client method that does
|
|
29
|
+
// not exist yet; when the SDK gains one, this call site is the only thing that
|
|
30
|
+
// moves.
|
|
31
|
+
//
|
|
32
|
+
// A reply box that cannot reply is still worse than none, which is why this one
|
|
33
|
+
// is drawn only where there is a room id to address.
|
|
34
|
+
import { useCallback, useMemo, useRef, useState } from 'react';
|
|
35
|
+
import { ChannelHeader } from '@hanzo/ui/agents';
|
|
36
|
+
import { Composer } from '@hanzo/ui/chat';
|
|
37
|
+
import { Box, Text, XStack, YStack } from '@hanzo/ui';
|
|
38
|
+
import { useIam } from '@hanzo/iam/react';
|
|
39
|
+
import { Plus } from 'lucide-react';
|
|
40
|
+
import { useAi } from './lib/ai.js';
|
|
41
|
+
import { useOpen } from './open.js';
|
|
42
|
+
import { Face } from './cast.js';
|
|
43
|
+
import { plainly } from './failure.js';
|
|
44
|
+
import { Network, of, roomName, speaker } from './network.js';
|
|
45
|
+
import { conversationOf, useConversations, } from './conversations.js';
|
|
46
|
+
import { Directory } from './Directory.js';
|
|
47
|
+
import { BAD } from './lib/mix.js';
|
|
48
|
+
/** Unix SECONDS, which is what the platform sends. Milliseconds here read as 1970. */
|
|
49
|
+
const when = (seconds) => new Date(seconds * 1000).toLocaleString(undefined, {
|
|
50
|
+
month: 'short',
|
|
51
|
+
day: 'numeric',
|
|
52
|
+
hour: 'numeric',
|
|
53
|
+
minute: '2-digit',
|
|
54
|
+
});
|
|
55
|
+
/** Today says the time; anything older says the day. A list scans on the part
|
|
56
|
+
* that differs, and for mail from this morning that is the clock. */
|
|
57
|
+
function stamp(seconds) {
|
|
58
|
+
const then = new Date(seconds * 1000);
|
|
59
|
+
const now = new Date();
|
|
60
|
+
const sameDay = then.getFullYear() === now.getFullYear() &&
|
|
61
|
+
then.getMonth() === now.getMonth() &&
|
|
62
|
+
then.getDate() === now.getDate();
|
|
63
|
+
return then.toLocaleString(undefined, sameDay ? { hour: 'numeric', minute: '2-digit' } : { month: 'short', day: 'numeric' });
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* WHO WROTE, AND FROM WHERE — one mark.
|
|
67
|
+
*
|
|
68
|
+
* The face is a disc and the network is a square tucked into its corner,
|
|
69
|
+
* because they answer two different questions and a reader should not have to
|
|
70
|
+
* tell two circles apart. The disc draws neither picture nor initials: the
|
|
71
|
+
* platform sends no likeness, and there is no name to reduce to letters — it
|
|
72
|
+
* drew the head of the platform's id, which on Slack is the same letter for
|
|
73
|
+
* everybody, so it told nobody apart while looking like somebody's initials.
|
|
74
|
+
* A disc with nothing to say says '?', which is what it means.
|
|
75
|
+
*/
|
|
76
|
+
function Sender({ channel, size = 36 }) {
|
|
77
|
+
return (_jsxs(YStack, { width: size, height: size, shrink: 0, position: "relative", children: [_jsx(Face, { size: size }), _jsx(YStack, { position: "absolute", b: -2, r: -2, children: _jsx(Network, { channel: channel, size: Math.round(size * 0.46) }) })] }));
|
|
78
|
+
}
|
|
79
|
+
/** Where a network is connected. Opens My Channels modal. */
|
|
80
|
+
function Connect() {
|
|
81
|
+
const [modalOpen, setModalOpen] = useState(false);
|
|
82
|
+
return (_jsxs(_Fragment, { children: [_jsx(Box, { render: "button", onClick: () => setModalOpen(true), px: "$3", py: "$2", rounded: "$3", borderWidth: 1, borderColor: "$borderColor", hoverStyle: { bg: '$hover' }, cursor: "pointer", children: _jsxs(XStack, { items: "center", gap: "$1.5", children: [_jsx(Plus, { size: 13, "aria-hidden": true }), _jsx(Text, { fontSize: "$2", color: "$ink", children: "Connect an app" })] }) }), _jsx(Directory, { open: modalOpen, onClose: () => setModalOpen(false), tab: "channels" })] }));
|
|
83
|
+
}
|
|
84
|
+
function Empty({ children, offer }) {
|
|
85
|
+
return (_jsxs(YStack, { flex: 1, items: "center", justify: "center", p: "$6", gap: "$3", children: [_jsx(Text, { render: "h1", fontSize: "$4", fontWeight: "600", color: "$ink", children: "Inbox" }), _jsx(Text, { fontSize: "$3", color: "$soft", text: "center", maxW: 420, children: children }), offer ? _jsx(Connect, {}) : null] }));
|
|
86
|
+
}
|
|
87
|
+
/** The list, which stays on screen while you read down it. */
|
|
88
|
+
function Arrivals({ rooms, open, onPick, }) {
|
|
89
|
+
return (_jsxs(YStack, { flex: 1, minH: 0, children: [_jsxs(XStack, { height: 48, shrink: 0, items: "center", px: "$3", gap: "$2", borderBottomWidth: 1, borderColor: "$borderColor", children: [_jsx(Text, { flex: 1, fontSize: "$3", fontWeight: "600", color: "$ink", children: "Inbox" }), _jsx(Connect, {})] }), _jsx(YStack, { flex: 1, minH: 0, overflow: "scroll", children: rooms.map((one) => (_jsx(Box, { render: "button", onClick: () => onPick(one.key), "aria-current": one.key === open, display: "block", width: "100%", cursor: "pointer", borderBottomWidth: 1, borderColor: "var(--borderColor, var(--white-08))", bg: one.key === open ? 'var(--raised, var(--white-06))' : 'transparent', hoverStyle: { bg: '$hover' }, children: _jsxs(XStack, { gap: "$3", items: "center", p: "$3", children: [_jsx(Sender, { channel: one.channel }), _jsxs(YStack, { flex: 1, minW: 0, gap: "$1", items: "flex-start", children: [_jsxs(XStack, { width: "100%", items: "baseline", gap: "$2", children: [_jsx(Text, { fontSize: "$3", fontWeight: "600", color: "$ink", numberOfLines: 1, flex: 1, text: "left", children: one.title }), one.lastAt ? (_jsx(Text, { fontSize: "$1", color: "$soft", children: stamp(one.lastAt / 1000) })) : null] }), one.preview ? (_jsx(Text, { fontSize: "$2", color: "$soft", numberOfLines: 1, children: one.preview })) : null] })] }) }, one.key))) })] }));
|
|
90
|
+
}
|
|
91
|
+
/** What is open, beside the list rather than instead of it. */
|
|
92
|
+
function Reading({ room, reload, }) {
|
|
93
|
+
const { client } = useAi();
|
|
94
|
+
const [draft, setDraft] = useState('');
|
|
95
|
+
const [sending, setSending] = useState(false);
|
|
96
|
+
const [failed, setFailed] = useState(null);
|
|
97
|
+
/**
|
|
98
|
+
* Say it back, on the transport it arrived on.
|
|
99
|
+
*
|
|
100
|
+
* IDENTITY IS NOT A FIELD: the channel is the path segment and the sender is
|
|
101
|
+
* the caller's validated org, and the route refuses a body carrying `sender`,
|
|
102
|
+
* `account` or `channel` with a 400 rather than dropping it quietly. So the
|
|
103
|
+
* body is the room and the words, and nothing else.
|
|
104
|
+
*
|
|
105
|
+
* The room's own refusals are the reader's situation and are shown as the
|
|
106
|
+
* platform words them — a room this org has not bound is 403, and one whose
|
|
107
|
+
* route the bot has never learned is 409, which means somebody has to message
|
|
108
|
+
* it there first. `plainly` keeps those and answers for the estate's own.
|
|
109
|
+
*
|
|
110
|
+
* The list is re-read rather than appended to: what the transport accepted is
|
|
111
|
+
* what it assigned an id and a landing second to, and inventing a local row
|
|
112
|
+
* would put a message on screen that may not have gone.
|
|
113
|
+
*/
|
|
114
|
+
const send = useCallback(async () => {
|
|
115
|
+
const text = draft.trim();
|
|
116
|
+
if (!client || !text || sending || !room.roomId)
|
|
117
|
+
return;
|
|
118
|
+
setSending(true);
|
|
119
|
+
setFailed(null);
|
|
120
|
+
try {
|
|
121
|
+
await client.http.json({
|
|
122
|
+
method: 'POST',
|
|
123
|
+
path: `/v1/channels/${encodeURIComponent(room.channel)}/send`,
|
|
124
|
+
body: { room: { id: room.roomId }, text },
|
|
125
|
+
});
|
|
126
|
+
setDraft('');
|
|
127
|
+
reload();
|
|
128
|
+
}
|
|
129
|
+
catch (e) {
|
|
130
|
+
setFailed(plainly(e, 'this room'));
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
setSending(false);
|
|
134
|
+
}
|
|
135
|
+
}, [client, draft, sending, room.channel, room.roomId, reload]);
|
|
136
|
+
return (_jsxs(YStack, { flex: 1, minH: 0, children: [_jsx(ChannelHeader, { name: roomName(room), children: _jsxs(XStack, { items: "center", gap: "$1.5", children: [_jsx(Network, { channel: room.channel, size: 14 }), _jsx(Text, { fontSize: "$2", color: "$soft", children: of(room.channel).name })] }) }), _jsx(YStack, { flex: 1, minH: 0, overflow: "scroll", p: "$4", gap: "$3", children: [...room.messages]
|
|
137
|
+
.sort((a, b) => a.createdAt - b.createdAt)
|
|
138
|
+
.map((message) => (_jsxs(XStack, { gap: "$3", items: "flex-start", children: [_jsx(Sender, { channel: room.channel, size: 32 }), _jsxs(YStack, { flex: 1, minW: 0, gap: "$1", children: [_jsxs(XStack, { gap: "$2", items: "baseline", children: [_jsx(Text, { fontSize: "$3", fontWeight: "500", color: "$ink", children: speaker(room.channel, message.sender) }), _jsx(Text, { fontSize: "$1", color: "$soft", children: when(message.createdAt) })] }), _jsx(Text, { fontSize: "$3", color: "$ink", children: message.text })] })] }, message.id))) }), _jsx(YStack, { p: "$3", gap: "$2", borderTopWidth: 1, borderColor: "$borderColor", children: room.roomId ? (_jsxs(_Fragment, { children: [_jsx(Composer, { value: draft, onChange: setDraft, onSend: () => void send(), busy: sending, placeholder: `Reply on ${of(room.channel).name}`, label: `Reply on ${of(room.channel).name}` }), failed ? (_jsx(Text, { fontSize: "$2", color: BAD, children: failed })) : null] })) : (_jsx(Text, { fontSize: "$2", color: "$soft", children: "This message names no room to answer in." })) })] }));
|
|
139
|
+
}
|
|
140
|
+
function InboxGrip({ span, onSpan, }) {
|
|
141
|
+
const held = useRef(0);
|
|
142
|
+
return (_jsx(Box, { render: "div", role: "separator", "aria-orientation": "vertical", "aria-label": "Resize inbox channel list", "aria-valuenow": span, tabIndex: 0, position: "absolute", t: 0, b: 0, r: -3, width: 6, z: 10, cursor: "col-resize", hoverStyle: { bg: '$borderColor' }, onPointerDown: (e) => {
|
|
143
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
144
|
+
held.current = e.clientX - span;
|
|
145
|
+
}, onPointerMove: (e) => {
|
|
146
|
+
if (!e.currentTarget.hasPointerCapture(e.pointerId))
|
|
147
|
+
return;
|
|
148
|
+
const want = e.clientX - held.current;
|
|
149
|
+
const clamped = Math.min(540, Math.max(220, want));
|
|
150
|
+
onSpan(clamped);
|
|
151
|
+
}, onPointerUp: () => {
|
|
152
|
+
try {
|
|
153
|
+
localStorage.setItem('hanzo.inbox.span', String(span));
|
|
154
|
+
}
|
|
155
|
+
catch { }
|
|
156
|
+
} }));
|
|
157
|
+
}
|
|
158
|
+
function ReadingInbox() {
|
|
159
|
+
const { room, openRoom } = useOpen();
|
|
160
|
+
const { conversations, loading, reload } = useConversations();
|
|
161
|
+
const [listSpan, setListSpan] = useState(() => {
|
|
162
|
+
if (typeof window === 'undefined')
|
|
163
|
+
return 320;
|
|
164
|
+
try {
|
|
165
|
+
const stored = localStorage.getItem('hanzo.inbox.span');
|
|
166
|
+
return stored ? Math.max(220, Math.min(540, parseInt(stored, 10))) : 320;
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return 320;
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
const open = useMemo(() => conversationOf(conversations, room), [conversations, room]);
|
|
173
|
+
if (loading && conversations.length === 0)
|
|
174
|
+
return _jsx(Empty, { children: "Reading your messages." });
|
|
175
|
+
if (conversations.length === 0) {
|
|
176
|
+
return (_jsx(Empty, { offer: true, children: "Nothing has arrived yet. Connect an app and the messages it carries land here \u2014 mail, chat, and the networks your work already happens on." }));
|
|
177
|
+
}
|
|
178
|
+
return (_jsxs(XStack, { flex: 1, minH: 0, position: "relative", children: [_jsxs(YStack, { display: open ? 'none' : 'flex', $md: { display: 'flex', width: listSpan, shrink: 0, borderRightWidth: 1 }, flex: 1, minH: 0, borderColor: "$borderColor", position: "relative", children: [_jsx(Arrivals, { rooms: conversations, open: room, onPick: openRoom }), _jsx(InboxGrip, { span: listSpan, onSpan: setListSpan })] }), open ? (_jsx(YStack, { flex: 1, minW: 0, minH: 0, children: _jsx(Reading, { room: open.arrival, reload: reload }) })) : (_jsx(YStack, { display: "none", $md: { display: 'flex' }, flex: 1, minW: 0, children: _jsx(Empty, { children: "Pick a conversation to read it." }) }))] }));
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* The pane, and the session its reads need.
|
|
182
|
+
*
|
|
183
|
+
* SPLIT so the hooks below never run for a reader with none: every route this
|
|
184
|
+
* pane reads is org-scoped and answers 403 without one, and a hook cannot be
|
|
185
|
+
* called conditionally — so the condition is a component boundary rather than
|
|
186
|
+
* an `if` the rules forbid.
|
|
187
|
+
*/
|
|
188
|
+
export function Inbox() {
|
|
189
|
+
const { isLoading } = useIam();
|
|
190
|
+
if (isLoading)
|
|
191
|
+
return _jsx(Empty, { children: "\u00A0" });
|
|
192
|
+
return _jsx(ReadingInbox, {});
|
|
193
|
+
}
|
package/dist/Market.d.ts
ADDED
package/dist/Market.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
// The shelf: agents an org can bring into Hanzo, and the ones it already has.
|
|
4
|
+
//
|
|
5
|
+
// EVERY ROW IS REAL. The listings come from the platform's canonical copy of
|
|
6
|
+
// registry.modelcontextprotocol.io — tens of thousands of published servers —
|
|
7
|
+
// so this file names no vendor and hardcodes no catalogue. A brand appears here
|
|
8
|
+
// the day its publisher pushes an MCP server, and disappears when they pull it,
|
|
9
|
+
// without this surface being edited.
|
|
10
|
+
//
|
|
11
|
+
// INSTALL IS THE PLATFORM'S ONE INSTALL. `POST /v1/tools/mcp/servers` with a
|
|
12
|
+
// listing id creates the SAME MCPServer row that typing a URL by hand creates,
|
|
13
|
+
// so an org has one place its servers live however they got there.
|
|
14
|
+
//
|
|
15
|
+
// A STDIO-ONLY LISTING CANNOT BE INSTALLED HERE and the card says so rather
|
|
16
|
+
// than offering a button that fails: a remote endpoint is something this app can
|
|
17
|
+
// reach, a stdio package is a process someone still has to run.
|
|
18
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
19
|
+
import { say } from './failure.js';
|
|
20
|
+
import { Check, Search } from 'lucide-react';
|
|
21
|
+
import { Box, Text, XStack, YStack } from '@hanzo/ui';
|
|
22
|
+
import { useIam } from '@hanzo/iam/react';
|
|
23
|
+
import { hasSession } from './lib/session.js';
|
|
24
|
+
import { useAi } from './lib/ai.js';
|
|
25
|
+
/** A listing reachable over HTTP is one this app can connect on its own. */
|
|
26
|
+
const reachable = (listing) => (listing.transports ?? []).includes('streamable-http');
|
|
27
|
+
/** Labels a namespace carries that name no brand: `com.cloudflare.mcp` is
|
|
28
|
+
* Cloudflare, and `io.sanity.www` is Sanity. */
|
|
29
|
+
const PLUMBING = new Set(['mcp', 'www', 'api', 'app', 'cloud', 'io', 'server']);
|
|
30
|
+
const label = (listing) => {
|
|
31
|
+
if (listing.title)
|
|
32
|
+
return listing.title;
|
|
33
|
+
// A listing without a title still has a publisher, and a reverse-DNS vendor
|
|
34
|
+
// names it — but the LAST label is not always the brand: `com.cloudflare.mcp`
|
|
35
|
+
// ends in the word every entry here would end in. So read from the right and
|
|
36
|
+
// take the first label that is a name rather than plumbing.
|
|
37
|
+
const brand = (listing.vendor ?? '')
|
|
38
|
+
.split('.')
|
|
39
|
+
.reverse()
|
|
40
|
+
.find((part) => part && !PLUMBING.has(part));
|
|
41
|
+
if (brand)
|
|
42
|
+
return brand.charAt(0).toUpperCase() + brand.slice(1);
|
|
43
|
+
return listing.name?.split('/').pop() || listing.id;
|
|
44
|
+
};
|
|
45
|
+
function Empty({ children }) {
|
|
46
|
+
return (_jsx(YStack, { flex: 1, items: "center", justify: "center", p: "$6", children: _jsx(Text, { fontSize: "$3", color: "$soft", text: "center", children: children }) }));
|
|
47
|
+
}
|
|
48
|
+
/** The publisher's mark, or its initial. Never a generated face for a brand. */
|
|
49
|
+
function Mark({ listing }) {
|
|
50
|
+
return (_jsx(YStack, { width: 40, height: 40, shrink: 0, rounded: "$3", bg: "$panel", items: "center", justify: "center", overflow: "hidden", children: listing.logo ? (_jsx("img", { src: listing.logo, alt: "", width: 40, height: 40 })) : (_jsx(Text, { fontSize: "$4", fontWeight: "500", color: "$soft", children: label(listing).slice(0, 1).toUpperCase() })) }));
|
|
51
|
+
}
|
|
52
|
+
function Card({ listing, installed, onInstall, busy, }) {
|
|
53
|
+
const canInstall = reachable(listing);
|
|
54
|
+
return (_jsxs(YStack, { width: 300, gap: "$3", p: "$4", borderWidth: 1, borderColor: "$borderColor", rounded: "$4", children: [_jsxs(XStack, { gap: "$3", items: "flex-start", children: [_jsx(Mark, { listing: listing }), _jsxs(YStack, { flex: 1, minW: 0, gap: "$1", children: [_jsx(Text, { fontSize: "$3", fontWeight: "500", color: "$ink", numberOfLines: 1, children: label(listing) }), _jsx(Text, { fontSize: "$1", color: "$soft", numberOfLines: 1, children: listing.vendor ?? '' })] })] }), _jsx(Text, { fontSize: "$2", color: "$soft", numberOfLines: 3, children: listing.description || 'No description published.' }), installed ? (_jsxs(XStack, { items: "center", gap: "$2", children: [_jsx(Check, { size: 14, "aria-hidden": true }), _jsx(Text, { fontSize: "$2", color: "$soft", children: "Installed" })] })) : canInstall ? (_jsx(Box, { render: "button", onClick: onInstall, self: "flex-start", px: "$3", py: "$2", rounded: "$3", borderWidth: 1, borderColor: "$borderColor", hoverStyle: { bg: '$hover' }, children: _jsx(Text, { fontSize: "$2", color: "$ink", children: busy ? 'Installing' : 'Install' }) })) : (_jsxs(Text, { fontSize: "$1", color: "$soft", children: ["Ships as a ", (listing.packages ?? [])[0]?.registry ?? 'stdio', " package \u2014 needs a runner before it can be connected."] }))] }));
|
|
55
|
+
}
|
|
56
|
+
export function Market({ scope }) {
|
|
57
|
+
const { client } = useAi();
|
|
58
|
+
// SIGNED IN IS THE CREDENTIAL, NOT THE PROFILE. This gated on `user`, which is
|
|
59
|
+
// the answer to a userinfo REQUEST — and `IamProvider` swallows that request's
|
|
60
|
+
// failure, so a blip or a slow reply left `isAuthenticated` true and `user`
|
|
61
|
+
// null, and this pane told somebody holding a perfectly good token to sign in.
|
|
62
|
+
// The token is what the API accepts; the profile is decoration on top of it.
|
|
63
|
+
const { isAuthenticated, isLoading } = useIam();
|
|
64
|
+
const [ask, setAsk] = useState('');
|
|
65
|
+
const [listings, setListings] = useState(null);
|
|
66
|
+
const [mine, setMine] = useState([]);
|
|
67
|
+
const [total, setTotal] = useState(0);
|
|
68
|
+
const [busy, setBusy] = useState(null);
|
|
69
|
+
const [failed, setFailed] = useState(null);
|
|
70
|
+
// The shelf is org-scoped, so a signed-out reader has none to read. Asking
|
|
71
|
+
// anyway answers 403 and the SDK's sentence is written for whoever wired the
|
|
72
|
+
// client, not for a reader.
|
|
73
|
+
const ready = Boolean(client && isAuthenticated);
|
|
74
|
+
const reload = useCallback(() => {
|
|
75
|
+
if (!ready || !client)
|
|
76
|
+
return;
|
|
77
|
+
client.tools
|
|
78
|
+
.mcp()
|
|
79
|
+
.then(setMine)
|
|
80
|
+
.catch(() => setMine([]));
|
|
81
|
+
}, [client, ready]);
|
|
82
|
+
useEffect(reload, [reload]);
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (!ready || !client)
|
|
85
|
+
return;
|
|
86
|
+
let live = true;
|
|
87
|
+
// The catalog is the one paginated surface here, so it is read whole and
|
|
88
|
+
// the page size is stated rather than left to the default.
|
|
89
|
+
client.tools
|
|
90
|
+
// BROWSING SHOWS THE SHELF; SEARCHING SHOWS THE WAREHOUSE.
|
|
91
|
+
//
|
|
92
|
+
// The registry holds tens of thousands of servers, and a grid of all of
|
|
93
|
+
// them is a directory listing rather than a storefront — the first screen
|
|
94
|
+
// would be whatever sorts first alphabetically. So an empty box asks for
|
|
95
|
+
// the curated ones and typing asks the whole catalog: same route, one
|
|
96
|
+
// parameter, and the count below says which shelf you are looking at.
|
|
97
|
+
.catalog({ q: ask || undefined, featured: ask ? undefined : true, limit: 60 })
|
|
98
|
+
.then((page) => {
|
|
99
|
+
if (!live)
|
|
100
|
+
return;
|
|
101
|
+
setListings(page.catalog ?? []);
|
|
102
|
+
setTotal(page.total ?? 0);
|
|
103
|
+
})
|
|
104
|
+
.catch((e) => live && setFailed(e.message));
|
|
105
|
+
return () => {
|
|
106
|
+
live = false;
|
|
107
|
+
};
|
|
108
|
+
}, [client, ready, ask]);
|
|
109
|
+
const installed = useMemo(() => new Set(mine.map((server) => server.listing).filter(Boolean)), [mine]);
|
|
110
|
+
const install = useCallback(async (listing) => {
|
|
111
|
+
if (!client)
|
|
112
|
+
return;
|
|
113
|
+
setBusy(listing.id);
|
|
114
|
+
try {
|
|
115
|
+
await client.http.json({
|
|
116
|
+
method: 'POST',
|
|
117
|
+
path: '/v1/tools/mcp/servers',
|
|
118
|
+
body: { listing: listing.id },
|
|
119
|
+
});
|
|
120
|
+
reload();
|
|
121
|
+
}
|
|
122
|
+
catch (e) {
|
|
123
|
+
setFailed(e.message);
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
setBusy(null);
|
|
127
|
+
}
|
|
128
|
+
}, [client, reload]);
|
|
129
|
+
const shown = useMemo(() => {
|
|
130
|
+
const all = listings ?? [];
|
|
131
|
+
return scope === 'installed' ? all.filter((l) => installed.has(l.id)) : all;
|
|
132
|
+
}, [listings, installed, scope]);
|
|
133
|
+
const signedIn = isAuthenticated || hasSession();
|
|
134
|
+
if (isLoading)
|
|
135
|
+
return _jsx(Empty, { children: "\u00A0" });
|
|
136
|
+
if (!signedIn)
|
|
137
|
+
return _jsx(Empty, { children: "Sign in to browse agents you can add." });
|
|
138
|
+
if (failed)
|
|
139
|
+
return _jsx(Empty, { children: say(failed, 'the marketplace') });
|
|
140
|
+
return (_jsxs(YStack, { flex: 1, minH: 0, children: [_jsxs(YStack, { p: "$5", gap: "$2", borderBottomWidth: 1, borderColor: "$borderColor", children: [_jsx(Text, { fontSize: "$7", lineHeight: "$7", fontWeight: "500", color: "$ink", children: "Put AI to work in Hanzo" }), _jsx(Text, { fontSize: "$3", color: "$soft", children: "Browse agents that bring your tools into Hanzo." }), _jsxs(XStack, { mt: "$2", width: 420, maxW: "100%", height: 36, items: "center", gap: "$2", px: "$3", rounded: "$3", borderWidth: 1, borderColor: "$borderColor", children: [_jsx(Search, { size: 14, "aria-hidden": true }), _jsx(Text, { render: _jsx("input", { value: ask, onChange: (e) => setAsk(e.target.value), placeholder: "Search agents" }), flex: 1, bg: "transparent", borderWidth: 0, outlineStyle: "none", color: "inherit" })] })] }), !listings ? (_jsx(Empty, { children: "Reading the shelf." })) : shown.length === 0 ? (_jsx(Empty, { children: scope === 'installed'
|
|
141
|
+
? 'No agents installed yet.'
|
|
142
|
+
: ask
|
|
143
|
+
? 'Nothing in the registry matches that.'
|
|
144
|
+
: 'The shelf is empty — the catalog has not been synced yet.' })) : (_jsxs(YStack, { flex: 1, minH: 0, overflow: "scroll", p: "$5", gap: "$3", children: [_jsx(Text, { fontSize: "$1", color: "$soft", children: scope === 'installed'
|
|
145
|
+
? `${shown.length} installed`
|
|
146
|
+
: ask
|
|
147
|
+
? `${shown.length} of ${total} matching`
|
|
148
|
+
: `${shown.length} featured` }), _jsx(XStack, { flexWrap: "wrap", gap: "$3", children: shown.map((listing) => (_jsx(Card, { listing: listing, installed: installed.has(listing.id), busy: busy === listing.id, onInstall: () => void install(listing) }, listing.id))) })] }))] }));
|
|
149
|
+
}
|
package/dist/Me.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type Host } from '@hanzo/build';
|
|
2
|
+
/** One organization as the menu draws it. */
|
|
3
|
+
export interface Org {
|
|
4
|
+
name: string;
|
|
5
|
+
display: string;
|
|
6
|
+
personal: boolean;
|
|
7
|
+
role: string;
|
|
8
|
+
}
|
|
9
|
+
/** The first letters of a name, at most two: Antje Worring → AW. */
|
|
10
|
+
export declare const initials: (name: string) => string;
|
|
11
|
+
/** The person's picture, or their initials. */
|
|
12
|
+
export declare function Face({ host, size }: {
|
|
13
|
+
host: Host;
|
|
14
|
+
size?: number;
|
|
15
|
+
}): import("react").JSX.Element;
|
|
16
|
+
export declare function Me({ host, narrow, onOrg, }: {
|
|
17
|
+
host: Host;
|
|
18
|
+
/** The collapsed rail's: the face alone, the menu opening beside it. */
|
|
19
|
+
narrow?: boolean;
|
|
20
|
+
/** Work in another organization: picked, then the app opened in it. */
|
|
21
|
+
onOrg: (org: string) => void;
|
|
22
|
+
}): import("react").JSX.Element;
|
|
23
|
+
/**
|
|
24
|
+
* What became of an organization this tab created: nothing to say once the token
|
|
25
|
+
* carries it (the app is already in it), and a plain line while it does not.
|
|
26
|
+
*/
|
|
27
|
+
export declare function Made(): import("react").JSX.Element;
|
package/dist/Me.js
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
// The account row at the foot of the app's column, and the menu it opens.
|
|
4
|
+
//
|
|
5
|
+
// The row says who is signed in and where: the person's face or initials, their
|
|
6
|
+
// name, and their role in the organization they work in. The menu lists every
|
|
7
|
+
// organization the token names — the personal one first, with its plan — and
|
|
8
|
+
// switches with `pick()` (lib/auth/session.ts), the one switch the app has.
|
|
9
|
+
// Under the list: Create organization, Organization settings, and Hanzo Team —
|
|
10
|
+
// opened in the same organization for a team, offered as "Create a team" for a
|
|
11
|
+
// personal one. Then the balance with Add funds, Settings, Usage, the plans,
|
|
12
|
+
// help and Log out. A SuperAdmin also gets All organizations (support mode).
|
|
13
|
+
//
|
|
14
|
+
// IAM is the source of every fact here: names and roles from the token's `orgs`
|
|
15
|
+
// claim, display names and which org is personal from `GET /v1/iam/organizations`,
|
|
16
|
+
// the plan and balance from billing. An organization is created by the same
|
|
17
|
+
// `POST /v1/account/orgs` the no-organization gate uses (components/workspace/
|
|
18
|
+
// Orgs.tsx); the next load mints a token that carries it.
|
|
19
|
+
import { useEffect, useRef, useState } from 'react';
|
|
20
|
+
import { Popover, SizableText, View, XStack, YStack } from '@hanzo/gui';
|
|
21
|
+
import { Building2, ChevronsUpDown, ExternalLink, Gauge, LifeBuoy, LogOut, Plus, Settings, Sparkles, Users, X } from 'lucide-react';
|
|
22
|
+
import { path, useWho } from '@hanzo/build';
|
|
23
|
+
import { useOrganizations } from '@hanzo/iam/react';
|
|
24
|
+
import { Button, Dialog, DialogContent, DialogTitle, Input } from '@hanzo/ui';
|
|
25
|
+
import { glass } from '@hanzo/ui/glass';
|
|
26
|
+
import { formatCents } from '@hanzo/usage';
|
|
27
|
+
import { api } from './lib/api.js';
|
|
28
|
+
import { createOrg, say } from './lib/org.js';
|
|
29
|
+
import { useTier } from './lib/tier.js';
|
|
30
|
+
import { list } from './lib/list.js';
|
|
31
|
+
import { org as current, orgs, pick, renew, scope, superAdmin } from './lib/session.js';
|
|
32
|
+
import { payPage, planName } from './lib/plans.js';
|
|
33
|
+
import { ENTRY } from './lib/host.js';
|
|
34
|
+
import { SupportPicker } from './Support.js';
|
|
35
|
+
import { BAD } from './lib/mix.js';
|
|
36
|
+
/** The documentation Get help opens. */
|
|
37
|
+
const DOCS = 'https://docs.hanzo.ai/docs/dev';
|
|
38
|
+
/** Where an organization's own settings live. */
|
|
39
|
+
const ORG_SETTINGS = '/settings/organization';
|
|
40
|
+
/** An organization this tab created, until the token carries it. */
|
|
41
|
+
const MADE = 'hanzo:org:made';
|
|
42
|
+
/** A role as a person reads it: `owner` → Owner. */
|
|
43
|
+
const said = (role) => (role ? role.charAt(0).toUpperCase() + role.slice(1) : '');
|
|
44
|
+
/** The first letters of a name, at most two: Antje Worring → AW. */
|
|
45
|
+
export const initials = (name) => name
|
|
46
|
+
.split(/[\s@._-]+/)
|
|
47
|
+
.filter(Boolean)
|
|
48
|
+
.slice(0, 2)
|
|
49
|
+
.map((w) => w.charAt(0).toUpperCase())
|
|
50
|
+
.join('') || '?';
|
|
51
|
+
/** The plan's name as billing sells it. */
|
|
52
|
+
const planOf = (tier) => (tier ? planName(tier.plan) || tier.tier?.displayName || tier.tier?.name || '' : '');
|
|
53
|
+
/**
|
|
54
|
+
* The token's organizations with IAM's rows beside them: display name, and which
|
|
55
|
+
* one is the person's own. The personal organization leads, then the token's order.
|
|
56
|
+
*/
|
|
57
|
+
function useOrgs(current) {
|
|
58
|
+
const { roles } = useOrganizations();
|
|
59
|
+
const names = orgs();
|
|
60
|
+
const [rows, setRows] = useState([]);
|
|
61
|
+
const key = names.join(',');
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (!key)
|
|
64
|
+
return;
|
|
65
|
+
const stop = new AbortController();
|
|
66
|
+
fetch(`${api()}/v1/iam/organizations?limit=100`, { headers: scope(), signal: stop.signal })
|
|
67
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
68
|
+
.then((body) => {
|
|
69
|
+
if (!stop.signal.aborted)
|
|
70
|
+
setRows(list(body?.organizations));
|
|
71
|
+
})
|
|
72
|
+
.catch(() => { });
|
|
73
|
+
return () => stop.abort();
|
|
74
|
+
}, [key, current]);
|
|
75
|
+
const drawn = names.map((name) => {
|
|
76
|
+
const row = rows.find((r) => r?.name === name);
|
|
77
|
+
return { name, display: row?.displayName || name, personal: Boolean(row?.isPersonal), role: roles?.[name] ?? '' };
|
|
78
|
+
});
|
|
79
|
+
return [...drawn.filter((o) => o.personal), ...drawn.filter((o) => !o.personal)];
|
|
80
|
+
}
|
|
81
|
+
/** The plan of an organization other than the current one: billing, asked in its name. */
|
|
82
|
+
function usePlan(org, enabled) {
|
|
83
|
+
const [plan, setPlan] = useState('');
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
if (!enabled || !org)
|
|
86
|
+
return;
|
|
87
|
+
const stop = new AbortController();
|
|
88
|
+
fetch(`${api()}/v1/billing/tier`, { headers: { ...scope(), 'X-Org-Id': org }, signal: stop.signal })
|
|
89
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
90
|
+
.then((t) => {
|
|
91
|
+
if (!stop.signal.aborted)
|
|
92
|
+
setPlan(planOf(t));
|
|
93
|
+
})
|
|
94
|
+
.catch(() => { });
|
|
95
|
+
return () => stop.abort();
|
|
96
|
+
}, [org, enabled]);
|
|
97
|
+
return plan;
|
|
98
|
+
}
|
|
99
|
+
/** The person's picture, or their initials. */
|
|
100
|
+
export function Face({ host, size = 28 }) {
|
|
101
|
+
const p = host.person;
|
|
102
|
+
return (_jsx(XStack, { width: size, height: size, rounded: 9999, items: "center", justify: "center", bg: "$raised", overflow: "hidden", shrink: 0, borderWidth: 1, borderColor: "$borderColor", children: p?.avatar ? (_jsx("img", { src: p.avatar, alt: "", width: size, height: size })) : (_jsx(SizableText, { size: "$1", fontWeight: "600", color: "$ink", children: initials(p?.name || p?.email || '') })) }));
|
|
103
|
+
}
|
|
104
|
+
/** One row of the menu: a real button, so Enter, Space and focus are the browser's. */
|
|
105
|
+
function Item({ icon, label, sub, checked, onPress, trail, }) {
|
|
106
|
+
const radio = checked !== undefined;
|
|
107
|
+
return (_jsxs(XStack, { render: "button", ...{ role: radio ? 'menuitemradio' : 'menuitem' }, "aria-checked": radio ? checked : undefined, "data-item": "", onPress: onPress, items: "center", gap: "$2.5", px: "$2", minH: 36, py: "$1.5", rounded: "$4", bg: checked ? '$edge' : 'transparent', hoverStyle: { bg: '$hover' }, focusVisibleStyle: { bg: '$hover', outlineWidth: 2, outlineStyle: 'solid', outlineColor: '$outlineColor' }, cursor: "pointer", width: "100%", children: [icon ? (_jsx(XStack, { width: 20, justify: "center", shrink: 0, opacity: 0.8, children: icon })) : null, _jsxs(YStack, { flex: 1, minW: 0, children: [_jsx(SizableText, { size: "$2", color: "$ink", numberOfLines: 1, text: "left", children: label }), sub ? (_jsx(SizableText, { size: "$1", color: "$soft", numberOfLines: 1, text: "left", children: sub })) : null] }), trail] }));
|
|
108
|
+
}
|
|
109
|
+
const Rule = () => _jsx(XStack, { height: 1, bg: "$borderColor", my: "$1", mx: "$1" });
|
|
110
|
+
const Label = ({ children }) => (_jsx(SizableText, { size: "$1", color: "$soft", px: "$2", pt: "$1.5", pb: "$1", children: children }));
|
|
111
|
+
/** An organization's mark in the list: its initials on a rounded square. */
|
|
112
|
+
const Mark = ({ org }) => (_jsx(XStack, { width: 20, height: 20, rounded: "$2", items: "center", justify: "center", bg: "$raised", shrink: 0, children: org.personal ? (_jsx(SizableText, { size: "$1", fontWeight: "600", color: "$ink", children: initials(org.display) })) : (_jsx(Building2, { size: 12, "aria-hidden": true })) }));
|
|
113
|
+
/** Arrows, Home and End move between the menu's rows; Escape closes it. */
|
|
114
|
+
function rove(e, close) {
|
|
115
|
+
const rows = [...e.currentTarget.querySelectorAll('[data-item]')];
|
|
116
|
+
const at = rows.indexOf(document.activeElement);
|
|
117
|
+
const to = e.key === 'ArrowDown' ? (at + 1) % rows.length : e.key === 'ArrowUp' ? (at - 1 + rows.length) % rows.length : e.key === 'Home' ? 0 : e.key === 'End' ? rows.length - 1 : -2;
|
|
118
|
+
if (e.key === 'Escape') {
|
|
119
|
+
e.preventDefault();
|
|
120
|
+
close();
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (to === -2 || !rows.length)
|
|
124
|
+
return;
|
|
125
|
+
e.preventDefault();
|
|
126
|
+
rows[to]?.focus();
|
|
127
|
+
}
|
|
128
|
+
export function Me({ host, narrow = false, onOrg, }) {
|
|
129
|
+
const menu = useWho();
|
|
130
|
+
const [creating, setCreating] = useState(false);
|
|
131
|
+
const [supporting, setSupporting] = useState(false);
|
|
132
|
+
const row = useRef(null);
|
|
133
|
+
const content = useRef(null);
|
|
134
|
+
const current = host.org;
|
|
135
|
+
const all = useOrgs(current);
|
|
136
|
+
const here = all.find((o) => o.name === current) ?? null;
|
|
137
|
+
const personal = all.find((o) => o.personal) ?? null;
|
|
138
|
+
const { tier } = useTier(menu.open, current);
|
|
139
|
+
const other = usePlan(personal && personal.name !== current ? personal.name : null, menu.open);
|
|
140
|
+
const planFor = (o) => (o.name === current ? planOf(tier) : o.personal ? other : '');
|
|
141
|
+
const who = host.person?.name || host.person?.email || '';
|
|
142
|
+
const where = [said(here?.role ?? ''), here?.display ?? current].filter(Boolean).join(' · ');
|
|
143
|
+
// The organization in use takes focus once the menu is drawn — after the
|
|
144
|
+
// popover has placed its own focus — so the keys reach the rows at once.
|
|
145
|
+
const land = (node) => {
|
|
146
|
+
content.current = node;
|
|
147
|
+
if (!node)
|
|
148
|
+
return;
|
|
149
|
+
requestAnimationFrame(() => requestAnimationFrame(() => (node.querySelector('[data-item][aria-checked="true"]') ?? node.querySelector('[data-item]'))?.focus()));
|
|
150
|
+
};
|
|
151
|
+
const close = () => {
|
|
152
|
+
menu.onOpenChange(false);
|
|
153
|
+
row.current?.focus();
|
|
154
|
+
};
|
|
155
|
+
const go = (p) => {
|
|
156
|
+
menu.onOpenChange(false);
|
|
157
|
+
host.go(p);
|
|
158
|
+
};
|
|
159
|
+
if (!host.person)
|
|
160
|
+
return null;
|
|
161
|
+
return (_jsxs(_Fragment, { children: [_jsxs(Popover, { open: menu.open, onOpenChange: menu.onOpenChange, placement: narrow ? 'right-end' : 'top-start', allowFlip: true, stayInFrame: true, offset: 8, children: [_jsx(Popover.Anchor, { width: "100%", children: _jsxs(XStack, { ref: (el) => {
|
|
162
|
+
row.current = el instanceof HTMLElement ? el : null;
|
|
163
|
+
}, render: "button", "data-slot": "me", "aria-label": `Account: ${[who, current].filter(Boolean).join(' · ')}`, "aria-haspopup": "menu", "aria-expanded": menu.open, onPress: menu.toggle, width: narrow ? 40 : '100%', minH: narrow ? 40 : 48, px: narrow ? 0 : '$2', gap: "$2.5", rounded: narrow ? 999 : '$5', items: "center", justify: narrow ? 'center' : 'flex-start', hoverStyle: { bg: '$hover' }, focusVisibleStyle: { outlineWidth: 2, outlineStyle: 'solid', outlineColor: '$outlineColor' }, cursor: "pointer", children: [_jsx(Face, { host: host, size: narrow ? 30 : 32 }), narrow ? null : (_jsxs(_Fragment, { children: [_jsxs(YStack, { flex: 1, minW: 0, children: [_jsx(SizableText, { size: "$3", fontWeight: "600", color: "$ink", numberOfLines: 1, text: "left", children: who }), where ? (_jsx(SizableText, { size: "$1", color: "$soft", numberOfLines: 1, text: "left", children: where })) : null] }), _jsx(ChevronsUpDown, { size: 14, "aria-hidden": true, opacity: 0.6 })] }))] }) }), _jsx(Popover.Content, { ...glass(2), items: "stretch", borderWidth: 1, rounded: "$6", p: "$1.5", width: 288, maxW: "calc(100vw - 16px)", maxH: "calc(100dvh - 32px)", overflow: "scroll", children: _jsx(View, { render: _jsx("div", { ref: land, role: "menu", "aria-label": "Account", onKeyDown: (e) => rove(e, close) }), self: "stretch", minW: 0, children: _jsxs(YStack, { gap: 2, children: [_jsx(SizableText, { size: "$2", color: "$soft", px: "$2", py: "$1.5", numberOfLines: 1, children: host.person.email || who }), _jsx(Rule, {}), _jsx(Label, { children: "Organizations" }), _jsx(YStack, { role: "group", "aria-label": "Organizations", gap: 2, children: all.map((o) => (_jsx(Item, { icon: _jsx(Mark, { org: o }), label: o.display, sub: [o.personal ? 'Personal' : said(o.role), planFor(o)].filter(Boolean).join(' · '), checked: o.name === current, onPress: () => {
|
|
164
|
+
menu.onOpenChange(false);
|
|
165
|
+
if (o.name !== current)
|
|
166
|
+
onOrg(o.name);
|
|
167
|
+
} }, o.name))) }), _jsx(Item, { icon: _jsx(Plus, { size: 15, "aria-hidden": true }), label: "Create organization", onPress: () => (menu.onOpenChange(false), setCreating(true)) }), _jsx(Item, { icon: _jsx(Settings, { size: 15, "aria-hidden": true }), label: "Organization settings", onPress: () => (menu.onOpenChange(false), window.location.assign(ORG_SETTINGS)) }), here && !here.personal ? (_jsx(Item, { icon: _jsx(Users, { size: 15, "aria-hidden": true }), label: "Open in Hanzo Team", trail: _jsx(ExternalLink, { size: 13, "aria-hidden": true, opacity: 0.6 }), onPress: () => (menu.onOpenChange(false), window.location.assign(`${ENTRY}&org=${encodeURIComponent(here.name)}`)) })) : (_jsx(Item, { icon: _jsx(Users, { size: 15, "aria-hidden": true }), label: "Create a team", onPress: () => (menu.onOpenChange(false), setCreating(true)) })), superAdmin() ? (_jsx(Item, { icon: _jsx(Building2, { size: 15, "aria-hidden": true }), label: "All organizations", onPress: () => (menu.onOpenChange(false), setSupporting(true)) })) : null, _jsx(Rule, {}), _jsx(Item, { icon: _jsx(Sparkles, { size: 15, "aria-hidden": true }), label: "Add funds", sub: typeof tier?.balance?.effectiveAvailable === 'number' ? `Credits ${formatCents(tier.balance.effectiveAvailable)}` : 'Credits', onPress: () => (menu.onOpenChange(false), window.open(payPage(), '_blank', 'noopener')), trail: _jsx(ExternalLink, { size: 13, "aria-hidden": true, opacity: 0.6 }) }), _jsx(Item, { icon: _jsx(Settings, { size: 15, "aria-hidden": true }), label: "Settings", onPress: () => go(path({ kind: 'settings', section: 'general' })) }), _jsx(Item, { icon: _jsx(Gauge, { size: 15, "aria-hidden": true }), label: "Usage", onPress: () => go(path({ kind: 'settings', section: 'usage' })) }), _jsx(Item, { icon: _jsx(Sparkles, { size: 15, "aria-hidden": true }), label: "View all plans", onPress: () => go(path({ kind: 'screen', screen: 'plans' })) }), _jsx(Item, { icon: _jsx(LifeBuoy, { size: 15, "aria-hidden": true }), label: "Get help", onPress: () => (menu.onOpenChange(false), window.open(DOCS, '_blank', 'noopener,noreferrer')) }), host.signOut ? (_jsxs(_Fragment, { children: [_jsx(Rule, {}), _jsx(Item, { icon: _jsx(LogOut, { size: 15, "aria-hidden": true }), label: "Log out", onPress: () => (menu.onOpenChange(false), host.signOut?.()) })] })) : null] }) }) })] }), _jsx(Create, { open: creating, onOpenChange: setCreating }), supporting ? _jsx(SupportPicker, { onClose: () => setSupporting(false) }) : null] }));
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* The sheet that creates an organization: a name, then `POST /v1/account/orgs`,
|
|
171
|
+
* the creator's own. The token in hand predates it, so it is renewed and the app
|
|
172
|
+
* reloads in the new organization; `Made` says so if the new token does not carry
|
|
173
|
+
* it yet.
|
|
174
|
+
*/
|
|
175
|
+
function Create({ open, onOpenChange }) {
|
|
176
|
+
const [name, setName] = useState('');
|
|
177
|
+
const [busy, setBusy] = useState(false);
|
|
178
|
+
const [refused, setRefused] = useState('');
|
|
179
|
+
useEffect(() => {
|
|
180
|
+
if (open) {
|
|
181
|
+
setName('');
|
|
182
|
+
setRefused('');
|
|
183
|
+
}
|
|
184
|
+
}, [open]);
|
|
185
|
+
const submit = async () => {
|
|
186
|
+
const words = name.trim();
|
|
187
|
+
if (!words)
|
|
188
|
+
return setRefused('Give it a name.');
|
|
189
|
+
if (busy)
|
|
190
|
+
return;
|
|
191
|
+
setBusy(true);
|
|
192
|
+
setRefused('');
|
|
193
|
+
try {
|
|
194
|
+
const org = await createOrg(words);
|
|
195
|
+
try {
|
|
196
|
+
sessionStorage.setItem(MADE, JSON.stringify({ org, name: words, from: current() }));
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
/* the next load simply says nothing */
|
|
200
|
+
}
|
|
201
|
+
pick(org);
|
|
202
|
+
renew();
|
|
203
|
+
window.location.assign('/');
|
|
204
|
+
}
|
|
205
|
+
catch (e) {
|
|
206
|
+
setRefused(say(e));
|
|
207
|
+
setBusy(false);
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { maxW: 440, rounded: "$6", gap: "$3", children: [_jsx(DialogTitle, { children: "Create organization" }), _jsx(SizableText, { size: "$2", color: "$soft", children: "You own it. Agents, credits and members belong to it, and you switch to it from this menu." }), _jsx(Input, { autoFocus: true, value: name, onChangeText: setName, "aria-label": "Organization name", placeholder: "Acme", maxLength: 80, onKeyDown: (e) => {
|
|
211
|
+
if (e.key === 'Enter')
|
|
212
|
+
void submit();
|
|
213
|
+
} }), refused ? (_jsx(SizableText, { size: "$1", color: BAD, role: "alert", children: refused })) : null, _jsxs(XStack, { gap: "$2", justify: "flex-end", children: [_jsx(Button, { size: "sm", variant: "outline", rounded: 999, onPress: () => onOpenChange(false), children: "Cancel" }), _jsx(Button, { size: "sm", rounded: 999, disabled: busy || !name.trim(), onPress: () => void submit(), children: busy ? 'Creating…' : 'Create' })] })] }) }));
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* What became of an organization this tab created: nothing to say once the token
|
|
217
|
+
* carries it (the app is already in it), and a plain line while it does not.
|
|
218
|
+
*/
|
|
219
|
+
export function Made() {
|
|
220
|
+
const [made, setMade] = useState(null);
|
|
221
|
+
useEffect(() => {
|
|
222
|
+
try {
|
|
223
|
+
const kept = JSON.parse(sessionStorage.getItem(MADE) || 'null');
|
|
224
|
+
if (!kept?.org)
|
|
225
|
+
return;
|
|
226
|
+
if (orgs().includes(kept.org))
|
|
227
|
+
return sessionStorage.removeItem(MADE);
|
|
228
|
+
// Still out of the token: go on working where the person was.
|
|
229
|
+
if (kept.from && current() !== kept.from && orgs().includes(kept.from)) {
|
|
230
|
+
pick(kept.from);
|
|
231
|
+
return window.location.reload();
|
|
232
|
+
}
|
|
233
|
+
setMade({ org: kept.org, name: kept.name || kept.org });
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
/* nothing kept */
|
|
237
|
+
}
|
|
238
|
+
}, []);
|
|
239
|
+
if (!made)
|
|
240
|
+
return null;
|
|
241
|
+
const dismiss = () => {
|
|
242
|
+
try {
|
|
243
|
+
sessionStorage.removeItem(MADE);
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
/* gone with the tab */
|
|
247
|
+
}
|
|
248
|
+
setMade(null);
|
|
249
|
+
};
|
|
250
|
+
return (_jsxs(XStack, { "data-slot": "made", role: "status", items: "flex-start", gap: "$2", px: "$3", py: "$2.5", rounded: "$5", borderWidth: 1, borderColor: "$borderColor", bg: "$edge", children: [_jsx(SizableText, { flex: 1, minW: 0, size: "$1", color: "$ink", children: `${made.name} was created, and your sign-in does not include it yet, so you are still in this organization. It appears here once IAM adds you to it.` }), _jsx(XStack, { render: "button", "aria-label": "Dismiss", onPress: dismiss, p: "$1", rounded: 999, hoverStyle: { bg: '$hover' }, children: _jsx(X, { size: 13, "aria-hidden": true }) })] }));
|
|
251
|
+
}
|