@hanzo/rooms 0.1.21 → 0.1.23

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/Account.d.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  /** The block at the foot of the sidebar: press it for the menu. */
2
- export declare function Account({ onToggleCollapse, onProfile, onAppearance, }?: {
2
+ export declare function Account({ onToggleCollapse, onProfile, onAppearance, onUsage, }?: {
3
3
  onToggleCollapse?: () => void;
4
4
  /** Opens the profile beside the room. Absent, the row goes to hanzo.id. */
5
5
  onProfile?: () => void;
6
6
  /** Opens the appearance settings. Absent, the row is not drawn. */
7
7
  onAppearance?: () => void;
8
+ /** Opens the plan's usage settings. Absent, the row is not drawn. */
9
+ onUsage?: () => void;
8
10
  }): import("react").JSX.Element;
package/dist/Account.js CHANGED
@@ -3,13 +3,13 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  /**
4
4
  * WHO YOU ARE, at the foot of the sidebar: the person, and the person's menu.
5
5
  *
6
- * Profile, appearance, security and sign out — the account, and nothing that
6
+ * Profile, appearance, usage, security and sign out — the account, and nothing that
7
7
  * belongs to the workspace. Which workspace you stand in is the switcher at the
8
8
  * TOP of the sidebar, and what it may spend is its Billing, so this card shows
9
9
  * neither: a balance here read as the person's money when it is the workspace's.
10
10
  */
11
11
  import { useEffect, useRef, useState } from 'react';
12
- import { ChevronsUpDown, LogOut, PanelLeftClose, Palette, Shield, UserRound } from 'lucide-react';
12
+ import { Activity, ChevronsUpDown, LogOut, PanelLeftClose, Palette, Shield, UserRound } from 'lucide-react';
13
13
  import { XStack, YStack, Text, Box } from '@hanzo/ui';
14
14
  import { sheet } from '@hanzo/ui/glass';
15
15
  import { useIam } from '@hanzo/iam/react';
@@ -22,7 +22,7 @@ function Row({ icon, onPress, children, }) {
22
22
  return (_jsx(Box, { render: "button", onClick: onPress, width: "100%", px: "$3", py: "$2", rounded: "$2", hoverStyle: { bg: '$hover' }, children: _jsxs(XStack, { items: "center", gap: "$2", children: [icon, _jsx(Text, { fontSize: "$2", color: "$ink", children: children })] }) }));
23
23
  }
24
24
  /** The block at the foot of the sidebar: press it for the menu. */
25
- export function Account({ onToggleCollapse, onProfile, onAppearance, } = {}) {
25
+ export function Account({ onToggleCollapse, onProfile, onAppearance, onUsage, } = {}) {
26
26
  const { user } = useIam();
27
27
  const logout = useSignOut();
28
28
  const { user: account } = useAccount();
@@ -54,5 +54,5 @@ export function Account({ onToggleCollapse, onProfile, onAppearance, } = {}) {
54
54
  // @ts-expect-error — ref is forwarded to the DOM node by the primitive.
55
55
  , {
56
56
  // @ts-expect-error — ref is forwarded to the DOM node by the primitive.
57
- ref: holder, position: "relative", borderTopWidth: 1, borderColor: "$borderColor", children: [open ? (_jsxs(YStack, { role: "menu", "aria-label": "Account", position: "absolute", b: "calc(100% + 6px)", l: "$2", r: "$2", rounded: "$3", borderWidth: 1, borderColor: "$borderColor", ...sheet(2), py: "$2", gap: "$1", z: "var(--z-modal)", children: [_jsx(Row, { icon: _jsx(UserRound, { size: 15, "aria-hidden": true }), onPress: act(() => (onProfile ? onProfile() : window.location.assign(link(`${iam()}/account`)))), children: "Profile" }), onAppearance ? (_jsx(Row, { icon: _jsx(Palette, { size: 15, "aria-hidden": true }), onPress: act(onAppearance), children: "Appearance" })) : null, _jsx(Row, { icon: _jsx(Shield, { size: 15, "aria-hidden": true }), onPress: act(() => window.location.assign(link(`${iam()}/account/security`))), children: "Security" }), _jsx(Box, { height: 1, bg: "$borderColor", my: "$1" }), _jsx(Row, { icon: _jsx(LogOut, { size: 15, "aria-hidden": true }), onPress: act(() => void logout()), children: "Sign out" })] })) : null, _jsxs(XStack, { items: "center", width: "100%", children: [_jsx(Box, { render: "button", onClick: () => setOpen((v) => !v), "aria-expanded": open, "aria-haspopup": "menu", "aria-label": "Account", flex: 1, minW: 0, px: "$3", py: "$2", hoverStyle: { bg: '$hover' }, children: _jsxs(XStack, { items: "center", gap: "$2", minW: 0, children: [_jsx(Face, { src: face, name: called, size: 22 }), _jsxs(YStack, { flex: 1, minW: 0, items: "flex-start", children: [_jsx(Text, { fontSize: "$2", fontWeight: "600", color: "$color", numberOfLines: 1, children: called }), email && email !== called ? (_jsx(Text, { fontSize: "$1", color: "$soft", numberOfLines: 1, children: email })) : null] }), _jsx(ChevronsUpDown, { size: 14, "aria-hidden": true })] }) }), onToggleCollapse ? (_jsx(Box, { render: "button", onClick: onToggleCollapse, "aria-label": "Collapse sidebar", p: "$2", mr: "$1.5", rounded: "$2", hoverStyle: { bg: '$hover' }, cursor: "pointer", children: _jsx(PanelLeftClose, { size: 16, "aria-hidden": true }) })) : null] })] }));
57
+ ref: holder, position: "relative", borderTopWidth: 1, borderColor: "$borderColor", children: [open ? (_jsxs(YStack, { role: "menu", "aria-label": "Account", position: "absolute", b: "calc(100% + 6px)", l: "$2", r: "$2", rounded: "$3", borderWidth: 1, borderColor: "$borderColor", ...sheet(2), py: "$2", gap: "$1", z: "var(--z-modal)", children: [_jsx(Row, { icon: _jsx(UserRound, { size: 15, "aria-hidden": true }), onPress: act(() => (onProfile ? onProfile() : window.location.assign(link(`${iam()}/account`)))), children: "Profile" }), onAppearance ? (_jsx(Row, { icon: _jsx(Palette, { size: 15, "aria-hidden": true }), onPress: act(onAppearance), children: "Appearance" })) : null, onUsage ? (_jsx(Row, { icon: _jsx(Activity, { size: 15, "aria-hidden": true }), onPress: act(onUsage), children: "Usage" })) : null, _jsx(Row, { icon: _jsx(Shield, { size: 15, "aria-hidden": true }), onPress: act(() => window.location.assign(link(`${iam()}/account/security`))), children: "Security" }), _jsx(Box, { height: 1, bg: "$borderColor", my: "$1" }), _jsx(Row, { icon: _jsx(LogOut, { size: 15, "aria-hidden": true }), onPress: act(() => void logout()), children: "Sign out" })] })) : null, _jsxs(XStack, { items: "center", width: "100%", children: [_jsx(Box, { render: "button", onClick: () => setOpen((v) => !v), "aria-expanded": open, "aria-haspopup": "menu", "aria-label": "Account", flex: 1, minW: 0, px: "$3", py: "$2", hoverStyle: { bg: '$hover' }, children: _jsxs(XStack, { items: "center", gap: "$2", minW: 0, children: [_jsx(Face, { src: face, name: called, size: 22 }), _jsxs(YStack, { flex: 1, minW: 0, items: "flex-start", children: [_jsx(Text, { fontSize: "$2", fontWeight: "600", color: "$color", numberOfLines: 1, children: called }), email && email !== called ? (_jsx(Text, { fontSize: "$1", color: "$soft", numberOfLines: 1, children: email })) : null] }), _jsx(ChevronsUpDown, { size: 14, "aria-hidden": true })] }) }), onToggleCollapse ? (_jsx(Box, { render: "button", onClick: onToggleCollapse, "aria-label": "Collapse sidebar", p: "$2", mr: "$1.5", rounded: "$2", hoverStyle: { bg: '$hover' }, cursor: "pointer", children: _jsx(PanelLeftClose, { size: 16, "aria-hidden": true }) })) : null] })] }));
58
58
  }
