@hanzo/rooms 0.1.24 → 0.1.26

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/Chat.js CHANGED
@@ -44,7 +44,8 @@ import { Anchor, Box, Button, Text, Tooltip, TooltipContent, TooltipTrigger, Vie
44
44
  import { Control } from "@hanzo/composer";
45
45
  import { useIam, useIamToken } from "@hanzo/iam/react";
46
46
  import { hasSession, org } from "./lib/session.js";
47
- import { chats, sku, useLimits } from "./lib/limits.js";
47
+ import { chats, house, paidPlan, setCreditsAfterAllowance, useLimits } from "./lib/limits.js";
48
+ import { parseModels } from "@hanzo/ui/models/catalog";
48
49
  import { LimitedBanner } from "@hanzo/ui/product/LimitedBanner";
49
50
  import { WEB } from "./lib/web.js";
50
51
  import { useHydrated } from "./lib/hydrated.js";
@@ -63,7 +64,6 @@ import { useStarred } from "./stars.js";
63
64
  import { useModel } from "./model.js";
64
65
  import { Enso, nameOf, useEffort } from "./enso.js";
65
66
  import { checkoutUrl } from './lib/pay.js';
66
- import { onFree } from './lib/plans.js';
67
67
  import { Upgrade, useFree } from './Upgrade.js';
68
68
  import { enter, LOGIN } from './lib/destination.js';
69
69
  import { first } from './lib/first.js';
@@ -518,32 +518,43 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
518
518
  // outputs and names no text (`chats`) is neither offered nor fallen back to.
519
519
  const { models: catalog } = useModels();
520
520
  const models = useMemo(() => catalog.filter(chats), [catalog]);
521
- // HANZO MODELS ALONE are offered here, to everyone: an Enso or Zen id
522
- // (`sku`). A preference kept from before for any other model is not one this
523
- // room offers, so the house router is asked for instead, and `served` keeps
524
- // to the house models wherever the catalog carries one. An agent's own model
525
- // is sent as it is named, never swapped for another the catalog lists; the
526
- // gateway says which model answered (`servedBy`).
527
- const offered = useMemo(() => models.filter((m) => sku(m.id)), [models]);
528
- const wanted = sku(preferred) ? preferred : ENSO;
529
- const model = persona?.model || served(models, anonymous ? FREE : wanted);
521
+ // EVERY MODEL THE GATEWAY LISTS is offered here, through the one picker, and
522
+ // read from the catalog's own fields (`parseModels`): its class, its family.
523
+ // A premium model is picked for THIS conversation and never remembered as
524
+ // the default, so the next chat starts on Enso again; any other pick is the
525
+ // reader's default. A preference kept from before for a premium model is
526
+ // not a default either, so the house router is asked for instead. An agent's
527
+ // own model is sent as it is named; the gateway says which model answered
528
+ // (`servedBy`).
529
+ const listed = useMemo(() => parseModels(catalog), [catalog]);
530
+ const rowOf = (id) => listed.find((m) => m.id === id);
531
+ const [picked, setPicked] = useState(null);
532
+ const wanted = picked ?? (preferred && rowOf(preferred)?.class !== "premium" ? preferred : ENSO);
533
+ // A model the catalog lists is sent as picked, whatever it answers with; only
534
+ // a name the catalog does not list falls to one that converses (`served`).
535
+ const sent = anonymous ? FREE : wanted;
536
+ const model = persona?.model || (rowOf(sent) ? sent : served(models, sent));
530
537
  // THE PLAN'S USAGE (lib/limits.ts), kept current by every chat answer's
531
538
  // headers and read again whenever a send settles, below. The room draws no
532
539
  // bars and no near note — those live on Settings → Usage. It says something
533
540
  // only once the reader is turned away: a reply answered from a free model, or
534
541
  // a refusal.
535
542
  const { limits, notice, reload: reread } = useLimits(!anonymous, org(), (id) => nameOf(catalog, id));
536
- const held = Boolean(limits?.plan);
543
+ const held = paidPlan(limits);
537
544
  const pause = notice && (notice.fallback || notice.refused) ? notice : null;
538
- // A MODEL THE FREE PLAN DOES NOT INCLUDE IS ASKED FOR, NOT CHOSEN. On Free,
539
- // picking a model the plan's row does not run (`onFree`) leaves the choice
540
- // where it was and opens the upgrade, titled with the model; so does a turn
541
- // the gateway refuses `plan_required`. A plan the limits name is not Free,
542
- // whatever the tier says, so every Hanzo model is simply chosen on it.
543
- // `isFree` still drives the free-lane notice.
545
+ // EVERY MODEL IS CHOSEN, never asked for: one the plan does not cover still
546
+ // picks, and the gateway answers it with the policy's refusal or from Enso,
547
+ // which the room then shows. A turn the gateway refuses `plan_required`
548
+ // opens the upgrade, titled with the model. `isFree` still drives the
549
+ // free-lane notice.
544
550
  const free = useFree(!anonymous) && !held;
545
551
  const [ask, setAsk] = useState(null);
546
- const choose = (id) => (free && !onFree(id) ? setAsk({ model: nameOf(models, id) }) : setPreferred(id));
552
+ const choose = (id) => {
553
+ if (rowOf(id)?.class === "premium")
554
+ return setPicked(id);
555
+ setPicked(null);
556
+ setPreferred(id);
557
+ };
547
558
  // KEEPING THE CONVERSATION is the reader's account, not this pane's choice:
548
559
  // the store is per-org, so a signed-in reader has somewhere for it to go and
549
560
  // an anonymous one does not. `thread` names the conversation being continued
@@ -560,7 +571,7 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
560
571
  // lookups the platform's assistant uses, run inside the completion by the
561
572
  // client's fetch (lib/web.ts), so a question about the weather or the news
562
573
  // is answered from what the web says now.
563
- params: { reasoning_effort: effort, ...(!anonymous && sku(model) ? { tools: WEB } : {}) },
574
+ params: { reasoning_effort: effort, ...(!anonymous && house(rowOf(model)?.family) ? { tools: WEB } : {}) },
564
575
  ...(open ? { thread: open } : {}),
565
576
  onError: (e) => {
566
577
  const needs = planRequired(e);
@@ -818,6 +829,37 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
818
829
  }
819
830
  void latest.current(text);
820
831
  }, []);
