@hanzo/rooms 0.1.83 → 0.1.85

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/Orgs.js CHANGED
@@ -28,11 +28,13 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
28
28
  // Free is offered a team, and an open one is sent on (`forward`). Behind the
29
29
  // door, and only there, nothing opens without a live plan.
30
30
  import { dev } from './lib/host.js';
31
- import { useCallback, useEffect, useMemo, useState } from 'react';
31
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
32
32
  import { useIam } from '@hanzo/iam/react';
33
33
  import { Text, View, XStack, YStack } from '@hanzo/gui';
34
- import { Button } from '@hanzo/ui';
35
- import { Minus, Plus, X } from 'lucide-react';
34
+ import { Avatar, Button, Tag } from '@hanzo/ui';
35
+ import { SearchInput } from '@hanzo/ui/product/Filters';
36
+ import { label, said as spoken } from '@hanzo/build/plan';
37
+ import { Check, Minus, Plus, X } from 'lucide-react';
36
38
  import { Waiting } from './waiting.js';
37
39
  import { LOGIN, enter } from './lib/destination.js';
38
40
  import { useAi } from './lib/ai.js';
@@ -40,7 +42,8 @@ import { useHost } from './lib/hostname.js';
40
42
  import { useHydrated } from './lib/hydrated.js';
41
43
  import { api } from './lib/api.js';
42
44
  import { usePersonal, useTier } from './lib/tier.js';
43
- import { ORG, PICKED, assumed, hasSession, org, orgs as claimed, pick, renew, scope } from './lib/session.js';
45
+ import { CHOOSE, ORG, PICKED, assumed, bearer, hasSession, org, orgs as claimed, pick, renew, role, scope, superAdmin, support } from './lib/session.js';
46
+ import { matches, refusal, slugify } from './lib/orgname.js';
44
47
  import { team as workspace } from './lib/host.js';
45
48
  import { checkoutUrl, money } from './lib/pay.js';
46
49
  import { cartUrl, charge, priced, usePlan, usePlans } from './lib/plans.js';
@@ -119,9 +122,17 @@ export function Orgs({ children, front = false, forward, signup = false, }) {
119
122
  window.removeEventListener('focus', back);
120
123
  };
121
124
  }, [reload]);
125
+ // The chooser over the room, asked for from the account menu (session.ts `choose`).
126
+ const [choosing, setChoosing] = useState(false);
127
+ useEffect(() => {
128
+ const ask = () => setChoosing(true);
129
+ window.addEventListener(CHOOSE, ask);
130
+ return () => window.removeEventListener(CHOOSE, ask);
131
+ }, []);
122
132
  const choose = useCallback((o) => {
123
133
  pick(o);
124
134
  setPicked(true);
135
+ setChoosing(false);
125
136
  }, []);
126
137
  // The organization a link arrives for. hanzo.ai opens the workspace at
127
138
  // `?signin&org=<the org it is working in>` (Me.tsx); once signed in, an org
@@ -198,7 +209,10 @@ export function Orgs({ children, front = false, forward, signup = false, }) {
198
209
  return door ? _jsx(Start, { onFounded: setFounding, first: true }) : _jsx(Create, {});
199
210
  }
200
211
  if (!current || (members.length > 1 && !picked))
201
- return _jsx(Pick, { members: members, onPick: choose });
212
+ return _jsx(Pick, { members: members, current: current, onPick: choose });
213
+ // Asked for over an open room, the room keeps its place under the chooser and closing it goes back.
214
+ if (choosing)
215
+ return _jsxs(_Fragment, { children: [_jsx(Held, { veiled: true, children: children }), _jsx(Pick, { members: members, current: current, onPick: choose, onClose: () => setChoosing(false) })] });
202
216
  if (open)
203
217
  return forward ? _jsx(Forward, { to: forward }) : _jsx(_Fragment, { children: _jsx(Held, { veiled: false, children: children }) });
204
218
  // IN THE WORKSPACE NOTHING OPENS WITHOUT A LIVE PLAN. Every branch here
@@ -458,9 +472,253 @@ function CreateDialog({ onClose }) {
458
472
  ? `A team organization runs on ${plan.name} at ${price} per seat a month, at least ${Number(plan.limits?.minSeats ?? 1)} seats.`
459
473
  : `A personal organization runs on ${plan.name} at ${price} a month.`, ' ', "Nothing in it works until that plan is live, so creating one takes you straight to checkout, where you choose the seats and the term.", ' ', "Each plan includes session and daily usage limits; usage never draws your credit balance.", ' ', "Payment is on the provider's hosted page."] })) : null, refused ? (_jsx(Text, { render: "p", role: "alert", mt: 14, fontSize: "$3", color: "var(--foreground)", children: refused })) : null, _jsxs(XStack, { justify: "flex-end", gap: 10, mt: 24, flexWrap: "wrap", children: [_jsx(Button, { type: "button", size: "lg", onClick: onClose, children: "Cancel" }), _jsx(Button, { type: "submit", variant: "primary", size: "lg", shrink: 1, py: "$2", disabled: !plan || busy, children: _jsx(Text, { fontFamily: "$body", fontSize: "$3", color: "inherit", text: "center", children: busy ? 'Creating…' : paid ? `Create and subscribe — ${price}${seat} / month` : 'Create' }) })] }), _jsx(Rule, { mt: 18, mb: 8 }), _jsx(Text, { render: "p", text: "right", fontSize: "$2", color: muted, fontFamily: "$mono", children: "Esc to close" })] }) }));
460
474
  }