package/dist/Chat.js CHANGED
@@ -28,7 +28,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
28
28
  // belongs in the thread where the reader can see it, not in a caption that
29
29
  // pre-emptively refuses on the gateway's behalf.
30
30
  import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
31
- import { capped, detail, familyOf, planRequired, plainly, refused } from "./failure.js";
31
+ import { billed, detail, familyOf, planRequired, plainly, refused } from "./failure.js";
32
32
  import { Prose } from "./Prose.js";
33
33
  import { useAgent, useAi, useChat, useModels, } from "@hanzo/ai/react";
34
34
  import { freeCopy, grantConsent, hasConsent, isFree, } from "@hanzo/ai";
@@ -44,9 +44,9 @@ 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, clock, sku, useLimits } from "./lib/limits.js";
47
+ import { chats, sku, useLimits } from "./lib/limits.js";
48
+ import { LimitedBanner } from "@hanzo/ui/product/LimitedBanner";
48
49
  import { WEB } from "./lib/web.js";
49
- import { Meters } from "./meters.js";
50
50
  import { useHydrated } from "./lib/hydrated.js";
51
51
  import { base, served } from "./lib/ai.js";
52
52
  import { onServed } from "./lib/served.js";
@@ -63,7 +63,7 @@ import { useStarred } from "./stars.js";
63
63
  import { useModel } from "./model.js";
64
64
  import { Enso, nameOf, useEffort } from "./enso.js";
65
65
  import { checkoutUrl } from './lib/pay.js';
66
- import { onFree, planName } from './lib/plans.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';
@@ -527,10 +527,14 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
527
527
  const offered = useMemo(() => models.filter((m) => sku(m.id)), [models]);
528
528
  const wanted = sku(preferred) ? preferred : ENSO;
529
529
  const model = persona?.model || served(models, anonymous ? FREE : wanted);
530
- // WHAT THE PLAN HAS LEFT, as shares of the session, the day and the month
531
- // (lib/hanzo/limits.ts). Read again whenever a send settles, below.
532
- const { limits, reload: reread } = useLimits(!anonymous, org());
530
+ // THE PLAN'S USAGE (lib/limits.ts), kept current by every chat answer's
531
+ // headers and read again whenever a send settles, below. The room draws no
532
+ // bars and no near note — those live on Settings → Usage. It says something
533
+ // only once the reader is turned away: a reply answered from a free model, or
534
+ // a refusal.
535
+ const { limits, notice, reload: reread } = useLimits(!anonymous, org(), (id) => nameOf(catalog, id));
533
536
  const held = Boolean(limits?.plan);
537
+ const pause = notice && (notice.fallback || notice.refused) ? notice : null;
534
538
  // A MODEL THE FREE PLAN DOES NOT INCLUDE IS ASKED FOR, NOT CHOSEN. On Free,
535
539
  // picking a model the plan's row does not run (`onFree`) leaves the choice
536
540
  // where it was and opens the upgrade, titled with the model; so does a turn
@@ -971,12 +975,11 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
971
975
  const whom = addressed(text, room);
972
976
  const wrong = failed?.error ?? null;
973
977
  const how = wrong ? refusalOf(wrong) : null;
974
- // A SPENT WINDOW is the plan holder's own limit, said with its reset and,
975
- // below the top plan, the next plan's checkout. Asking again before the
976
- // reset meets the same answer, so it offers no Try again.
977
- const cap = wrong ? capped(wrong) : null;
978
- const next = cap?.href ? planName(limits?.upgrade) : null;
979
- const resign = !cap && !!wrong && (refused(wrong) || /audience not allowed|invalid access token/i.test(wrong.message));
978
+ // A BILLING REFUSAL is said in the gateway's words; its way past —
979
+ // Upgrade, Add prepaid credit — is the banner over the composer. The free
980
+ // plan's cap answers the same until it resets, so it offers no Try again.
981
+ const bill = wrong ? billed(wrong) : null;
982
+ const resign = !bill && !!wrong && (refused(wrong) || /audience not allowed|invalid access token/i.test(wrong.message));
980
983
  const lift = free ? planRequired(wrong) : null;