832
+ // THE WAYS PAST A PAUSE, as the server sends them. A page opens; Continue
833
+ // with credits turns the org's choice on and asks the last question again on
834
+ // the model the reader picked; a switch moves the picker. A refusal of the
835
+ // write (only an org admin may make it) is said under the banner.
836
+ const [actWrong, setActWrong] = useState("");
837
+ const act = useCallback((a) => {
838
+ setActWrong("");
839
+ if (a.kind === "switch") {
840
+ if (a.model)
841
+ choose(a.model);
842
+ return;
843
+ }
844
+ if (a.kind !== "credits") {
845
+ if (a.url)
846
+ window.location.assign(a.url);
847
+ return;
848
+ }
849
+ setCreditsAfterAllowance(true)
850
+ .then(() => {
851
+ reread();
852
+ // A refused question is asked again in its place; one a free model
853
+ // answered is asked once more after that answer.
854
+ const q = [...turns.current].reverse().find((m) => m.role === "user");
855
+ if (q)
856
+ speak(words(q.content), failures[q.id] ? q.id : undefined);
857
+ })
858
+ .catch((e) => setActWrong(e instanceof Error ? e.message : "Credits could not be turned on."));
859
+ },
860
+ // `choose` is rebuilt each render; the handler follows it.
861
+ // eslint-disable-next-line react-hooks/exhaustive-deps
862
+ [reread, speak, failures, picked, preferred, listed]);
821
863
  // A TURN THAT NEVER SPEAKS IS STOPPED. A stream that stays open with nothing
822
864
  // in it would leave a Stop button and no answer for as long as the upstream
823
865
  // holds the connection; after `STALL` it is ended and its question is said to
@@ -1197,14 +1239,14 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
1197
1239
  },
1198
1240
  // WHAT GOES WITH THE MESSAGE, above the frame: one chip a file, each
1199
1241
  // with its own remove, and the reason any file was refused.