461
- /** Several organizations: which one, this time. */
462
- function Pick({ members, onPick }) {
463
- return (_jsx(Page, { label: "Choose an organization", children: _jsxs(Column, { children: [_jsx(Brand, {}), _jsx(Heading, { title: "Choose an organization", lede: _jsx(_Fragment, { children: "Everything you open \u2014 chats, agents, credit \u2014 is this one's." }) }), _jsx(Rule, {}), _jsx(View, { display: "grid", gap: 10, children: members.map((o) => (_jsx(View, { render: "button", onClick: () => onPick(o), items: "flex-start", px: 16, py: 14, rounded: 12, borderWidth: 1, borderColor: "var(--border)", bg: "transparent", cursor: "pointer", children: _jsx(Text, { fontSize: "$4", color: "var(--foreground)", text: "left", children: o }) }, o))) })] }) }));
475
+ /**
476
+ * A page of IAM's organization list (`GET /v1/iam/organizations`): the reader's
477
+ * own organizations whole on the first page, then, for a SuperAdmin only, every
478
+ * other one, newest first. `q` narrows it by name or display name on the server;
479
+ * `cursor` continues it. IAM decides the scope from the bearer, so this asks the
480
+ * same thing for everyone.
481
+ */
482
+ async function listing(q, cursor, limit, signal) {
483
+ const query = new URLSearchParams({ limit: String(limit) });
484
+ if (q.trim())
485
+ query.set('q', q.trim());
486
+ if (cursor)
487
+ query.set('cursor', cursor);
488
+ const res = await fetch(`${api()}/v1/iam/organizations?${query}`, {
489
+ headers: { Authorization: `Bearer ${bearer() ?? ''}` },
490
+ signal,
491
+ });
492
+ if (!res.ok)
493
+ throw new Error(`IAM answered ${res.status}.`);
494
+ return (await res.json());
495
+ }
496
+ /** How each of the reader's organizations appears, by name, from IAM's rows: the first page carries all of them. */
497
+ function useMarks(members) {
498
+ const [rows, setRows] = useState({});
499
+ const key = members.join(',');
500
+ useEffect(() => {
501
+ if (!key)
502
+ return;
503
+ const stop = new AbortController();
504
+ listing('', '', 1, stop.signal)
505
+ .then((page) => {
506
+ if (!stop.signal.aborted)
507
+ setRows(Object.fromEntries((page.organizations ?? []).map((o) => [o.name, o])));
508
+ })
509
+ .catch(() => { });
510
+ return () => stop.abort();
511
+ }, [key]);
512
+ return rows;
513
+ }
514
+ /** Each organization's plan by its family (`Max 20x`), billing asked in its name; absent where it names none. */
515
+ function useBilled(members) {
516
+ const [plans, setPlans] = useState({});
517
+ const key = members.join(',');
518
+ useEffect(() => {
519
+ const stop = new AbortController();
520
+ for (const o of key.split(',').filter(Boolean)) {
521
+ fetch(`${api()}/v1/billing/tier`, { headers: { ...scope(), 'X-Org-Id': o }, signal: stop.signal })
522
+ .then((r) => (r.ok ? r.json() : null))
523
+ .then((t) => {
524
+ const plan = t ? spoken(label(t.plan, t.tier?.name)) : '';
525
+ if (plan && !stop.signal.aborted)
526
+ setPlans((had) => ({ ...had, [o]: plan }));
527
+ })
528
+ .catch(() => { });
529
+ }
530
+ return () => stop.abort();
531
+ }, [key]);
532
+ return plans;
533
+ }
534
+ /** A role as a person reads it: `owner` → Owner. */
535
+ const said = (role) => (role ? role.charAt(0).toUpperCase() + role.slice(1) : '');
536
+ /** An organization's mark: its image, else its emoji, else its initial in its own hue (keyed on the slug, so a rename keeps it). */
537
+ function Mark({ org }) {
538
+ const emoji = org.mark?.avatar ? '' : org.mark?.emoji;
539
+ return (_jsx(Avatar, { size: "xl", shape: "rounded", src: org.mark?.avatar || (emoji ? null : org.mark?.logo) || null, name: org.display, colorSeed: org.name, icon: emoji ? _jsx(Text, { fontSize: "$7", children: emoji }) : undefined, alt: "" }));
540
+ }
541
+ /** One organization: its mark, its name and slug, the reader's role and its plan; the current one checked. */
542
+ function Choice({ org, current = false, note, disabled, onPress }) {
543
+ return (_jsx(View, { render: "li", display: "block", children: _jsxs(XStack, { render: "button", onClick: onPress, disabled: disabled, "aria-current": current || undefined, width: "100%", items: "center", gap: 12, px: 12, py: 10, rounded: 12, borderWidth: 1, borderColor: current ? '$ink' : '$edge', bg: "transparent", cursor: "pointer", hoverStyle: { bg: '$hover' }, children: [_jsx(Mark, { org: org }), _jsxs(YStack, { flex: 1, minW: 0, gap: 4, children: [_jsx(Text, { fontSize: "$4", fontWeight: "500", color: "$ink", numberOfLines: 1, text: "left", children: org.display }), _jsxs(XStack, { items: "center", gap: 6, flexWrap: "wrap", children: [_jsx(Text, { fontSize: "$2", fontFamily: "$mono", color: "$soft", numberOfLines: 1, children: org.name }), org.role ? _jsx(Tag, { variant: "outline", children: said(org.role) }) : null, org.plan ? _jsx(Tag, { children: org.plan }) : null, note ? (_jsx(Text, { fontSize: "$2", color: "$soft", children: note })) : null] })] }), current ? _jsx(Check, { size: 16, "aria-label": "Current" }) : null] }) }));
544
+ }
545
+ /**
546
+ * WHICH ORGANIZATION, THIS TIME — and, for a SuperAdmin, any organization.
547
+ *
548
+ * The reader's organizations are the token's `orgs` (no other list is theirs),
549
+ * drawn with IAM's rows beside them: the mark, the display name, the role the
550
+ * token signs and the plan billing reads in each one's name. The search filters
551
+ * them as it is typed, by slug or display name. A person founds another here
552
+ * (`Found`); a SuperAdmin, whom IAM never lets own a customer organization, is
553
+ * shown every organization instead (`Everyone`), stepped into through IAM.
554
+ */
555
+ function Pick({ members, current, onPick, onClose, }) {
556
+ // IAM's verdict as it signed it into the token (authz `Claims.Sudo`). It only
557
+ // decides what is drawn: IAM answers anyone else's list with their own
558
+ // organizations and refuses them the step in.
559
+ const sudo = superAdmin();
560
+ const [q, setQ] = useState('');
561
+ const [founding, setFounding] = useState(false);
562
+ const marks = useMarks(members);
563
+ const plans = useBilled(members);
564
+ const mine = members.map((name) => ({
565
+ name,
566
+ display: marks[name]?.displayName || name,
567
+ mark: marks[name],
568
+ role: role(name) ?? '',
569
+ plan: plans[name],
570
+ }));
571
+ const shown = mine.filter((o) => matches(o, q));
572
+ useEffect(() => {
573
+ if (!onClose || founding)
574
+ return;
575
+ const key = (e) => {
576
+ if (e.key === 'Escape')
577
+ onClose();
578
+ };
579
+ document.addEventListener('keydown', key);
580
+ return () => document.removeEventListener('keydown', key);
581
+ }, [onClose, founding]);
582
+ return (_jsxs(Page, { label: "Choose an organization", children: [_jsxs(Column, { children: [_jsxs(XStack, { items: "flex-start", justify: "space-between", gap: 12, children: [_jsx(Brand, {}), onClose ? (_jsx(Button, { variant: "ghost", size: "icon-sm", mt: -6, mr: -8, onClick: onClose, "aria-label": "Close", children: _jsx(X, { size: 16, "aria-hidden": true }) })) : null] }), _jsx(Heading, { title: "Choose an organization", lede: _jsx(_Fragment, { children: "Everything you open \u2014 chats, agents, credit \u2014 is this one's." }) }), _jsx(Rule, {}), _jsx(XStack, { render: "form", onSubmit: (e) => {
583
+ e.preventDefault();
584
+ if (shown[0])
585
+ onPick(shown[0].name);
586
+ }, children: _jsx(SearchInput, { name: "organizations", value: q, onChange: setQ, placeholder: sudo ? 'Search every organization' : 'Find an organization', actions: sudo ? null : (_jsxs(Button, { type: "button", onClick: () => setFounding(true), children: [_jsx(Plus, { size: 16, "aria-hidden": true }), "New organization"] })) }) }), _jsx(YStack, { render: "ul", "aria-label": "Your organizations", m: 0, p: 0, mt: 14, gap: 8, children: shown.map((o) => (_jsx(Choice, { org: o, current: o.name === current, onPress: () => onPick(o.name) }, o.name))) }), shown.length === 0 ? (_jsxs(Text, { render: "p", mt: 14, fontSize: "$3", color: "$soft", children: ["None of yours matches \u201C", q.trim(), "\u201D."] })) : null, sudo ? _jsx(Everyone, { q: q, skip: members }) : null] }), founding ? _jsx(Found, { onClose: () => setFounding(false), onMade: onPick }) : null] }));
587
+ }
588
+ /**
589
+ * EVERY ORGANIZATION, for a SuperAdmin to step into.
590
+ *
591
+ * The list and the search are IAM's, a page at a time, and IAM records each new
592
+ * search under the operator's name. Opening one is IAM's `assume` (session.ts
593
+ * `support`): the operator's own token re-scoped to it and the step recorded in
594
+ * that organization's audit log. Nobody is made a member of anything.
595
+ */
596
+ function Everyone({ q, skip }) {
597
+ const [rows, setRows] = useState([]);
598
+ const [cursor, setCursor] = useState('');
599
+ const [loading, setLoading] = useState(false);
600
+ const [refused, setRefused] = useState(null);
601
+ const [stepping, setStepping] = useState(null);
602
+ // Only the newest read lands: a search typed while a page is in flight
603
+ // supersedes it, so a slow answer to an old query never overwrites the new one.
604
+ const asked = useRef(0);
605
+ const load = useCallback(async (query, from) => {
606
+ const mine = ++asked.current;
607
+ setLoading(true);
608
+ setRefused(null);
609
+ try {
610
+ const next = await listing(query, from, 20);
611
+ if (mine !== asked.current)
612
+ return;
613
+ setRows((had) => {
614
+ // IAM's cursor is a position, so an org created mid-walk can shift a row
615
+ // across a page edge; each org is kept once.
616
+ const all = from ? [...had, ...(next.organizations ?? [])] : next.organizations ?? [];
617
+ return all.filter((o, i) => all.findIndex((x) => x.name === o.name) === i);
618
+ });
619
+ setCursor(next.cursor ?? '');
620
+ }
621
+ catch (e) {
622
+ if (mine === asked.current)
623
+ setRefused(e instanceof Error ? e.message : 'The list did not load.');
624
+ }
625
+ finally {
626
+ if (mine === asked.current)
627
+ setLoading(false);
628
+ }
629
+ }, []);
630
+ useEffect(() => {
631
+ const id = setTimeout(() => void load(q, ''), 250);
632
+ return () => clearTimeout(id);
633
+ }, [q, load]);
634
+ const enter = (org) => {
635
+ setStepping(org);
636
+ setRefused(null);
637
+ support(org).catch((e) => {
638
+ setRefused(e instanceof Error ? e.message : 'IAM did not step in.');
639
+ setStepping(null);
640
+ });
641
+ };
642
+ const others = rows.filter((o) => !skip.includes(o.name));
643
+ return (_jsxs(YStack, { render: "section", "aria-label": "All organizations", mt: 36, children: [_jsx(Text, { render: "h2", fontSize: "$6", fontWeight: "500", color: "$ink", children: "All organizations" }), _jsx(Text, { render: "p", mt: 4, fontSize: "$3", color: "$soft", lineHeight: 21, children: "Support mode. Step into any organization to see it as it does; IAM records every step under your name." }), _jsx(YStack, { render: "ul", "aria-label": "Every organization", m: 0, p: 0, mt: 14, gap: 8, children: others.map((o) => (_jsx(Choice, { org: { name: o.name, display: o.displayName || o.name, mark: o }, note: stepping === o.name ? 'Stepping in…' : undefined, disabled: stepping !== null, onPress: () => enter(o.name) }, o.name))) }), !loading && others.length === 0 && !refused ? (_jsx(Text, { render: "p", mt: 8, fontSize: "$3", color: "$soft", children: "No organization matches." })) : null, refused ? (_jsx(Text, { render: "p", role: "alert", mt: 8, fontSize: "$3", color: BAD, children: refused })) : null, cursor ? (_jsx(Button, { self: "flex-start", mt: 10, onClick: () => void load(q, cursor), disabled: loading, children: loading ? 'Loading…' : 'More' })) : null] }));
644
+ }
645
+ /**
646
+ * FOUND AN ORGANIZATION: its name, then the slug IAM keys it by.
647
+ *
648
+ * The slug follows the name the way IAM's onboarding makes it, until it is typed
649
+ * over; a slug typed is IAM's to take as it is, so one IAM would not keep is
650
+ * refused here with the rule, never rewritten. IAM's own onboarding makes it
651
+ * (`POST /v1/iam/onboard`): the organization, the reader as its owner and its
652
+ * first API key, the founder's account untouched. It is sent the name when the
653
+ * name makes the slug, and the slug when the slug was typed, the display name
654
+ * then written on the row (`/v1/iam/organizations/profile`). The token in hand
655
+ * predates it, so it is refreshed before the organization opens.
656
+ */
657
+ function Found({ onClose, onMade }) {
658
+ const { sdk } = useIam();
659
+ const [name, setName] = useState('');
660
+ const [typed, setTyped] = useState(null);
661
+ const slug = typed ?? slugify(name);
662
+ const wrong = slug ? refusal(slug) : null;
663
+ const [busy, setBusy] = useState(false);
664
+ const [refused, setRefused] = useState(null);
665
+ useEffect(() => {
666
+ const key = (e) => {
667
+ if (e.key === 'Escape')
668
+ onClose();
669
+ };
670
+ document.addEventListener('keydown', key);
671
+ return () => document.removeEventListener('keydown', key);
672
+ }, [onClose]);
673
+ const submit = async (e) => {
674
+ e.preventDefault();
675
+ const display = name.trim();
676
+ if (busy)
677
+ return;
678
+ if (!display)
679
+ return setRefused('Give it a name.');
680
+ if (wrong)
681
+ return setRefused(wrong);
682
+ setBusy(true);
683
+ setRefused(null);
684
+ const headers = { Authorization: `Bearer ${bearer() ?? ''}`, 'Content-Type': 'application/json' };
685
+ const why = async (r) => {
686
+ const body = (await r.json().catch(() => null));
687
+ return body?.error || body?.detail || body?.msg || body?.message || `IAM answered ${r.status}.`;
688
+ };
689
+ try {
690
+ const named = slugify(display) === slug;
691
+ const r = await fetch(`${api()}/v1/iam/onboard`, { method: 'POST', headers, body: JSON.stringify({ name: named ? display : slug }) });
692
+ if (!r.ok)
693
+ throw new Error(await why(r));
694
+ const made = (await r.json().catch(() => null))?.org;
695
+ if (!made)
696
+ throw new Error('IAM made no organization. Try again.');
697
+ renew();
698
+ await sdk.refreshAccessToken().catch(() => null);
699
+ if (!named) {
700
+ const p = await fetch(`${api()}/v1/iam/organizations/profile`, {
701
+ method: 'POST',
702
+ headers: { ...headers, Authorization: `Bearer ${bearer() ?? ''}` },
703
+ body: JSON.stringify({ owner: 'admin', name: made, displayName: display }),
704
+ });
705
+ if (!p.ok)
706
+ throw new Error(`${made} is made, and it is still called ${made}: ${await why(p)}`);
707
+ }
708
+ // A token that does not carry it yet is minted on the next load, which opens it.
709
+ if (!claimed().includes(made)) {
710
+ pick(made);
711
+ window.location.reload();
712
+ return;
713
+ }
714
+ onMade(made);
715
+ }
716
+ catch (err) {
717
+ setRefused(err instanceof Error ? err.message : 'Could not create it.');
718
+ setBusy(false);
719
+ }
720
+ };
721
+ return (_jsx(YStack, { role: "dialog", "aria-modal": true, "aria-label": "New organization", position: "fixed", inset: 0, z: DIALOG, bg: shade(28), overflowY: "auto", py: "6vh", px: 16, onClick: onClose, children: _jsxs(YStack, { render: "form", onSubmit: submit, onClick: (e) => e.stopPropagation(), width: "100%", maxW: 480, mx: "auto", bg: "var(--background)", rounded: 16, borderWidth: 1, borderColor: "$edge", boxShadow: `0 24px 64px ${shade(28)}`, pt: 24, px: 24, pb: 20, children: [_jsxs(XStack, { items: "flex-start", justify: "space-between", gap: 12, children: [_jsxs(YStack, { shrink: 1, minW: "auto", children: [_jsx(Text, { render: "h2", fontSize: "$8", fontWeight: "500", color: "$ink", children: "New organization" }), _jsx(Text, { render: "p", mt: 6, fontSize: "$3", color: "$soft", lineHeight: 21, children: "You own it. Its agents, credit and members are its own." })] }), _jsx(Button, { type: "button", variant: "ghost", size: "icon-sm", mr: -8, onClick: onClose, "aria-label": "Close", children: _jsx(X, { size: 16, "aria-hidden": true }) })] }), _jsxs(View, { render: "label", display: "block", mt: 20, children: [_jsx(Text, { display: "block", fontSize: "$4", fontWeight: "500", mb: 8, color: "$ink", children: "Name" }), _jsx(Text, { render: _jsx("input", { value: name, onChange: (e) => setName(e.target.value), placeholder: "Acme", maxLength: 100, autoFocus: true }), ...FIELD })] }), _jsxs(View, { render: "label", display: "block", mt: 16, children: [_jsx(Text, { display: "block", fontSize: "$4", fontWeight: "500", mb: 8, color: "$ink", children: "Slug" }), _jsx(Text, { render: _jsx("input", { value: slug, onChange: (e) => setTyped(e.target.value), placeholder: "acme", spellCheck: false, autoCapitalize: "off", autoCorrect: "off", "aria-invalid": Boolean(slug && wrong) || undefined }), ...FIELD, fontFamily: "$mono" }), _jsx(Text, { display: "block", fontSize: "$3", color: slug && wrong ? BAD : '$soft', mt: 8, children: slug && wrong ? wrong : 'How every request names it: lowercase letters, digits and hyphens. It does not change.' })] }), refused ? (_jsx(Text, { render: "p", role: "alert", mt: 14, fontSize: "$3", color: BAD, children: refused })) : null, _jsxs(XStack, { justify: "flex-end", gap: 10, mt: 22, flexWrap: "wrap", children: [_jsx(Button, { type: "button", onClick: onClose, children: "Cancel" }), _jsx(Button, { type: "submit", variant: "primary", disabled: busy || !name.trim() || Boolean(wrong), children: busy ? 'Creating…' : 'Create and open' })] })] }) }));
464
722
  }
465
723
  /** On the workspace's host, an organization whose plan is not live: it is offered the per-seat Team plan. */
466
724
  function Subscribe({ org, members, onSwitch, }) {
package/dist/Rail.d.ts CHANGED
@@ -3,6 +3,19 @@ import { type Host } from '@hanzo/build';
3
3
  import { type Place } from './lib/host.js';
4
4
  /** The rail's width, px. */
5
5
  export declare const RAIL = 48;
6
+ /** The places the rail holds beside the products, by the builder address each opens. */
7
+ export declare const RAISED: {
8
+ readonly library: '-/artifacts';
9
+ readonly scheduled: '-/automations';
10
+ readonly customize: '-/customize';
11
+ };
12
+ export type Raised = keyof typeof RAISED;
13
+ /** A place that takes the sidebar: one of the rail's, or Settings (the account menu's). */
14
+ export type Spot = Raised | 'settings';
15
+ /** The place a builder address stands on, if it is one that takes the sidebar. */
16
+ export declare const spot: (at: string | null) => Spot | null;
17
+ /** Which of them a builder address stands on, if any: an agent's page is Customize's, an automation Scheduled tasks'. */
18
+ export declare function raised(at: string | null): Raised | null;
6
19
  /** One mark: an icon button named for where it goes, its name its tooltip, lit where the reader is. */
7
20
  export declare function Mark({ label, on, onPress, children, ...rest }: {
8
21
  label: string;
@@ -18,9 +31,11 @@ export declare function Toggle({ open, onPress, label }: {
18
31
  onPress: () => void;
19
32
  label?: string;
20
33
  }): import("react").JSX.Element;
21
- export declare function Rail({ host, mode, open, toggle, flat, onChat, onDev, onStudio, onOrg, }: {
34
+ export declare function Rail({ host, mode, at, open, toggle, flat, onChat, onDev, onStudio, go, onOrg, }: {
22
35
  host: Host;
23
36
  mode: Place['mode'];
37
+ /** Dev's builder address, or null outside Dev. */
38
+ at: string | null;
24
39
  /** The sidebar beside the rail is open. */
25
40
  open: boolean;
26
41
  /** Lead with the sidebar's toggle; absent where the host's bar holds it. */
@@ -31,6 +46,8 @@ export declare function Rail({ host, mode, open, toggle, flat, onChat, onDev, on
31
46
  onChat: () => void;
32
47
  onDev: () => void;
33
48
  onStudio: () => void;
49
+ /** Moves the app to an address of this page. */
50
+ go: (address: string) => void;
34
51
  /** Work in another organization. */
35
52
  onOrg: (org: string) => void;
36
53
  }): import("react").JSX.Element;
package/dist/Rail.js CHANGED
@@ -1,19 +1,20 @@
1
1
  'use client';
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  // The app's rail: its products as marks down the window's left edge, in one
4
- // order — Chat, Dev, Studio — then More, a menu of the products on hosts of
5
- // their own, and the account at the foot. It is how the app moves between its
6
- // products; a phone, which has no rail, switches Chat and Dev in its bar
7
- // (Top.tsx).
4
+ // order — Chat, Dev, Studio, Library, Scheduled tasks, Customize — then More, a
5
+ // menu of the products on hosts of their own, and the account at the foot. It
6
+ // is how the app moves between its products; a phone, which has no rail,
7
+ // switches Chat and Dev in its bar (Top.tsx).
8
8
  //
9
9
  // Studio is the app's third mode (@hanzo/studio at /studio), each of the three
10
10
  // opened where the reader left it.
11
11
  //
12
- // A mark is a product, and a product owns the sidebar beside the rail: Chat its
13
- // conversations, Dev its places (@hanzo/build's New, Projects, Issues, Library,
14
- // Templates, Scheduled tasks, Machines, Environments, Customize, Docs) and its
15
- // runs, Studio its own. Nothing of Dev's is raised to the rail, and nothing of
16
- // the rail's is listed inside Dev: one level of the app a place.
12
+ // A mark is a place, and a place owns the sidebar beside the rail: Chat its
13
+ // conversations, Dev its own places (@hanzo/build `own`: New run, Projects,
14
+ // Issues, Templates) and its runs, Studio its own; Library, Scheduled tasks and
15
+ // Customize (@hanzo/build's Artifacts, Automations and Customize) each their
16
+ // own, and Settings its sections (Column.tsx). Nothing on the rail is listed
17
+ // inside Dev: one level of the app a place, each lit while the reader is on it.
17
18
  //
18
19
  // It stands on the frame's floor, never cut as a pane (ground.ts): the rail and
19
20
  // the sidebar beside it are the window, and the content is the panel inset in
@@ -22,17 +23,37 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
22
23
  // The sidebar's toggle leads it (`toggle`) where the host has no bar of its own:
23
24
  // a browser tab. A host that is a window draws the toggle in its bar beside Back
24
25
  // and Forward (App.tsx `bar`), and its rail starts with Chat. On a phone the
25
- // rail lies along the foot of the drawer (`flat`), the account at its end.
26
- import { Code2, Ellipsis, MessageSquare, PanelLeft, SquareTerminal, Users, WandSparkles } from 'lucide-react';
26
+ // rail lies along the foot of the drawer (`flat`), the account at its end; the
27
+ // drawer is too narrow for every mark, so Library, Scheduled tasks and
28
+ // Customize lead More there.
29
+ import { Clock, Code2, Ellipsis, Library, MessageSquare, PanelLeft, Puzzle, SquareTerminal, Users, WandSparkles } from 'lucide-react';
27
30
  import { XStack, YStack } from '@hanzo/gui';
31
+ import { route } from '@hanzo/build';
28
32
  import { DropdownMenu } from '@hanzo/ui';
29
33
  import { out } from './help.js';
30
34
  import { Me } from './Me.js';
31
- import { TABS, WORKSPACE } from './lib/host.js';
35
+ import { app, TABS, WORKSPACE } from './lib/host.js';
32
36
  /** The rail's width, px. */
33
37
  export const RAIL = 48;
34
38
  /** A mark, px: a 36px square on a 44px pitch. */
35
39
  const MARK = 36;
40
+ /** The places the rail holds beside the products, by the builder address each opens. */
41
+ export const RAISED = { library: '-/artifacts', scheduled: '-/automations', customize: '-/customize' };
42
+ /** The place a builder address stands on, if it is one that takes the sidebar. */
43
+ export const spot = (at) => (at !== null && route(at).kind === 'settings' ? 'settings' : raised(at));
44
+ /** Which of them a builder address stands on, if any: an agent's page is Customize's, an automation Scheduled tasks'. */
45
+ export function raised(at) {
46
+ if (at === null)
47
+ return null;
48
+ const r = route(at);
49
+ if (r.kind === 'screen' && r.screen === 'artifacts')
50
+ return 'library';
51
+ if ((r.kind === 'screen' && r.screen === 'automations') || r.kind === 'automation')
52
+ return 'scheduled';
53
+ if (r.kind === 'customize' || r.kind === 'agent' || (r.kind === 'screen' && r.screen === 'mcp'))
54
+ return 'customize';
55
+ return null;
56
+ }
36
57
  /** One mark: an icon button named for where it goes, its name its tooltip, lit where the reader is. */
37
58
  export function Mark({ label, on = false, onPress, children, ...rest }) {
38
59
  return (_jsx(XStack, { render: "button", "aria-label": label, ...{ title: label }, "aria-current": on ? 'page' : undefined, onPress: onPress, width: MARK, height: MARK, rounded: "$4", items: "center", justify: "center", shrink: 0, cursor: "pointer", opacity: on ? 1 : 0.62, bg: on ? '$edge' : 'transparent', hoverStyle: { bg: on ? '$edge' : '$hover', opacity: 1 }, pressStyle: { bg: '$raised' }, focusVisibleStyle: { outlineWidth: 2, outlineStyle: 'solid', outlineColor: '$outlineColor' },
@@ -43,12 +64,22 @@ export function Mark({ label, on = false, onPress, children, ...rest }) {
43
64
  export function Toggle({ open, onPress, label }) {
44
65
  return (_jsx(Mark, { label: label ?? (open ? 'Close sidebar' : 'Open sidebar'), "data-slot": "sidebar-toggle", "aria-expanded": open, onPress: onPress, children: _jsx(PanelLeft, { size: 18, "aria-hidden": true }) }));
45
66
  }
46
- export function Rail({ host, mode, open, toggle, flat = false, onChat, onDev, onStudio, onOrg, }) {
47
- // The products on hosts of their own, behind More.
67
+ export function Rail({ host, mode, at, open, toggle, flat = false, onChat, onDev, onStudio, go, onOrg, }) {
68
+ const here = mode === 'dev' ? spot(at) : null;
69
+ const to = (p) => () => go(app(p));
70
+ // The products on hosts of their own, behind More; on a phone the raised places lead it.
48
71
  const rest = [
72
+ ...(flat
73
+ ? [
74
+ { key: 'library', label: 'Library', icon: _jsx(Library, { size: 16, "aria-hidden": true }), onSelect: to(RAISED.library) },
75
+ { key: 'scheduled', label: 'Scheduled tasks', icon: _jsx(Clock, { size: 16, "aria-hidden": true }), onSelect: to(RAISED.scheduled) },
76
+ { key: 'customize', label: 'Customize', icon: _jsx(Puzzle, { size: 16, "aria-hidden": true }), onSelect: to(RAISED.customize) },
77
+ { type: 'separator', key: 'raised' },
78
+ ]
79
+ : []),
49
80
  { key: 'tabs', label: 'Tabs', icon: _jsx(SquareTerminal, { size: 16, "aria-hidden": true }), onSelect: () => out(TABS) },
50
81
  { key: 'team', label: 'Team', icon: _jsx(Users, { size: 16, "aria-hidden": true }), onSelect: () => out(WORKSPACE) },
51
82
  ];
52
83
  const Stack = flat ? XStack : YStack;
53
- return (_jsxs(Stack, { "data-slot": "rail", render: "nav", "aria-label": "Products", ...(flat ? { pt: '$2', mt: '$2', borderTopWidth: 1, borderColor: '$borderColor', gap: '$1' } : { width: RAIL, flex: 1, pt: '$2', pb: '$2', gap: 8 }), shrink: 0, items: "center", children: [toggle ? (_jsx(YStack, { pb: 8, children: _jsx(Toggle, { open: open, onPress: toggle }) })) : null, _jsx(Mark, { label: "Chat", on: mode === 'chat', onPress: onChat, "data-slot": "rail-chat", children: _jsx(MessageSquare, { size: 18, "aria-hidden": true }) }), _jsx(Mark, { label: "Dev", on: mode === 'dev', onPress: onDev, "data-slot": "rail-dev", children: _jsx(Code2, { size: 18, "aria-hidden": true }) }), _jsx(Mark, { label: "Studio", on: mode === 'studio', onPress: onStudio, "data-slot": "rail-studio", children: _jsx(WandSparkles, { size: 18, "aria-hidden": true }) }), _jsx(DropdownMenu, { placement: flat ? 'top-start' : 'right-start', minWidth: 220, trigger: _jsx(XStack, { render: "button", "aria-label": "More", ...{ title: 'More' }, "data-slot": "rail-more", width: MARK, height: MARK, rounded: "$4", items: "center", justify: "center", cursor: "pointer", opacity: 0.62, hoverStyle: { bg: '$hover', opacity: 1 }, pressStyle: { bg: '$raised' }, focusVisibleStyle: { outlineWidth: 2, outlineStyle: 'solid', outlineColor: '$outlineColor' }, $touchable: { width: 44, height: 44 }, children: _jsx(Ellipsis, { size: 18, "aria-hidden": true }) }), items: rest }), _jsx(YStack, { flex: 1 }), _jsx(Me, { host: host, narrow: true, above: flat, onOrg: onOrg })] }));
84
+ return (_jsxs(Stack, { "data-slot": "rail", render: "nav", "aria-label": "Products", ...(flat ? { pt: '$2', mt: '$2', borderTopWidth: 1, borderColor: '$borderColor', gap: '$1' } : { width: RAIL, flex: 1, pt: '$2', pb: '$2', gap: 8 }), shrink: 0, items: "center", children: [toggle ? (_jsx(YStack, { pb: 8, children: _jsx(Toggle, { open: open, onPress: toggle }) })) : null, _jsx(Mark, { label: "Chat", on: mode === 'chat', onPress: onChat, "data-slot": "rail-chat", children: _jsx(MessageSquare, { size: 18, "aria-hidden": true }) }), _jsx(Mark, { label: "Dev", on: mode === 'dev' && !here, onPress: onDev, "data-slot": "rail-dev", children: _jsx(Code2, { size: 18, "aria-hidden": true }) }), _jsx(Mark, { label: "Studio", on: mode === 'studio', onPress: onStudio, "data-slot": "rail-studio", children: _jsx(WandSparkles, { size: 18, "aria-hidden": true }) }), flat ? null : (_jsxs(_Fragment, { children: [_jsx(Mark, { label: "Library", on: here === 'library', onPress: to(RAISED.library), "data-slot": "rail-library", children: _jsx(Library, { size: 18, "aria-hidden": true }) }), _jsx(Mark, { label: "Scheduled tasks", on: here === 'scheduled', onPress: to(RAISED.scheduled), "data-slot": "rail-scheduled", children: _jsx(Clock, { size: 18, "aria-hidden": true }) }), _jsx(Mark, { label: "Customize", on: here === 'customize', onPress: to(RAISED.customize), "data-slot": "rail-customize", children: _jsx(Puzzle, { size: 18, "aria-hidden": true }) })] })), _jsx(DropdownMenu, { placement: flat ? 'top-start' : 'right-start', minWidth: 220, trigger: _jsx(XStack, { render: "button", "aria-label": "More", ...{ title: 'More' }, "data-slot": "rail-more", width: MARK, height: MARK, rounded: "$4", items: "center", justify: "center", cursor: "pointer", opacity: 0.62, hoverStyle: { bg: '$hover', opacity: 1 }, pressStyle: { bg: '$raised' }, focusVisibleStyle: { outlineWidth: 2, outlineStyle: 'solid', outlineColor: '$outlineColor' }, $touchable: { width: 44, height: 44 }, children: _jsx(Ellipsis, { size: 18, "aria-hidden": true }) }), items: rest }), _jsx(YStack, { flex: 1 }), _jsx(Me, { host: host, narrow: true, above: flat, onOrg: onOrg })] }));
54
85
  }
package/dist/Shell.js CHANGED
@@ -27,8 +27,7 @@ import { Box, Shortcut, XStack, YStack, Text } from "@hanzo/ui";
27
27
  import { scrim, sheet } from "@hanzo/ui/glass";
28
28
  import { Sidebar, SidebarIconButton, SidebarNewChat, SidebarScroll, SidebarSection, SidebarItem, SidebarFolder, } from "@hanzo/ui/chat";
29
29
  import { OrgProjectSwitcher, useIam, useOrganizations, } from "@hanzo/iam/react";
30
- import { SideProvider, useSplit } from "@hanzo/build";
31
- import { Grip } from "@hanzo/ui";
30
+ import { Gutter, SideProvider, useSplit } from "@hanzo/build";
32
31
  import { HanzoMark, } from "@hanzogui/shell";
33
32
  import { useHydrated } from "./lib/hydrated.js";
34
33
  import { useHost } from "./lib/hostname.js";
@@ -58,12 +57,8 @@ const FIELD = {
58
57
  color: "inherit",
59
58
  fontSize: "$2",
60
59
  };
61
- /** The side column's grip stands in the gutter the frame leaves, or 8px where there is none; the rail clears the
62
- * home indicator on a phone. gui carries a `calc()`, `max()` or `env()` to the page unchanged; its types only name
60
+ /** The rail clears the home indicator on a phone. gui carries an `env()` to the page unchanged; its types only name
63
61
  * custom properties. */
64
- const GUTTER_START = "calc(-1 * var(--pane-gap, 0px))";
65
- const GUTTER = "max(8px, var(--pane-gap, 8px))";
66
- const TOUCH_START = "calc(-22px - var(--pane-gap, 0px) / 2)";
67
62
  const HOME_INDICATOR = "env(safe-area-inset-bottom)";
68
63
  import { invite } from "./lib/home.js";
69
64
  import { reach } from "./lib/reach.js";
@@ -910,13 +905,7 @@ export function Paned({ children, swap, framed = false, bare = false, }) {
910
905
  // In the frame the gutter stands between the pane and this column.
911
906
  ml: framed ? "var(--pane-gap)" : undefined, ref: split.aside,
912
907
  // inline-style: the width a drag rewrites on the element itself, and full screen (useSplit).
913
- style: split.asideStyle, position: "relative", shrink: 0, minH: 0, display: "none", $lg: { display: "flex" }, children: [split.grip ? (_jsx(Grip, { ...split.grip, label: "Resize side panel", bar: "inline",
914
- // The handle halfway down the edge, where a hand looks for it.
915
- justify: "center",
916
- // In the gutter the frame leaves between the panes; a room has none.
917
- l: GUTTER_START, width: GUTTER,
918
- // A finger's band, centred on the gutter.
919
- $touchable: { l: TOUCH_START, width: 44 } })) : null, _jsx(YStack, { flex: 1, minH: 0, overflowY: "auto", overflowX: "hidden", rounded: "var(--pane-round)", ref: (el) => setSlot(el instanceof HTMLElement ? el : null) })] })) : null] }));
908
+ style: split.asideStyle, position: "relative", shrink: 0, minH: 0, display: "none", $lg: { display: "flex" }, children: [split.grip ? (_jsx(Gutter, { ...split.grip, label: "Resize side panel", inset: 1 })) : null, _jsx(YStack, { flex: 1, minH: 0, overflowY: "auto", overflowX: "hidden", rounded: "var(--pane-round)", ref: (el) => setSlot(el instanceof HTMLElement ? el : null) })] })) : null] }));
920
909
  }