981
984
  const said = !failed
982
985
  ? "No answer came back for this question."
@@ -984,8 +987,8 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
984
987
  ? `${nameOf(models, model)} sent nothing for ${STALL / 60_000} minutes, so the turn was stopped.`
985
988
  : failed.empty
986
989
  ? `${nameOf(models, model)} answered with nothing.`
987
- : cap
988
- ? `You've used ${cap.limit === "session" ? "this session's" : "today's"} limit.${cap.resets ? ` Resets at ${clock(cap.resets)}.` : ""}`
990
+ : bill
991
+ ? notice?.message ?? (bill.message || plainly(wrong, "this conversation"))
989
992
  : resign
990
993
  ? anonymous
991
994
  ? "Sign in to ask this."
@@ -993,7 +996,7 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
993
996
  : how?.provider
994
997
  ? `${familyOf(model) || nameOf(models, model)} is unavailable right now.`
995
998
  : plainly(wrong, "this conversation");
996
- return (_jsx(Failure, { onRetry: resign || cap ? undefined : () => speak(text, question.id), children: _jsxs(YStack, { gap: "$2", children: [_jsxs(Text, { fontSize: "$3", color: "$ink", children: [whom.length ? `No answer from ${join(whom)}. ` : "", said] }), how?.provider && wrong ? _jsx(Why, { text: detail(wrong) }) : null, cap?.href || resign || lift || how?.wallet || how?.visitor ? (_jsx(XStack, { gap: "$2", flexWrap: "wrap", children: cap?.href ? (_jsx(Anchor, { href: cap.href, children: _jsx(Button, { size: "sm", variant: "outline", children: next ? `Upgrade to ${next}` : "Upgrade" }) })) : resign ? (_jsx(Button, { size: "sm", onClick: () => {
999
+ return (_jsx(Failure, { onRetry: resign || bill?.code === "free_plan_cap" ? undefined : () => speak(text, question.id), children: _jsxs(YStack, { gap: "$2", children: [_jsxs(Text, { fontSize: "$3", color: "$ink", children: [whom.length ? `No answer from ${join(whom)}. ` : "", said] }), how?.provider && wrong ? _jsx(Why, { text: detail(wrong) }) : null, !bill && (resign || lift || how?.wallet || how?.visitor) ? (_jsx(XStack, { gap: "$2", flexWrap: "wrap", children: resign ? (_jsx(Button, { size: "sm", onClick: () => {
997
1000
  if (typeof window !== "undefined") {
998
1001
  localStorage.removeItem("hanzo_iam_access_token");
999
1002
  localStorage.removeItem("hanzo_iam_refresh_token");
@@ -1194,7 +1197,7 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
1194
1197
  },
1195
1198
  // WHAT GOES WITH THE MESSAGE, above the frame: one chip a file, each
1196
1199
  // with its own remove, and the reason any file was refused.
1197
- head: files.length || turnedAway || preparing || talk.open || talk.refusal ? (_jsxs(YStack, { width: "calc(100% - 32px)", maxW: `calc(${MEASURE} - 3rem)`, mx: "auto", gap: "$1.5", children: [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
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
1198
1201
  ? REFUSED.lost
1199
1202
  : talk.reply
1200
1203
  ? `Hanzo: ${talk.reply}`
@@ -1206,9 +1209,6 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
1206
1209
  listening: "In conversation — click to hang up",
1207
1210
  speaking: "Hanzo is speaking — talk to interrupt",
1208
1211
  }, children: () => _jsx(AudioLines, { size: 15 }) }) }), _jsx(Control, { type: "button", size: ROUND, fill: true, "data-slot": "composer-send", "aria-label": chat.streaming ? "Stop" : "Send", opacity: chat.streaming || draft.trim() || files.length ? 1 : 0.4, onClick: () => (chat.streaming ? chat.stop() : submit()), children: chat.streaming ? (_jsx(Square, { size: 13, fill: "currentColor" })) : (_jsx(ArrowUp, { size: 15, strokeWidth: 2.5 })) })] })),
1209
- // WHAT THE PLAN HAS LEFT, under the frame and at its measure, for a
1210
- // plan holder only: Free keeps the free lane's own notice.
1211
- foot: held && limits && (limits.session || limits.day || limits.month) ? (_jsx(YStack, { width: "calc(100% - 32px)", maxW: `calc(${MEASURE} - 3rem)`, mx: "auto", children: _jsx(Meters, { limits: limits }) })) : undefined,
1212
1212
  } }), _jsx(Crew, {}), isFree(model) ? (_jsx(XStack, { items: "center", justify: "center", py: "$1", px: "$3", borderTopWidth: 1, borderColor: "$borderColor", bg: "$background", children: _jsxs(Text, { fontSize: "$1", color: "$soft", children: ["Free chat is data-shared to train models. ", _jsx(Anchor, { href: site("/privacy"), fontSize: "$1", color: "$ink", children: "Privacy" })] }) })) : null, consent, _jsx(Upgrade, { ask: ask, onClose: () => setAsk(null) })] }));
1213
1213
  }
1214
1214
  /** The agreement free asks for, in @hanzo/ai's words so every surface says one thing. */
package/dist/Column.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  // The app's one column, the same in both modes: the Chat / Dev switch, a filter,
4
4
  // New, Dev's places, the Recents of the mode it is in, and at the foot the
5
- // Slack card and the account (components/workspace/Me.tsx).
5
+ // account (components/workspace/Me.tsx).
6
6
  //
7
7
  // It is a pane on the frame's ground (app/_web.tsx), sized by the edge in the
8
8
  // gutter to its right: dragged, keyed or double-clicked back (@hanzo/build's
@@ -11,7 +11,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
11
11
  // choice outlives the move.
12
12
  import { Text, XStack, YStack } from '@hanzo/gui';
13
13
  import { ChevronDown, ChevronUp, PanelLeft, Plus, Search } from 'lucide-react';
14
- import { Grip, nav, path, route, Slack } from '@hanzo/build';
14
+ import { Grip, nav, route } from '@hanzo/build';
15
15
  import { Sidebar, SidebarIconButton, SidebarItem } from '@hanzo/ui/chat';
16
16
  import { HanzoMark } from '@hanzogui/shell';
17
17
  import { useState } from 'react';
@@ -54,7 +54,6 @@ const ROW = { rounded: '$4', minH: 34 };
54
54
  export function Column({ host, mode, thread, onChat, onDev, go, drawer = false, }) {
55
55
  const [kept, setCollapsed] = useKept('hanzo.build.rail', false);
56
56
  const collapsed = kept && !drawer;
57
- const [slack, setSlack] = useKept('hanzo.build.slack', false);
58
57
  const [unfolded, setUnfolded] = useState(false);
59
58
  const [words, setWords] = useState('');
60
59
  const span = useSpan(SPAN, WIDTH, FLOOR, CEIL);
@@ -83,7 +82,7 @@ export function Column({ host, mode, thread, onChat, onDev, go, drawer = false,
83
82
  setWords('');
84
83
  }, placeholder: "Filter", "aria-label": "Filter recents" }), flex: 1, minW: 0, bg: "transparent", borderWidth: 0, outlineStyle: "none", color: "inherit", fontSize: "$2" })] }), _jsxs(YStack, { role: "list", mt: "$2.5", gap: 2, shrink: 0, children: [_jsx(YStack, { role: "listitem", children: _jsx(SidebarItem, { "data-slot": "app-new", ...ROW, active: fresh, icon: _jsx(Plus, { size: 16, "aria-hidden": true }), onPress: create, ...keys(create), children: mode === 'chat' ? 'New chat' : 'New run' }) }), mode === 'dev'
85
84
  ? links.map((link) => (_jsx(YStack, { role: "listitem", children: _jsx(SidebarItem, { ...ROW, active: link.active, icon: link.icon, onPress: link.onPress, ...keys(() => link.onPress?.()), children: link.label }) }, link.id)))
86
- : null, mode === 'dev' ? (_jsxs(YStack, { role: "listitem", children: [_jsx(SidebarItem, { ...ROW, "aria-expanded": unfolded, icon: unfolded ? _jsx(ChevronUp, { size: 16, "aria-hidden": true }) : _jsx(ChevronDown, { size: 16, "aria-hidden": true }), onPress: () => setUnfolded((v) => !v), ...keys(() => setUnfolded((v) => !v)), children: "More" }), unfolded ? (_jsx(YStack, { role: "list", gap: 2, children: more.map((link) => (_jsx(YStack, { role: "listitem", children: _jsx(SidebarItem, { ...ROW, active: link.active, icon: link.icon, onPress: link.onPress, ...keys(() => link.onPress?.()), children: link.label }) }, link.id))) })) : null] })) : null] }), _jsx(XStack, { height: 1, bg: "$borderColor", mx: "$1.5", mt: "$3", shrink: 0 }), _jsx(Recents, { host: host, mode: mode, thread: thread, words: words, onOpen: open }), _jsxs(YStack, { gap: "$2", pt: "$2", shrink: 0, children: [_jsx(Made, {}), _jsx(Setup, {}), host.person && !slack ? (_jsx(YStack, { "data-slot": "app-slack", children: _jsx(Slack, { onOpen: () => dev(path({ kind: 'settings', section: 'integrations' })), onDismiss: () => setSlack(true) }) })) : null] }), _jsxs(XStack, { "data-slot": "app-foot", items: "center", gap: "$1", pt: "$2", shrink: 0, children: [_jsx(YStack, { flex: 1, minW: 0, children: _jsx(Me, { host: host, onOrg: org }) }), drawer ? null : (_jsx(SidebarIconButton, { label: "Collapse sidebar", onPress: () => setCollapsed(true), width: 32, height: 32, rounded: 999, children: _jsx(PanelLeft, { size: 16 }) }))] })] }), drawer ? null : (_jsx(Grip, { side: "left", span: span.span, floor: FLOOR, ceil: CEIL, reset: WIDTH, onSpan: span.move, onKeep: span.keep, onShut: () => setCollapsed(true), label: "Resize sidebar",
85
+ : null, mode === 'dev' ? (_jsxs(YStack, { role: "listitem", children: [_jsx(SidebarItem, { ...ROW, "aria-expanded": unfolded, icon: unfolded ? _jsx(ChevronUp, { size: 16, "aria-hidden": true }) : _jsx(ChevronDown, { size: 16, "aria-hidden": true }), onPress: () => setUnfolded((v) => !v), ...keys(() => setUnfolded((v) => !v)), children: "More" }), unfolded ? (_jsx(YStack, { role: "list", gap: 2, children: more.map((link) => (_jsx(YStack, { role: "listitem", children: _jsx(SidebarItem, { ...ROW, active: link.active, icon: link.icon, onPress: link.onPress, ...keys(() => link.onPress?.()), children: link.label }) }, link.id))) })) : null] })) : null] }), _jsx(XStack, { height: 1, bg: "$borderColor", mx: "$1.5", mt: "$3", shrink: 0 }), _jsx(Recents, { host: host, mode: mode, thread: thread, words: words, onOpen: open }), _jsxs(YStack, { gap: "$2", pt: "$2", shrink: 0, children: [_jsx(Made, {}), _jsx(Setup, {})] }), _jsxs(XStack, { "data-slot": "app-foot", items: "center", gap: "$1", pt: "$2", shrink: 0, children: [_jsx(YStack, { flex: 1, minW: 0, children: _jsx(Me, { host: host, onOrg: org }) }), drawer ? null : (_jsx(SidebarIconButton, { label: "Collapse sidebar", onPress: () => setCollapsed(true), width: 32, height: 32, rounded: 999, children: _jsx(PanelLeft, { size: 16 }) }))] })] }), drawer ? null : (_jsx(Grip, { side: "left", span: span.span, floor: FLOOR, ceil: CEIL, reset: WIDTH, onSpan: span.move, onKeep: span.keep, onShut: () => setCollapsed(true), label: "Resize sidebar",
87
86
  // In the gutter between the column and the pane, not over the column's own rows.
88
87
  r: GUTTER_END, width: "var(--pane-gap)" }))] }));