1200
- head: files.length || turnedAway || preparing || talk.open || talk.refusal || pause ? (_jsxs(YStack, { width: "calc(100% - 32px)", maxW: `calc(${MEASURE} - 3rem)`, mx: "auto", gap: "$1.5", children: [pause ? (_jsx(LimitedBanner, { message: pause.message, actions: pause.actions, onUsage: () => showSettings("usage") })) : null, files.length ? (_jsx(XStack, { gap: "$1.5", flexWrap: "wrap", role: "list", "aria-label": "Attached files", "data-slot": "composer-attached", children: files.map((one) => (_jsx(HeldChip, { one: one, onRemove: () => drop(at, one.id) }, one.id))) })) : null, preparing ? (_jsxs(Text, { role: "status", "aria-live": "polite", "data-slot": "composer-preparing", fontSize: "$2", color: "$soft", children: [preparing, "\u2026"] })) : null, turnedAway ? (_jsx(Text, { role: "alert", fontSize: "$2", color: BAD, children: turnedAway })) : null, talk.open || talk.refusal ? (_jsx(Text, { role: "status", "aria-live": "polite", "data-slot": "composer-talk-reply", fontSize: "$2", color: talk.refusal ? BAD : "$soft", children: talk.refusal
1242
+ head: files.length || turnedAway || preparing || talk.open || talk.refusal || pause ? (_jsxs(YStack, { width: "calc(100% - 32px)", maxW: `calc(${MEASURE} - 3rem)`, mx: "auto", gap: "$1.5", children: [pause ? (_jsx(LimitedBanner, { message: pause.message, actions: pause.actions, onAction: act, onUsage: () => showSettings("usage") })) : null, actWrong ? (_jsx(Text, { role: "alert", "data-slot": "limited-banner-wrong", fontSize: "$2", color: BAD, children: actWrong })) : null, files.length ? (_jsx(XStack, { gap: "$1.5", flexWrap: "wrap", role: "list", "aria-label": "Attached files", "data-slot": "composer-attached", children: files.map((one) => (_jsx(HeldChip, { one: one, onRemove: () => drop(at, one.id) }, one.id))) })) : null, preparing ? (_jsxs(Text, { role: "status", "aria-live": "polite", "data-slot": "composer-preparing", fontSize: "$2", color: "$soft", children: [preparing, "\u2026"] })) : null, turnedAway ? (_jsx(Text, { role: "alert", fontSize: "$2", color: BAD, children: turnedAway })) : null, talk.open || talk.refusal ? (_jsx(Text, { role: "status", "aria-live": "polite", "data-slot": "composer-talk-reply", fontSize: "$2", color: talk.refusal ? BAD : "$soft", children: talk.refusal
1201
1243
  ? worded(talk.refusal)
1202
1244
  : talk.reply
1203
1245
  ? `Hanzo: ${talk.reply}`
1204
1246
  : talk.state === "listening"
1205
1247
  ? "Listening — speak, and Hanzo answers aloud."
1206
1248
  : "Connecting…" })) : null] })) : undefined,
1207
- send: (_jsxs(XStack, { items: "center", gap: "$2", children: [_jsx(Enso, { effort: effort, onEffort: setEffort, model: wanted, onModel: choose, models: offered }), _jsx(Control, { asChild: true, size: ROUND, children: _jsx(Voice, { voice: voice, says: { idle: "Dictate", listening: "Dictating — click to stop" }, children: (state) => (_jsx(Mic, { size: 15, fill: state === "idle" ? "none" : "currentColor" })) }) }), _jsx(Control, { asChild: true, size: ROUND, children: _jsx(Voice, { voice: talk, "data-slot": "composer-talk", says: {
1249
+ send: (_jsxs(XStack, { items: "center", gap: "$2", children: [_jsx(Enso, { effort: effort, onEffort: setEffort, model: wanted, onModel: choose, models: listed, limits: limits }), _jsx(Control, { asChild: true, size: ROUND, children: _jsx(Voice, { voice: voice, says: { idle: "Dictate", listening: "Dictating — click to stop" }, children: (state) => (_jsx(Mic, { size: 15, fill: state === "idle" ? "none" : "currentColor" })) }) }), _jsx(Control, { asChild: true, size: ROUND, children: _jsx(Voice, { voice: talk, "data-slot": "composer-talk", says: {
1208
1250
  idle: "Talk with Hanzo",
1209
1251
  listening: "In conversation — click to hang up",
1210
1252
  speaking: "Hanzo is speaking — talk to interrupt",
package/dist/Settings.js CHANGED
@@ -30,15 +30,16 @@ import { tap } from './lib/tap.js';
30
30
  import { useIam, useIamIdentity, useOrganizations } from '@hanzo/iam/react';
31
31
  import { useSignOut } from './lib/signout.js';
32
32
  import { useModels } from '@hanzo/ai/react';
33
- import { ModelSelector, RESEARCH } from '@hanzo/ui/models';
33
+ import { ModelPicker } from '@hanzo/ui/models';
34
+ import { parseModels } from '@hanzo/ui/models/catalog';
34
35
  import { UsagePanel } from '@hanzo/usage/panel';
35
36
  import { Catalog } from './Directory.js';
36
37
  import { useModel } from './model.js';
37
- import { payPage } from './lib/pay.js';
38
+ import { checkoutUrl, payPage } from './lib/pay.js';
38
39
  import { planName } from './lib/plans.js';
39
40
  import { api } from './lib/api.js';
40
41
  import { renewal, useSubscription, useTier } from './lib/tier.js';
41
- import { sku, spendShown, useLimits } from './lib/limits.js';
42
+ import { paidPlan, setCreditsAfterAllowance, spendShown, useLimits } from './lib/limits.js';
42
43
  import { PlanUsage } from '@hanzo/ui/product/PlanUsage';
43
44
  import { Look } from './look.js';
44
45
  import { ProviderMark } from './ProviderMark.js';
@@ -169,20 +170,16 @@ function Pane({ id, note }) {
169
170
  return _jsx(MemorySettings, {});
170
171
  return (_jsx(Text, { fontSize: "$3", color: "$soft", children: note ?? 'Nothing to configure here yet.' }));
171
172
  }
172
- /** One default in one place; agents may still bring their own model. */
173
+ /**
174
+ * One default in one place; agents may still bring their own model. A premium
175
+ * model is never a default: it is picked per conversation, in the composer, so
176
+ * this picker offers the rest of the catalog.
177
+ */
173
178
  function Model() {
174
179
  const { models: served } = useModels();
175
- // Hanzo models alone, as the composer's picker offers them (`sku`).
176
- const models = useMemo(() => served.filter((m) => sku(m.id)), [served]);
180
+ const models = useMemo(() => parseModels(served).filter((m) => m.class !== 'premium'), [served]);
177
181
  const [model, choose] = useModel();
178
- return (_jsxs(YStack, { gap: "$3", maxW: 420, children: [_jsx(Text, { fontSize: "$3", color: "$soft", children: "Used for new chats. An agent with its own model keeps that choice." }), _jsx(XStack, { self: "flex-start", children: _jsx(ModelSelector, { models: [...models, ...RESEARCH], value: model, onChange: choose, size: "sm" }) }), _jsx(YStack, { gap: "$2", mt: "$2", children: models
179
- .filter((m) => !m.id.includes('openrouter'))
180
- .slice(0, 8)
181
- .map((m) => {
182
- const isSel = model === m.id;
183
- const p = m.id.startsWith('zen') ? 'zen' : 'enso';
184
- return (_jsx(Box, { render: "button", onClick: () => choose(m.id), px: "$3", py: "$2", rounded: "$2", borderWidth: 1, borderColor: isSel ? '$ink' : '$borderColor', bg: isSel ? '$raised' : 'transparent', hoverStyle: { bg: '$hover' }, cursor: "pointer", children: _jsxs(XStack, { items: "center", gap: "$2.5", children: [_jsx(ProviderMark, { provider: p, size: 16 }), _jsx(Text, { fontSize: "$2", fontWeight: "600", color: "$ink", flex: 1, children: m.id }), isSel ? (_jsx(Text, { fontSize: "$1", color: "$soft", children: "Active" })) : null] }) }, m.id));
185
- }) })] }));
182
+ return (_jsxs(YStack, { gap: "$3", maxW: 420, children: [_jsx(Text, { fontSize: "$3", color: "$soft", children: "Used for new chats. An agent with its own model keeps that choice. Premium models are picked per conversation in the composer." }), _jsx(XStack, { self: "flex-start", children: _jsx(ModelPicker, { models: models, scope: "chat", value: model, onChange: choose, size: "sm" }) })] }));
186
183
  }
187
184
  /**
188
185
  * WHAT THE GATEWAY SERVES, read from the same `/v1/models` the rooms read.
@@ -241,8 +238,8 @@ function Usage({ sections, title, meters = false }) {
241
238
  if (!isAuthenticated || !accessToken) {
242
239
  return _jsx(Text, { fontSize: "$3", color: "$soft", children: "Sign in to see your usage." });
243
240
  }
244
- const plan = meters && read.limits ? _jsx(PlanUsage, { limits: read.limits, plan: planName(read.limits.plan) || undefined, notice: read.notice }) : null;
245
- if (read.limits?.plan || !spendShown(read))
241
+ const plan = meters && read.limits ? (_jsx(PlanUsage, { limits: read.limits, plan: planName(read.limits.plan) || undefined, notice: read.notice, onCredits: (on) => setCreditsAfterAllowance(on).then(read.reload), creditsHref: payPage(), addCreditsHref: checkoutUrl() })) : null;
242
+ if (paidPlan(read.limits) || !spendShown(read))
246
243
  return plan;
247
244
  // The address is read HERE rather than at import: the panel sends the bearer
248
245
  // with every read, and a credentialed read to an absolute address from a page
package/dist/Shell.js CHANGED
@@ -36,7 +36,8 @@ import { hasSession, superAdmin } from "./lib/session.js";
36
36
  import { enter, LOGIN } from "./lib/destination.js";
37
37
  import { address, sharedWithMe } from "./lib/share.js";
38
38
  import { list } from "./lib/list.js";
39
- import { sku } from "./lib/limits.js";
39
+ import { ModelPicker } from "@hanzo/ui/models";
40
+ import { parseModels } from "@hanzo/ui/models/catalog";
40
41
  import { mark } from "./lib/mark.js";
41
42
  import { useOpen } from "./open.js";
42
43
  import { useSpan } from "./span.js";
@@ -667,6 +668,7 @@ function Bots({ show, owner, filter = "", onPick, navigate, }) {
667
668
  const [wrong, setWrong] = useState(null);
668
669
  const picking = useRef(null);
669
670
  const { models } = useModels();
671
+ const listed = useMemo(() => parseModels(models), [models]);
670
672
  const allPeopleMap = new Map();
671
673
  for (const p of (Array.isArray(storedPeople) ? storedPeople : []))
672
674
  if (p?.id)
@@ -776,7 +778,7 @@ function Bots({ show, owner, filter = "", onPick, navigate, }) {
776
778
  setEmoji(e.target.value);
777
779
  if (e.target.value)
778
780
  setAvatar("");
779
- }, placeholder: "\uD83D\uDE42", "aria-label": "Emoji" }), ...FIELD, width: 44, text: "center", px: 4 }), _jsxs(Text, { render: _jsx("select", { value: model, onChange: (e) => setModel(e.target.value), "aria-label": "Model" }), ...FIELD, outlineStyle: undefined, flex: 1, minW: 0, px: 8, children: [_jsx("option", { value: "enso", children: "enso" }), models.filter((one) => sku(one.id)).map((one) => (_jsx("option", { value: one.id, children: one.id }, one.id)))] }), _jsx(Box, { render: "button", onClick: () => {
781
+ }, placeholder: "\uD83D\uDE42", "aria-label": "Emoji" }), ...FIELD, width: 44, text: "center", px: 4 }), _jsx(YStack, { flex: 1, minW: 0, children: _jsx(ModelPicker, { models: listed, scope: "chat", value: model, onChange: setModel, size: "sm" }) }), _jsx(Box, { render: "button", onClick: () => {
780
782
  if (!busy)
781
783
  void add();
782
784
  }, "aria-disabled": busy || !name.trim(), opacity: busy || !name.trim() ? 0.5 : 1, px: "$2.5", py: "$1.5", rounded: "$2", borderWidth: 1, borderColor: "$borderColor", hoverStyle: { bg: "$raised" }, children: _jsx(Text, { fontSize: "$1", color: "$ink", children: busy ? "Adding" : "Add" }) })] }), wrong ? (_jsx(Text, { fontSize: "$1", color: BAD, numberOfLines: 2, children: wrong })) : null] })) : null, open ? (_jsxs(YStack, { pl: "$4", gap: "$0.5", children: [broke ? (_jsx(Note, { children: say(broke, "this org's roster") })) : waiting && empty ? (_jsx(Note, { children: "Reading the roster." })) : empty ? (_jsx(Note, { children: show === "agents" ? "No agents yet. Add one with +." : "No one here yet. Invite a teammate." })) : null, folk.map((person) => {
package/dist/Vibe.js CHANGED
@@ -26,9 +26,10 @@ import { useEffect, useMemo, useRef, useState } from 'react';
26
26
  import { useRooms } from './host.js';
27
27
  import { ArrowUp, Globe, Monitor, RefreshCw, Smartphone, Sparkles, Tablet, UserPlus, Users, } from 'lucide-react';
28
28
  import { View, XStack, YStack, Text } from '@hanzo/gui';
29
- import { Button, Picker } from '@hanzo/ui';
29
+ import { Button } from '@hanzo/ui';
30
30
  import { AiProvider, useAgents, useModels, usePeople } from '@hanzo/ai/react';
31
- import { sku } from './lib/limits.js';
31
+ import { ModelPicker } from '@hanzo/ui/models';
32
+ import { parseModels } from '@hanzo/ui/models/catalog';
32
33
  import { useIam, useOrganizations } from '@hanzo/iam/react';
33
34
  import { hasSession } from './lib/session.js';
34
35
  import { useHydrated } from './lib/hydrated.js';
@@ -122,9 +123,9 @@ function VibeWorkspaceInner() {
122
123
  function Vibe() {
123
124
  const { Link } = useRooms();
124
125
  const { user } = useIam();
125
- // Hanzo models alone, as every picker here offers them (`sku`).
126
- const { models: listed } = useModels();
127
- const models = useMemo(() => listed.filter((m) => sku(m.id)), [listed]);
126
+ // Every model the gateway lists, through the one picker.
127
+ const { models: catalog } = useModels();
128
+ const models = useMemo(() => parseModels(catalog), [catalog]);
128
129
  const [activeTabMobile, setActiveTabMobile] = useState('stream');
129
130
  // The frame shows what the bar says. It starts empty because a run publishes
130
131
  // no URL — the 202 names a session, not an address — so this room has nothing
@@ -136,7 +137,7 @@ function Vibe() {
136
137
  // The house name for the lane, resolved against what the gateway serves —
137
138
  // `served` falls to a model that answers rather than sending one nobody has.
138
139
  const [wanted, setWanted] = useState(user ? ENSO : FREE);
139
- const model = served(models, wanted);
140
+ const model = models.some((m) => m.id === wanted) ? wanted : served(models, wanted);
140
141
  const [busy, setBusy] = useState(false);
141
142
  const [turns, setTurns] = useState([]);
142
143
  const bottom = useRef(null);
@@ -168,7 +169,7 @@ function Vibe() {
168
169
  };
169
170
  return (_jsxs(YStack, { "data-vibe": true, flex: 1, minH: 0, height: "100vh", width: "100%", maxW: "100%", overflowX: "auto", overflowY: "hidden", bg: "$background", pb: 56, children: [_jsxs(XStack, { height: 52, items: "center", justify: "space-between", px: "$3.5", borderBottomWidth: 1, borderColor: "$borderColor", bg: "$background", z: 30, shrink: 0, children: [_jsxs(XStack, { items: "center", gap: "$2.5", children: [_jsx(TopOrgSelector, {}), _jsxs(XStack, { display: "none", $md: { display: 'flex' }, items: "center", gap: "$2", py: 4, px: 10, rounded: 20, bg: "$panel", borderWidth: 1, borderColor: "$borderColor", children: [_jsx(View, { width: 7, height: 7, rounded: 4, bg: busy ? 'var(--state-online)' : '$faint' }), _jsx(Text, { fontSize: 11, color: "$quiet", fontWeight: "500", children: busy ? 'Starting a run' : 'Idle' })] })] }), _jsxs(XStack, { items: "center", gap: "$2", children: [_jsx(XStack, { $md: { display: 'none' }, bg: "$panel", rounded: 8, p: 2, gap: 2, borderWidth: 1, borderColor: "$borderColor", children: ['stream', 'preview', 'team'].map((tab) => (_jsx(Button, { size: "sm", px: "$2", variant: activeTabMobile === tab ? 'secondary' : 'ghost', color: activeTabMobile === tab ? '$ink' : '$soft', "aria-pressed": activeTabMobile === tab, onClick: () => setActiveTabMobile(tab), children: tab === 'stream' ? 'Stream' : tab === 'preview' ? 'Preview' : 'Team' }, tab))) }), _jsx(Button, { asChild: true, variant: "primary", size: "sm", children: _jsxs(Link, { href: invite(), children: [_jsx(UserPlus, { size: 13 }), "Invite"] }) })] })] }), _jsxs(View, { flex: 1, minH: 0, width: "100%", height: "100%", overflow: "hidden", display: "grid", gridTemplateRows: "100%", gridTemplateColumns: "100%", $md: { gridTemplateColumns: 'minmax(320px, 380px) minmax(0, 1fr)' }, $lg: { gridTemplateColumns: 'minmax(340px, 400px) minmax(360px, 1fr) minmax(300px, 340px)' }, $xl: { gridTemplateColumns: 'minmax(380px, 440px) minmax(480px, 1fr) minmax(340px, 380px)' }, children: [_jsxs(YStack, { height: "100%", borderRightWidth: 1, borderColor: "$borderColor", bg: "$background", minW: 0, display: activeTabMobile === 'stream' ? 'flex' : 'none', $md: { display: 'flex' }, children: [_jsxs(XStack, { height: 42, items: "center", gap: "$2", px: "$3.5", borderBottomWidth: 1, borderColor: "$borderColor", bg: "$background", children: [_jsx(Sparkles, { size: 14, color: "var(--foreground)" }), _jsx(Text, { fontSize: 12, fontWeight: "600", color: "$ink", children: "Runs" }), turns.length > 0 && (_jsx(View, { px: 6, py: 2, rounded: 4, bg: "$hover", children: _jsx(Text, { fontSize: 10, color: "$soft", children: turns.length }) }))] }), _jsxs(YStack, { flex: 1, overflowY: "auto", overflowX: "hidden", p: "$3.5", gap: "$4", children: [turns.length === 0 ? (_jsx(Quiet, { children: "Nothing has been sent from this room yet. Describe a task below and the run it opens appears here." })) : null, turns.map((turn) => (_jsxs(YStack, { gap: "$2.5", children: [_jsxs(XStack, { gap: "$2.5", children: [_jsx(View, { width: 24, height: 24, rounded: 12, bg: "$raised", items: "center", justify: "center", shrink: 0, children: _jsx(Text, { fontSize: 10, fontWeight: "700", color: "$ink", children: turn.who.charAt(0).toUpperCase() }) }), _jsxs(YStack, { flex: 1, gap: "$1", children: [_jsxs(XStack, { items: "center", gap: "$2", children: [_jsx(Text, { fontSize: 12, fontWeight: "700", color: "$ink", children: turn.who }), _jsx(Text, { fontSize: 11, color: "$soft", children: turn.time }), _jsx(View, { px: 5, py: 1, rounded: 4, bg: "$hover", children: _jsx(Text, { fontSize: 10, color: "$soft", fontFamily: "$mono", children: turn.model }) })] }), _jsx(Text, { fontSize: 12, color: "$quiet", lineHeight: 18, children: turn.prompt })] })] }), _jsx(YStack, { ml: "$4", gap: "$1.5", children: turn.refused ? (_jsx(Text, { fontSize: 11, color: "$ink", lineHeight: 16, children: turn.refused })) : !turn.run ? (_jsx(Quiet, { children: "Starting." })) : (_jsxs(YStack, { p: "$2", rounded: 8, bg: "$panel", borderWidth: 1, borderColor: "$borderColor", gap: "$1", children: [_jsx(Text, { fontSize: 11, color: "$quiet", fontFamily: "$mono", children: turn.run.sessionId }), _jsxs(Text, { fontSize: 11, color: "$soft", children: [turn.run.routed
170
171
  ? `Running on ${turn.run.targetId}`
171
- : 'Running in a sandbox', turn.run.branch ? ` · ${turn.run.branch}` : ''] }), _jsx(Text, { render: _jsx(Link, { href: dev(turn.run.sessionId) }), color: "$ink", fontSize: "$1", children: "Watch it work" })] })) })] }, turn.id))), _jsx("div", { ref: bottom })] }), _jsxs(YStack, { p: "$3", borderTopWidth: 1, borderColor: "$borderColor", bg: "$background", gap: "$2", children: [_jsxs(XStack, { items: "center", justify: "space-between", gap: "$2", children: [_jsxs(Picker, { value: model, onChange: (e) => setWanted(e.target.value), "aria-label": "Model", maxW: 200, children: [models.length === 0 ? _jsx("option", { value: model, children: model }) : null, models.map((m) => (_jsx("option", { value: m.id, children: m.id }, m.id)))] }), _jsx(Text, { fontSize: 10, color: "$faint", children: "Press Enter to send" })] }), _jsxs(YStack, { bg: "$panel", borderWidth: 1, borderColor: "var(--border-control)", rounded: 12, overflow: "hidden", children: [_jsx(Text, { render: _jsx("textarea", { value: agentInput, onChange: (e) => setAgentInput(e.target.value), onKeyDown: (e) => {
172
+ : 'Running in a sandbox', turn.run.branch ? ` · ${turn.run.branch}` : ''] }), _jsx(Text, { render: _jsx(Link, { href: dev(turn.run.sessionId) }), color: "$ink", fontSize: "$1", children: "Watch it work" })] })) })] }, turn.id))), _jsx("div", { ref: bottom })] }), _jsxs(YStack, { p: "$3", borderTopWidth: 1, borderColor: "$borderColor", bg: "$background", gap: "$2", children: [_jsxs(XStack, { items: "center", justify: "space-between", gap: "$2", children: [_jsx(ModelPicker, { models: models, scope: "chat", value: model, onChange: setWanted, size: "sm", quiet: true }), _jsx(Text, { fontSize: 10, color: "$faint", children: "Press Enter to send" })] }), _jsxs(YStack, { bg: "$panel", borderWidth: 1, borderColor: "var(--border-control)", rounded: 12, overflow: "hidden", children: [_jsx(Text, { render: _jsx("textarea", { value: agentInput, onChange: (e) => setAgentInput(e.target.value), onKeyDown: (e) => {
172
173
  if (e.key === 'Enter' && !e.shiftKey) {
173
174
  e.preventDefault();
174
175
  send();
package/dist/enso.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type ModelCatalogEntry, type PauseSource } from '@hanzo/ui/models';
1
2
  /**
2
3
  * How hard to think — and, one press inside, what thinks.
3
4
  *
@@ -45,7 +46,7 @@ export type EffortId = (typeof EFFORTS)[number]['id'];
45
46
  */
46
47
  export declare function useEffort(): readonly [EffortId, (next: EffortId) => void];
47
48
  /** The name a served model goes by: its row's label, its name, or its id made readable. */
48
- export declare function nameOf(models: {
49
+ export declare function nameOf(models: readonly {
49
50
  id: string;
50
51
  }[], id: string): string;
51
52
  export interface EnsoProps {
@@ -55,9 +56,10 @@ export interface EnsoProps {
55
56
  /** The override. `models` empty hides it rather than offering an empty menu. */
56
57
  model?: string;
57
58
  onModel?: (id: string) => void;
58
- models?: {
59
- id: string;
60
- }[];
59
+ /** The catalog, as `parseModels` reads `GET /v1/models`: every model is offered. */
60
+ models?: readonly ModelCatalogEntry[];
61
+ /** The payer's plan usage: a paused model says so and still picks. */
62
+ limits?: PauseSource | null;
61
63
  disabled?: boolean;
62
64
  }
63
65
  /**
@@ -67,4 +69,4 @@ export interface EnsoProps {
67
69
  * (`ENSO`, or `FREE` on the free route), and the model itself once one is
68
70
  * picked. Beside the name it states the effort where the surface sends one.
69
71
  */
70
- export declare function Enso({ effort, onEffort, model, onModel, models, disabled }: EnsoProps): import("react").JSX.Element;
72
+ export declare function Enso({ effort, onEffort, model, onModel, models, limits, disabled }: EnsoProps): import("react").JSX.Element;
package/dist/enso.js CHANGED
@@ -3,7 +3,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
3
3
  import { useEffect, useState } from 'react';
4
4
  import { Check, ChevronDown } from 'lucide-react';
5
5
  import { Box, Popover, PopoverContent, PopoverTrigger, Separator, Text, XStack, YStack, } from '@hanzo/ui';
6
- import { ModelSelector, RESEARCH } from '@hanzo/ui/models';
6
+ import { ModelPicker } from '@hanzo/ui/models';
7
7
  import { ProviderMark } from './ProviderMark.js';
8
8
  import { ENSO, FREE } from './lib/ai.js';
9
9
  import { modelName } from './lib/models.js';
@@ -90,7 +90,7 @@ export function nameOf(models, id) {
90
90
  * (`ENSO`, or `FREE` on the free route), and the model itself once one is
91
91
  * picked. Beside the name it states the effort where the surface sends one.
92
92
  */
93
- export function Enso({ effort, onEffort, model, onModel, models = [], disabled }) {
93
+ export function Enso({ effort, onEffort, model, onModel, models = [], limits, disabled }) {
94
94
  const [open, setOpen] = useState(false);
95
95
  const chosen = EFFORTS.find((e) => e.id === effort);
96
96
  const pace = onEffort && chosen ? chosen.label : null;
@@ -105,7 +105,7 @@ export function Enso({ effort, onEffort, model, onModel, models = [], disabled }
105
105
  onEffort(e.id);
106
106
  setOpen(false);
107
107
  }, px: "$2", py: "$2", rounded: "$3", width: "100%", hoverStyle: { bg: '$hover' }, "aria-pressed": e.id === effort, children: _jsxs(XStack, { items: "center", gap: "$2", width: "100%", children: [_jsxs(YStack, { flex: 1, minW: 0, items: "flex-start", children: [_jsx(Text, { fontSize: "$2", color: "$ink", text: "left", children: e.label }), _jsx(Text, { fontSize: "$1", color: "$soft", text: "left", children: e.note })] }), e.id === effort ? _jsx(Check, { size: 14, "aria-hidden": true }) : null] }) }, e.id)))
108
- : null, onModel && models.length ? (_jsxs(_Fragment, { children: [onEffort ? _jsx(Separator, { my: "$1" }) : null, _jsxs(YStack, { px: "$2", pt: "$1", pb: "$2", gap: "$2", children: [_jsx(Text, { fontSize: "$1", color: "$soft", children: "Enso picks the model. Override it for this account:" }), _jsx(ModelSelector, { models: [...models, ...RESEARCH], value: model, onChange: (id) => {
108
+ : null, onModel && models.length ? (_jsxs(_Fragment, { children: [onEffort ? _jsx(Separator, { my: "$1" }) : null, _jsxs(YStack, { px: "$2", pt: "$1", pb: "$2", gap: "$2", children: [_jsx(Text, { fontSize: "$1", color: "$soft", children: "Enso picks the model. Override it for this account:" }), _jsx(ModelPicker, { models: models, limits: limits, value: model, onChange: (id) => {
109
109
  onModel(id);
110
110
  setOpen(false);
111
111
  }, size: "sm" })] })] })) : null] }) })] }));
@@ -7,8 +7,15 @@
7
7
  * bearer is the subject and `X-Org-Id` the organization this browser works in.
8
8
  */
9
9
  import { type UseLimits } from '@hanzo/ui/product/useLimits';
10
+ import { paidPlan } from '@hanzo/ui/product/limits';
10
11
  /** Reads the limits as whoever holds the bearer, in this browser's organization. */
11
12
  export declare function readLimits(signal: AbortSignal): Promise<unknown>;
13
+ /**
14
+ * The org's choice to keep paying from credits once included usage runs out —
15
+ * `PUT /v1/ai/limits`, org admins only. A refusal throws with the server's own
16
+ * words, so the reader is told why.
17
+ */
18
+ export declare function setCreditsAfterAllowance(on: boolean): Promise<void>;
12
19
  /**
13
20
  * The limits for a signed-in reader, in organization `org`; `name` turns a
14
21
  * fallback model id into the name the reader knows. Signed out reads nothing,
@@ -17,11 +24,12 @@ export declare function readLimits(signal: AbortSignal): Promise<unknown>;
17
24
  export declare const useLimits: (enabled?: boolean, org?: string | null, name?: (id: string) => string) => UseLimits;
18
25
  /**
19
26
  * Whether a reader's dollar spend may be shown: their limits came back and name no
20
- * plan, or could not be read. A plan holder sees usage as shares, never money.
27
+ * paid plan, or could not be read. A plan holder sees usage as shares, never money.
21
28
  */
22
29
  export declare const spendShown: ({ limits, answered }: Pick<UseLimits, 'limits' | 'answered'>) => boolean;
23
- /** Whether a model is a Hanzo SKU — an Enso or Zen id — the only models a consumer surface offers. */
24
- export declare const sku: (id: string) => boolean;
30
+ export { paidPlan };
31
+ /** Whether a catalog family is one of Hanzo's chat families, Enso or Zen, whose turns carry the live web. */
32
+ export declare const house: (family: string | undefined) => boolean;
25
33
  /**
26
34
  * Whether a model can answer a chat turn: it names no outputs, or names text
27
35
  * among them. `zen-scribe` (a transcript) and `zen-voice-mini` (audio) are
@@ -8,6 +8,7 @@
8
8
  * bearer is the subject and `X-Org-Id` the organization this browser works in.
9
9
  */
10
10
  import { useLimits as useHeld } from '@hanzo/ui/product/useLimits';
11
+ import { paidPlan } from '@hanzo/ui/product/limits';
11
12
  import { api } from './api.js';
12
13
  import { scope } from './session.js';
13
14
  /** Reads the limits as whoever holds the bearer, in this browser's organization. */
@@ -17,6 +18,23 @@ export async function readLimits(signal) {
17
18
  throw Object.assign(new Error(`Limits answered ${res.status}`), { status: res.status });
18
19
  return res.json();
19
20
  }
21
+ /**
22
+ * The org's choice to keep paying from credits once included usage runs out —
23
+ * `PUT /v1/ai/limits`, org admins only. A refusal throws with the server's own
24
+ * words, so the reader is told why.
25
+ */
26
+ export async function setCreditsAfterAllowance(on) {
27
+ const res = await fetch(`${api()}/v1/ai/limits`, {
28
+ method: 'PUT',
29
+ headers: { ...scope(), 'Content-Type': 'application/json' },
30
+ body: JSON.stringify({ creditsAfterAllowance: on }),
31
+ });
32
+ if (res.ok)
33
+ return;
34
+ const body = (await res.json().catch(() => null));
35
+ const said = typeof body?.error === 'string' ? body.error : typeof body?.error?.message === 'string' ? body.error.message : typeof body?.message === 'string' ? body.message : '';
36
+ throw Object.assign(new Error(said || (res.status === 403 ? 'Only an organization admin can turn on credits.' : `Credits answered ${res.status}`)), { status: res.status });
37
+ }
20
38
  /**
21
39
  * The limits for a signed-in reader, in organization `org`; `name` turns a
22
40
  * fallback model id into the name the reader knows. Signed out reads nothing,
@@ -25,11 +43,12 @@ export async function readLimits(signal) {
25
43
  export const useLimits = (enabled = true, org = null, name) => useHeld(enabled ? readLimits : null, org, name);
26
44
  /**
27
45
  * Whether a reader's dollar spend may be shown: their limits came back and name no
28
- * plan, or could not be read. A plan holder sees usage as shares, never money.
46
+ * paid plan, or could not be read. A plan holder sees usage as shares, never money.
29
47
  */
30
- export const spendShown = ({ limits, answered }) => answered && !limits?.plan;
31
- /** Whether a model is a Hanzo SKU — an Enso or Zen id — the only models a consumer surface offers. */
32
- export const sku = (id) => /^(hanzo\/)?(enso|zen)/i.test(id);
48
+ export const spendShown = ({ limits, answered }) => answered && !paidPlan(limits);
49
+ export { paidPlan };
50
+ /** Whether a catalog family is one of Hanzo's chat families, Enso or Zen, whose turns carry the live web. */
51
+ export const house = (family) => family === 'enso' || family === 'zen';
33
52
  /**
34
53
  * Whether a model can answer a chat turn: it names no outputs, or names text
35
54
  * among them. `zen-scribe` (a transcript) and `zen-voice-mini` (audio) are
@@ -150,8 +150,6 @@ export declare function charge(plan: SubscriptionPlan, interval: Interval): numb
150
150
  export declare function quote(plan: SubscriptionPlan, interval: Interval): string | null;
151
151
  /** What a year saves against twelve months, as a whole percent: 1 − year ÷ (12 × month). Null where it saves nothing. */
152
152
  export declare function saving(plan: SubscriptionPlan): number | null;
153
- /** Whether the Free plan runs a model: one its row names, or any `:free` route. */
154
- export declare function onFree(model: string): boolean;
155
153
  /**
156
154
  * The catalog's own name for a plan slug — "max-20x" reads as "Max 20x" —
157
155
  * or null where the slug is empty or the catalog does not carry it.
package/dist/lib/plans.js CHANGED
@@ -222,17 +222,6 @@ export function saving(plan) {
222
222
  return null;
223
223
  return Math.round((1 - year / (12 * month)) * 100);
224
224
  }
225
- /**
226
- * The models the Free plan runs, as @hanzo/plans states them: `ai.models` on
227
- * the `free` row. Read on first ask and not at import, so a page that only
228
- * formats money or names the pay page carries none of the catalogue.
229
- */
230
- let freeModels = null;
231
- /** Whether the Free plan runs a model: one its row names, or any `:free` route. */
232
- export function onFree(model) {
233
- freeModels ??= new Set(subscriptionPlans.find((p) => p.id === "free")?.entitlements?.["ai.models"] ?? []);
234
- return freeModels.has(model) || model.endsWith(":free");
235
- }
236
225
  /**
237
226
  * The catalog's own name for a plan slug — "max-20x" reads as "Max 20x" —
238
227
  * or null where the slug is empty or the catalog does not carry it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanzo/rooms",
3
- "version": "0.1.24",
3
+ "version": "0.1.26",
4
4
  "description": "The workspace rooms — Home, Chat, Inbox, Contacts, Meet, Cal, Drive, Board, Work, Bots — and the frame that holds them, once, for every Hanzo surface.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -56,7 +56,7 @@
56
56
  "@hanzo/iam": ">=0.21.28",
57
57
  "@hanzo/logo": ">=1.0.26",
58
58
  "@hanzo/plans": ">=1.8.9",
59
- "@hanzo/ui": ">=8.27.57",
59
+ "@hanzo/ui": ">=8.27.67",
60
60
  "@hanzo/usage": ">=0.1.18",
61
61
  "@hanzo/voice": ">=0.1.16",
62
62
  "@hanzogui/shell": "^8.3.20",
@@ -66,16 +66,16 @@
66
66
  },
67
67
  "devDependencies": {
68
68
  "@hanzo/ai": "0.6.20",
69
- "@hanzo/appearance": "0.2.6",
69
+ "@hanzo/appearance": "0.2.8",
70
70
  "@hanzo/build": "0.2.34",
71
71
  "@hanzo/composer": "0.2.0",
72
- "@hanzo/design": "0.5.33",
72
+ "@hanzo/design": "0.5.34",
73
73
  "@hanzo/event": "0.3.56",
74
74
  "@hanzo/gui": "8.3.5",
75
75
  "@hanzo/iam": "0.21.28",
76
76
  "@hanzo/logo": "1.0.26",
77
77
  "@hanzo/plans": "1.8.9",
78
- "@hanzo/ui": "8.27.57",
78
+ "@hanzo/ui": "8.27.67",
79
79
  "@hanzo/usage": "0.1.18",
80
80
  "@hanzo/voice": "0.1.16",
81
81
  "@hanzogui/config": "8.3.5",