921
910
  /**
922
911
  * THE RAIL: where you can go, as marks with their names under them. It is the
@@ -1229,7 +1218,7 @@ function Frame({ mode, children, navigate, back, forward, }) {
1229
1218
  const list = (_jsxs(Sidebar, { ref: side.ref,
1230
1219
  // A phone's sheet is never wider than 280, whatever the laptop kept.
1231
1220
  // inline-style: `--span`, the width a drag rewrites on the element itself (span.ts); no prop sets a variable.
1232
- style: side.style, $md: { width: "var(--span)" }, "$max-md": { width: "100%", maxW: 280 }, ...sheet(0), borderRightWidth: 1, borderColor: "var(--edge, var(--white-06))", position: "relative", children: [_jsx(Grip, { side: "left", span: side.span, floor: FLOOR, ceil: CEIL, reset: SIDEBAR_WIDTH, onSpan: side.move, onKeep: side.keep, onShut: () => setAsked(false), label: "Resize sidebar" }), _jsx(XStack, { "data-slot": "mode-name", items: "center", px: "$2", pt: "$2", minW: 0, children: _jsx(Text, { fontSize: "$4", fontWeight: "600", color: "$ink", numberOfLines: 1, children: wordmark(mode === "dev" ? "dev" : "chat") }) }), _jsxs(XStack, { "data-slot": "workspace-switcher", items: "center", gap: "$2", px: "$2", pt: "$1", pb: "$1", minW: 0, children: [_jsx(Text, { render: _jsx("a", { href: "/", onClick: (e) => {
1221
+ style: side.style, $md: { width: "var(--span)" }, "$max-md": { width: "100%", maxW: 280 }, ...sheet(0), borderRightWidth: 1, borderColor: "var(--edge, var(--white-06))", position: "relative", children: [_jsx(Gutter, { side: "left", span: side.span, floor: FLOOR, ceil: CEIL, reset: SIDEBAR_WIDTH, onSpan: side.move, onKeep: side.keep, onShut: () => setAsked(false), label: "Resize sidebar" }), _jsx(XStack, { "data-slot": "mode-name", items: "center", px: "$2", pt: "$2", minW: 0, children: _jsx(Text, { fontSize: "$4", fontWeight: "600", color: "$ink", numberOfLines: 1, children: wordmark(mode === "dev" ? "dev" : "chat") }) }), _jsxs(XStack, { "data-slot": "workspace-switcher", items: "center", gap: "$2", px: "$2", pt: "$1", pb: "$1", minW: 0, children: [_jsx(Text, { render: _jsx("a", { href: "/", onClick: (e) => {
1233
1222
  if (!navigate || e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0)
1234
1223
  return;
1235
1224
  e.preventDefault();
package/dist/Support.d.ts CHANGED
@@ -5,10 +5,3 @@
5
5
  * token is.
6
6
  */
7
7
  export declare function SupportBanner(): import("react").JSX.Element;
8
- /**
9
- * Every organization, for a SuperAdmin to step into. The list and the search are
10
- * IAM's, a page at a time; picking one asks IAM to step in.
11
- */
12
- export declare function SupportPicker({ onClose }: {
13
- onClose: () => void;
14
- }): import("react").JSX.Element;