89
88
  }
package/dist/Profile.js CHANGED
@@ -3,7 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  // The reader's own profile, in the column beside the room.
4
4
  //
5
5
  // A WORKSPACE OPENS A PROFILE IN A PANEL, not a page. The page exists — it is
6
- // /account, and it is where the whole record is edited — but reaching it from a
6
+ // Settings (/settings), where the whole record is edited — but reaching it from a
7
7
  // conversation costs the conversation: the room, the sidebar and the rail all
8
8
  // go, and coming back is a navigation rather than a close. Slack, Teams and
9
9
  // every app shaped like them put this in the right column for that reason, and
@@ -26,7 +26,7 @@ import { mark } from './lib/mark.js';
26
26
  import { BAD } from './lib/mix.js';
27
27
  /** Slack's is 380 and the thread panel that shares this slot is the same. */
28
28
  const WIDTH = 380;
29
- const ACCOUNT = '/account';
29
+ const ACCOUNT = '/settings';
30
30
  export function Profile({ open, onClose }) {
31
31
  const { Link } = useRooms();
32
32
  const { user, updateUserProfile } = useAccount();
package/dist/Settings.js CHANGED
@@ -39,7 +39,7 @@ import { planName } from './lib/plans.js';
39
39
  import { api } from './lib/api.js';
40
40
  import { renewal, useSubscription, useTier } from './lib/tier.js';
41
41
  import { sku, spendShown, useLimits } from './lib/limits.js';
42
- import { Meters } from './meters.js';
42
+ import { PlanUsage } from '@hanzo/ui/product/PlanUsage';
43
43
  import { Look } from './look.js';
44
44
  import { ProviderMark } from './ProviderMark.js';
45
45
  import { org, pick } from './lib/session.js';
@@ -232,7 +232,8 @@ function Billing() {
232
232
  /**
233
233
  * The one usage panel every Hanzo product renders, reading as this account — for a
234
234
  * reader with no plan. A plan holder's usage is the plan's shares (`meters` draws
235
- * them where usage is the subject), never money (spendShown).
235
+ * them, with the plan's terms and its actions, where usage is the subject), never
236
+ * money (spendShown).
236
237
  */
237
238
  function Usage({ sections, title, meters = false }) {
238
239
  const { accessToken, isAuthenticated } = useIam();
@@ -240,15 +241,15 @@ function Usage({ sections, title, meters = false }) {
240
241
  if (!isAuthenticated || !accessToken) {
241
242
  return _jsx(Text, { fontSize: "$3", color: "$soft", children: "Sign in to see your usage." });
242
243
  }
243
- if (read.limits?.plan)
244
- return meters ? _jsx(Meters, { limits: read.limits, large: true }) : null;
245
- if (!spendShown(read))
246
- return null;
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))
246
+ return plan;
247
247
  // The address is read HERE rather than at import: the panel sends the bearer
248
248
  // with every read, and a credentialed read to an absolute address from a page
249
249
  // the gateway does not admit dies in preflight — which drew this panel as an
250
250
  // account that had spent nothing.
251
- return _jsx(UsagePanel, { baseUrl: api(), token: accessToken, sections: sections, title: title, upgrade: payPage() });
251
+ const spend = _jsx(UsagePanel, { baseUrl: api(), token: accessToken, sections: sections, title: title, upgrade: payPage() });
252
+ return plan ? (_jsxs(YStack, { gap: "$6", children: [plan, spend] })) : (spend);
252
253
  }
253
254
  /** Keys are minted and revoked through the console, which holds the one issuer. */
254
255
  function Keys() {
package/dist/Shell.js CHANGED
@@ -1221,7 +1221,7 @@ function Frame({ mode, children, navigate, back, forward, }) {
1221
1221
  placeholder: "Filter", "aria-label": "Filter sidebar" }), flex: 1, minW: 0, bg: "transparent", borderWidth: 0, outlineStyle: "none", color: "inherit", fontSize: "$2" })] }) }), settingsPane ? (_jsx(SidebarScroll, { children: _jsx(SettingsList, { active: settingsPane, filter: filter, onPick: pick, onBack: () => showSettings(null) }) })) : (_jsx(_Fragment, { children: _jsx(SidebarScroll, { children: _jsxs(_Fragment, { children: [settled ? (_jsx(Index, { mode: mode, owner: owner, filter: filter, onPick: onPick, navigate: navigate })) : null, products.length ? (_jsx(SidebarFolder, { name: "Cloud & Platform", open: filter ? true : undefined, children: products.map((prod) => (_jsx(SidebarItem, { icon: _jsx(prod.icon, { size: 13, "aria-hidden": true }), onPress: () => window.open(prod.href, "_blank", "noopener"), children: prod.label }, prod.id))) })) : null, more.length ? (_jsx(SidebarFolder, { name: "More", open: filter ? true : undefined, children: more.map((tool) => (_jsx(SidebarItem, { icon: _jsx(tool.icon, { size: 13, "aria-hidden": true }), onPress: tool.go, children: tool.label }, tool.id))) })) : null] }) }) })), settled && onApex ? _jsx(Leave, { href: signedIn ? ENTRY : WORKSPACE, children: "Workspace" }) : null, settled ? (signedIn ? (_jsxs(_Fragment, { children: [superAdmin() ? (_jsx(SidebarItem, { icon: _jsx(Building2, { size: 15, "aria-hidden": true }), onPress: () => setSupporting(true), children: "All organizations" })) : null, _jsx(SidebarItem, { icon: _jsx(UserPlus, { size: 15, "aria-hidden": true }), onPress: () => window.open(invite(), "_blank", "noopener"), children: "Invite Teammates" }), _jsx(SidebarItem, { icon: _jsx(Share2, { size: 15, "aria-hidden": true }), onPress: () => {
1222
1222
  navigate?.(site("/referral"));
1223
1223
  dismiss();
1224
- }, children: "Share Hanzo" }), _jsx(SidebarItem, { icon: _jsx(Sparkles, { size: 15, "aria-hidden": true }), onPress: () => window.open(BILLING, "_blank", "noopener"), children: "Upgrade" }), _jsx(Account, { onToggleCollapse: toggle, onProfile: () => setProfiling(true), onAppearance: () => showSettings("general") })] })) : null) : null] }));
1224
+ }, children: "Share Hanzo" }), _jsx(SidebarItem, { icon: _jsx(Sparkles, { size: 15, "aria-hidden": true }), onPress: () => window.open(BILLING, "_blank", "noopener"), children: "Upgrade" }), _jsx(Account, { onToggleCollapse: toggle, onProfile: () => setProfiling(true), onAppearance: () => showSettings("general"), onUsage: () => showSettings("usage") })] })) : null) : null] }));
1225
1225
  return (_jsxs(YStack, { "data-slot": "workspace", "data-hydrated": settled ? "true" : undefined, "data-sidebar": asked ? "open" : "shut", display: "grid", gridTemplateColumns: `${asked ? 0 : 64}px auto minmax(0, 1fr) auto`, gridTemplateRows: "44px minmax(0, 1fr)", gridTemplateAreas: '"rail bar bar bar" "rail list pane side"', "$max-md": {
1226
1226
  gridTemplateColumns: "minmax(0, 1fr)",
1227
1227
  gridTemplateRows: `44px minmax(0, 1fr) ${TABS_HEIGHT}`,
package/dist/failure.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type Refusal } from '@hanzo/ui/product/limits';
1
2
  /** Whether the store answered and the answer was no — exported so a surface can
2
3
  * offer the way past a refusal rather than only describing it. */
3
4
  export declare const refused: (error: unknown) => boolean;
@@ -33,24 +34,12 @@ export declare function plainly(error: unknown, subject: string): string;
33
34
  export declare function planRequired(error: unknown): {
34
35
  href?: string;
35
36
  } | null;
36
- /** A plan holder's spent window. */
37
- export interface Cap {
38
- limit: 'session' | 'day';
39
- /** When it opens again, RFC 3339. */
40
- resets: string | null;
41
- /** The next plan's checkout, on hanzo.ai only. Absent at the top plan. */
42
- href?: string;
43
- }
44
37
  /**
45
- * Whether a refusal is a plan's usage limit, and which window.
46
- *
47
- * A paid plan is a subscription with limits: a spent session or day comes back
48
- * 429 `usage_cap_exceeded` naming the window, its reset and, below the top
49
- * plan, an `upgrade_url`. The month refuses nothing — Enso and Zen keep
50
- * answering — so no other window is a cap. The code decides, never the
51
- * sentence, and the address is kept by the rule `planRequired` keeps.
38
+ * A billing refusal in the gateway's envelope — the plan's included usage
39
+ * spent, a paid plan required, the free plan's cap, or no balance — or null for
40
+ * any other failure. The code decides, never the sentence.
52
41
  */
53
- export declare function capped(error: unknown): Cap | null;
42
+ export declare function billed(error: unknown): Refusal | null;
54
43
  /** The family a model id belongs to, as a reader knows it, or '' for one outside them. */
55
44
  export declare const familyOf: (id: string) => string;
56
45
  /** What the server said about a failure, for the detail folded under the plain line. */
package/dist/failure.js CHANGED
@@ -9,6 +9,7 @@
9
9
  //
10
10
  // ONE FUNCTION, because every room owes the same three answers and three copies
11
11
  // would drift into three vocabularies for one event.
12
+ import { refusalOf } from '@hanzo/ui/product/limits';
12
13
  import { told } from './lib/reach.js';
13
14
  /**
14
15
  * THE STATUS, WHERE THE FAILURE CARRIES ONE.
@@ -109,21 +110,15 @@ export function planRequired(error) {
109
110
  return { href: ours(said?.upgrade_url) };
110
111
  }
111
112
  /**
112
- * Whether a refusal is a plan's usage limit, and which window.
113
- *
114
- * A paid plan is a subscription with limits: a spent session or day comes back
115
- * 429 `usage_cap_exceeded` naming the window, its reset and, below the top
116
- * plan, an `upgrade_url`. The month refuses nothing — Enso and Zen keep
117
- * answering — so no other window is a cap. The code decides, never the
118
- * sentence, and the address is kept by the rule `planRequired` keeps.
113
+ * A billing refusal in the gateway's envelope — the plan's included usage
114
+ * spent, a paid plan required, the free plan's cap, or no balance — or null for
115
+ * any other failure. The code decides, never the sentence.
119
116
  */
120
- export function capped(error) {
121
- const said = error?.body?.error;
122
- if (said?.code !== 'usage_cap_exceeded' || status(error) !== 429)
123
- return null;
124
- if (said.limit !== 'session' && said.limit !== 'day')
117
+ export function billed(error) {
118
+ const code = status(error);
119
+ if (code !== 402 && code !== 429)
125
120
  return null;
126
- return { limit: said.limit, resets: typeof said.resets_at === 'string' ? said.resets_at : null, href: ours(said.upgrade_url) };
121
+ return refusalOf(error?.body, code);
127
122
  }
128
123
  function ours(href) {
129
124
  if (typeof href !== 'string')
@@ -1,50 +1,25 @@
1
- /** One window: the percent of it used, and when it starts over. */
2
- export interface Span {
3
- /** Used, 0–100. */
4
- percent: number;
5
- /** RFC 3339, or null when no window is running — a session starts at the next request. */
6
- resets_at: string | null;
7
- }
8
- export interface Limits {
9
- /** The plan slug ("max-20x"), or "" for a caller with no plan. */
10
- plan: string;
11
- period_start?: string;
12
- period_end?: string;
13
- session?: Span;
14
- day?: Span;
15
- month?: Span;
16
- /** The next plan's slug, "" at the top of the ladder. */
17
- upgrade?: string;
18
- }
19
1
  /**
20
- * The answer, checked field by field: it is a network response. A body without
21
- * a string `plan` is not an answer at all; a window without a numeric percent
22
- * is left out rather than drawn as full or empty.
2
+ * The plan's usage, as @hanzo/ui holds it: `GET /v1/ai/limits` read as whoever
3
+ * holds the bearer in this browser's organization, kept current by every AI
4
+ * call `observed` (lib/served.ts) hands to `observe`. Shares only.
5
+ *
6
+ * Same base URL and headers as every billing read (lib/hanzo/tier.ts): the
7
+ * bearer is the subject and `X-Org-Id` the organization this browser works in.
23
8
  */
24
- export declare function limitsOf(body: unknown): Limits | null;
9
+ import { type UseLimits } from '@hanzo/ui/product/useLimits';
25
10
  /** Reads the limits as whoever holds the bearer, in this browser's organization. */
26
- export declare function readLimits(signal?: AbortSignal): Promise<Limits>;
11
+ export declare function readLimits(signal: AbortSignal): Promise<unknown>;
27
12
  /**
28
- * The limits, held, and the way to read them again. `limits` is `null` until
29
- * answered, and after a refusal: a reader whose limits are unknown is drawn no
30
- * bars. `answered` says the first read has come back either way, so a surface
31
- * that shows something only to a reader with no plan waits for it.
13
+ * The limits for a signed-in reader, in organization `org`; `name` turns a
14
+ * fallback model id into the name the reader knows. Signed out reads nothing,
15
+ * and still hears a refusal from a served call.
32
16
  */
33
- export declare function useLimits(enabled?: boolean, org?: string | null): {
34
- limits: Limits | null;
35
- answered: boolean;
36
- reload: () => void;
37
- };
17
+ export declare const useLimits: (enabled?: boolean, org?: string | null, name?: (id: string) => string) => UseLimits;
38
18
  /**
39
19
  * Whether a reader's dollar spend may be shown: their limits came back and name no
40
20
  * plan, or could not be read. A plan holder sees usage as shares, never money.
41
21
  */
42
- export declare const spendShown: ({ limits, answered }: {
43
- limits: Limits | null;
44
- answered: boolean;
45
- }) => boolean;
46
- /** What is left of a window, 0–100: the bar goes down as it is used. */
47
- export declare const left: (s: Span) => number;
22
+ export declare const spendShown: ({ limits, answered }: Pick<UseLimits, 'limits' | 'answered'>) => boolean;
48
23
  /** Whether a model is a Hanzo SKU — an Enso or Zen id — the only models a consumer surface offers. */
49
24
  export declare const sku: (id: string) => boolean;
50
25
  /**
@@ -56,9 +31,3 @@ export declare const chats: (model: {
56
31
  id: string;
57
32
  outputs?: unknown;
58
33
  }) => boolean;
59
- /** A reset as a clock reads it in this browser — "17:00" — or '' for none. */
60
- export declare function clock(iso: string | null | undefined): string;
61
- /** A period's end as a sentence states it — "Oct 30, 2026" — or '' for none. Periods end on UTC days. */
62
- export declare function date(iso: string | null | undefined): string;
63
- /** A period's end without its year — "Oct 30" — for the month bar. */
64
- export declare function day(iso: string | null | undefined): string;
@@ -1,105 +1,33 @@
1
1
  'use client';
2
2
  /**
3
- * WHAT A PLAN HOLDER HAS LEFT, AS A SHARE.
4
- *
5
- * A paid plan is a subscription with usage limits, never a debit from a
6
- * balance. `GET /v1/ai/limits` answers the acting org's plan and three
7
- * windows — the five-hour session, the day, and the billing month — each as
8
- * the percent USED with the moment it resets, and the next plan up. Shares
9
- * only: nothing here is a count of requests or an amount of money.
10
- *
11
- * The month is the plan's included usage for the period. Spending it refuses
12
- * nothing — Enso and Zen keep answering from free models — so it is a line
13
- * under the bars, never a refusal. The session and the day refuse when spent.
14
- *
15
- * `plan` "" is a caller with no plan (Free), and then nothing else is sent:
16
- * that reader keeps the Free display. A read that fails is `null`, which is not
17
- * knowing, and nothing is drawn from it.
3
+ * The plan's usage, as @hanzo/ui holds it: `GET /v1/ai/limits` read as whoever
4
+ * holds the bearer in this browser's organization, kept current by every AI
5
+ * call `observed` (lib/served.ts) hands to `observe`. Shares only.
18
6
  *
19
7
  * Same base URL and headers as every billing read (lib/hanzo/tier.ts): the
20
8
  * bearer is the subject and `X-Org-Id` the organization this browser works in.
21
9
  */
22
- import { useCallback, useEffect, useState } from 'react';
10
+ import { useLimits as useHeld } from '@hanzo/ui/product/useLimits';
23
11
  import { api } from './api.js';
24
12
  import { scope } from './session.js';
25
- const text = (v) => (typeof v === 'string' ? v : null);
26
- function span(v) {
27
- const o = v;
28
- const p = o?.percent;
29
- if (typeof p !== 'number' || !Number.isFinite(p))
30
- return undefined;
31
- return { percent: Math.min(100, Math.max(0, Math.round(p))), resets_at: text(o?.resets_at) };
32
- }
33
- /**
34
- * The answer, checked field by field: it is a network response. A body without
35
- * a string `plan` is not an answer at all; a window without a numeric percent
36
- * is left out rather than drawn as full or empty.
37
- */
38
- export function limitsOf(body) {
39
- const o = body;
40
- const plan = text(o?.plan);
41
- if (plan === null)
42
- return null;
43
- if (plan === '')
44
- return { plan };
45
- return {
46
- plan,
47
- period_start: text(o?.period_start) ?? undefined,
48
- period_end: text(o?.period_end) ?? undefined,
49
- session: span(o?.session),
50
- day: span(o?.day),
51
- month: span(o?.month),
52
- upgrade: text(o?.upgrade) ?? '',
53
- };
54
- }
55
13
  /** Reads the limits as whoever holds the bearer, in this browser's organization. */
56
14
  export async function readLimits(signal) {
57
15
  const res = await fetch(`${api()}/v1/ai/limits`, { headers: scope(), signal });
58
16
  if (!res.ok)
59
17
  throw Object.assign(new Error(`Limits answered ${res.status}`), { status: res.status });
60
- const got = limitsOf(await res.json());
61
- if (!got)
62
- throw Object.assign(new Error('Limits answered without a plan'), { status: 502 });
63
- return got;
18
+ return res.json();
64
19
  }
65
20
  /**
66
- * The limits, held, and the way to read them again. `limits` is `null` until
67
- * answered, and after a refusal: a reader whose limits are unknown is drawn no
68
- * bars. `answered` says the first read has come back either way, so a surface
69
- * that shows something only to a reader with no plan waits for it.
21
+ * The limits for a signed-in reader, in organization `org`; `name` turns a
22
+ * fallback model id into the name the reader knows. Signed out reads nothing,
23
+ * and still hears a refusal from a served call.
70
24
  */
71
- export function useLimits(enabled = true, org = null) {
72
- const [limits, setLimits] = useState(null);
73
- const [answered, setAnswered] = useState(false);
74
- const [nonce, setNonce] = useState(0);
75
- useEffect(() => {
76
- if (!enabled)
77
- return;
78
- const stop = new AbortController();
79
- readLimits(stop.signal)
80
- .then((got) => {
81
- if (!stop.signal.aborted)
82
- setLimits(got);
83
- })
84
- .catch(() => {
85
- if (!stop.signal.aborted)
86
- setLimits(null);
87
- })
88
- .finally(() => {
89
- if (!stop.signal.aborted)
90
- setAnswered(true);
91
- });
92
- return () => stop.abort();
93
- }, [enabled, nonce, org]);
94
- return { limits, answered, reload: useCallback(() => setNonce((n) => n + 1), []) };
95
- }
25
+ export const useLimits = (enabled = true, org = null, name) => useHeld(enabled ? readLimits : null, org, name);
96
26
  /**
97
27
  * Whether a reader's dollar spend may be shown: their limits came back and name no
98
28
  * plan, or could not be read. A plan holder sees usage as shares, never money.
99
29
  */
100
30
  export const spendShown = ({ limits, answered }) => answered && !limits?.plan;
101
- /** What is left of a window, 0–100: the bar goes down as it is used. */
102
- export const left = (s) => 100 - s.percent;
103
31
  /** Whether a model is a Hanzo SKU — an Enso or Zen id — the only models a consumer surface offers. */
104
32
  export const sku = (id) => /^(hanzo\/)?(enso|zen)/i.test(id);
105
33
  /**
@@ -108,18 +36,3 @@ export const sku = (id) => /^(hanzo\/)?(enso|zen)/i.test(id);
108
36
  * Zen ids the catalog lists, and neither answers a question.
109
37
  */
110
38
  export const chats = (model) => !Array.isArray(model.outputs) || model.outputs.includes('text');
111
- /** A reset as a clock reads it in this browser — "17:00" — or '' for none. */
112
- export function clock(iso) {
113
- const t = iso ? Date.parse(iso) : NaN;
114
- return Number.isNaN(t) ? '' : new Date(t).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' });
115
- }
116
- /** A period's end as a sentence states it — "Oct 30, 2026" — or '' for none. Periods end on UTC days. */
117
- export function date(iso) {
118
- const t = iso ? Date.parse(iso) : NaN;
119
- return Number.isNaN(t) ? '' : new Date(t).toLocaleDateString('en-US', { timeZone: 'UTC', year: 'numeric', month: 'short', day: 'numeric' });
120
- }
121
- /** A period's end without its year — "Oct 30" — for the month bar. */
122
- export function day(iso) {
123
- const t = iso ? Date.parse(iso) : NaN;
124
- return Number.isNaN(t) ? '' : new Date(t).toLocaleDateString('en-US', { timeZone: 'UTC', month: 'short', day: 'numeric' });
125
- }
@@ -7,7 +7,8 @@
7
7
  * neither, so the site's client is built on `observed(fetch)`, which reads
8
8
  * them off each completion as it passes and tells the listeners — the header
9
9
  * when the gateway exposes it, else the first frame that names a model. Every
10
- * byte is passed through untouched.
10
+ * byte is passed through untouched. Each chat answer's usage headers, and a
11
+ * billing refusal's envelope, go to @hanzo/ui's `observe`.
11
12
  */
12
13
  /** A completion's asked and answering models. */
13
14
  export interface Served {
@@ -7,8 +7,10 @@
7
7
  * neither, so the site's client is built on `observed(fetch)`, which reads
8
8
  * them off each completion as it passes and tells the listeners — the header
9
9
  * when the gateway exposes it, else the first frame that names a model. Every
10
- * byte is passed through untouched.
10
+ * byte is passed through untouched. Each chat answer's usage headers, and a
11
+ * billing refusal's envelope, go to @hanzo/ui's `observe`.
11
12
  */
13
+ import { observe } from '@hanzo/ui/product/useLimits';
12
14
  const listeners = new Set();
13
15
  /** Hears every served completion from now on; answers the unsubscribe. */
14
16
  export function onServed(listener) {
@@ -23,11 +25,17 @@ const tell = (heard) => {
23
25
  };
24
26
  /** The completion route, whatever host the client was built for. */
25
27
  const COMPLETION = /\/v1\/chat\/completions(?:[?#]|$)/;
28
+ /** Every chat route whose answer carries the plan's usage headers or a billing refusal. */
29
+ const CHAT = /\/v1\/chat\/(?:completions|public)(?:[?#]|$)/;
26
30
  /** A fetch that tells `onServed` listeners which model answered each completion. */
27
31
  export function observed(base) {
28
32
  return async (input, init) => {
29
33
  const res = await base(input, init);
30
34
  const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
35
+ // The plan's usage rides every chat answer and every refusal: the limits
36
+ // hear both before anything else reads the body.
37
+ if (CHAT.test(url))
38
+ observe(res);
31
39
  if (!COMPLETION.test(url) || !res.ok)
32
40
  return res;
33
41
  let asked = '';