@hanzo/rooms 0.1.38 → 0.1.40

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/pane.d.ts CHANGED
@@ -1,19 +1,10 @@
1
1
  import { type Api, type WorkFile } from './lib/files.js';
2
- /** One open tab in the pane's browser. */
3
- export interface Leaf {
4
- id: string;
5
- url: string;
6
- /** What the strip shows: host and path, which is all we can read across an
7
- * origin we do not own. A frame's real title is not ours to ask for. */
8
- title: string;
9
- }
10
2
  /**
11
3
  * A file put into this conversation, on its way to the workspace or already
12
4
  * there, waiting to go with the next message.
13
5
  *
14
- * `href` is an object URL and is the reason this is never written down — see
15
- * the note at the top. A name and a size are the file system's, or the
16
- * workspace file's, never a guess.
6
+ * `href` is an object URL and is the reason this is never written down. A name
7
+ * and a size are the file system's, or the workspace file's, never a guess.
17
8
  */
18
9
  export interface Held {
19
10
  id: string;
@@ -37,13 +28,8 @@ export interface Via {
37
28
  api: Api;
38
29
  org: string | null;
39
30
  }
40
- /** Everything the column beside one conversation is holding. */
31
+ /** What one conversation holds for its next message. */
41
32
  export interface Pane {
42
- tabs: Leaf[];
43
- /** The chosen tab, or null when this conversation has opened none. */
44
- at: string | null;
45
- /** Whether the summary column stands beside the browser. */
46
- pinned: boolean;
47
33
  held: Held[];
48
34
  /** Why the last files offered were refused, in the reader's words, or null. */
49
35
  refused: string | null;
@@ -55,52 +41,13 @@ export interface Pane {
55
41
  * an inbox room arrives over a transport and is keyed by it, a thread is a
56
42
  * conversation the org has recorded, and an agent is who you are talking to
57
43
  * before a thread exists. Prefixed so a room called `x` and a thread called `x`
58
- * cannot land on one entry.
59
- *
60
- * `new` is a real answer and not a fallback: a room nobody has spoken in yet is
61
- * somewhere a reader can open tabs, and they should still be there when the
62
- * first turn mints a thread. It is the ONE key that several conversations pass
63
- * through, which is the honest cost of not having an id to key on yet.
44
+ * cannot land on one entry. `new` is a room nobody has spoken in yet.
64
45
  */
65
46
  export declare function channel(open: {
66
47
  room: string | null;
67
48
  thread: string | null;
68
49
  agent: string | null;
69
50
  }): string;
70
- /** The host and path of an address, which is as much as a title can honestly be. */
71
- export declare function name(url: string): string;
72
- /**
73
- * An address as typed, made into one that can be opened.
74
- *
75
- * A bare host is `https`, because that is the web now; a loopback name is
76
- * `http`, because nothing is listening on 443 of a laptop and offering a
77
- * reader a URL that cannot connect is worse than guessing.
78
- */
79
- export declare function address(typed: string): string;
80
- /**
81
- * Opens an address in this conversation, and chooses it.
82
- *
83
- * `called` is for the things that HAVE a name of their own — an answer's
84
- * `index.html`, a file off the reader's disk — because their address is a blob
85
- * and `name()` can read nothing useful out of one. Without it the host and path
86
- * are the honest title.
87
- */
88
- export declare function openTab(key: string, url: string, called?: string): void;
89
- /** Points an already-open tab somewhere else. */
90
- export declare function goTab(key: string, id: string, url: string): void;
91
- /** Chooses an open tab. */
92
- export declare function pickTab(key: string, id: string): void;
93
- /**
94
- * Shuts a tab.
95
- *
96
- * THE NEIGHBOUR IS THE ONE TO ITS RIGHT, falling back to its left — what every
97
- * browser does, and the only choice that does not send the reader somewhere
98
- * they were not looking. Closing the last tab leaves none chosen rather than
99
- * refusing, because a pane with no tabs is a state this panel draws.
100
- */
101
- export declare function shutTab(key: string, id: string): void;
102
- /** Shows or hides the summary column beside this conversation's browser. */
103
- export declare function pin(key: string, on: boolean): void;
104
51
  /** The workspace file `id` as the index last said, or undefined until it has. */
105
52
  export declare function useWorkFile(id: string | undefined): WorkFile | undefined;
106
53
  /** The object URL over workspace file `id`'s bytes on this machine, or '' where this document never held them. */
package/dist/pane.js CHANGED
@@ -1,29 +1,18 @@
1
1
  'use client';
2
- // What the column beside a conversation is holding, PER CONVERSATION.
2
+ // What a conversation is holding for its next message, PER CONVERSATION: the
3
+ // files on their way to the workspace and why any were refused. The panel's
4
+ // tabs are @hanzo/build's (`useDeck`), kept per conversation under the same key
5
+ // (`channel`).
3
6
  //
4
7
  // The store lives outside React for the reason `open.ts` gives: the router
5
8
  // mounts the rooms, so anything kept inside one is destroyed by a route change.
6
- // This differs from `open.ts` in the one way that matters — IT PERSISTS. A
7
- // selection is WHERE YOU ARE and a fresh document is rightly a fresh room; a
8
- // pane is WHAT YOU HAD OPEN, and losing four tabs to a reload is the reader
9
- // losing work rather than losing their place.
10
- //
11
- // ONE ENTRY PER CONVERSATION, not one blob holding them all. A blob is read in
12
- // full at boot and rewritten in full on every change, so ten conversations make
13
- // nine channels' tabs the cost of moving one tab in the tenth. Keyed
14
- // separately, both costs are proportional to the conversation you are actually
15
- // in, and a corrupt entry loses one pane rather than all of them.
16
- //
17
- // WHAT IS KEPT AND WHAT IS NOT. Tabs, the chosen tab and the pin survive a
18
- // reload because they are addresses and flags. Files picked off the reader's
19
- // own disk DO NOT: a file handle is an object URL, which the browser revokes
20
- // with the document that minted it, so a persisted row would come back as a
21
- // name pointing at nothing. A row that looks like a file and opens nothing is
22
- // worse than no row, so they are held for the session and no longer.
9
+ // It is NOT written down: a file picked off the reader's own disk is an object
10
+ // URL, which the browser revokes with the document that minted it, so a kept
11
+ // row would come back as a name pointing at nothing.
23
12
  //
24
13
  // ONE WAY IN FOR A FILE. The composer's paperclip, a drop on the room, a paste
25
- // into the field and the column's `+` all call `hold`, so a file goes up by one
26
- // road (`lib/files`: straight to the org's workspace, in parts when it is
14
+ // into the field and the Sources tab's `+` all call `hold`, so a file goes up by
15
+ // one road (`lib/files`: straight to the org's workspace, in parts when it is
27
16
  // large), shows as a chip over the field and a row under Sources at once, and
28
17
  // goes with the next message by reference. Sending spends what is held; from
29
18
  // then on the turn itself is the record Sources reads.
@@ -31,8 +20,7 @@ import { useSyncExternalStore } from 'react';
31
20
  import { chatKey, follow, pool, register, settled, upload, workspace } from './lib/files.js';
32
21
  /** A pane nobody has touched. Shared, and never mutated — see `EMPTY` in
33
22
  * `open.ts` for why a fresh object per read is an infinite render. */
34
- const BARE = { tabs: [], at: null, pinned: true, held: [], refused: null };
35
- const where = (key) => `hanzo.pane.${key}`;
23
+ const BARE = { held: [], refused: null };
36
24
  /**
37
25
  * The conversation a pane belongs to.
38
26
  *
@@ -40,12 +28,7 @@ const where = (key) => `hanzo.pane.${key}`;
40
28
  * an inbox room arrives over a transport and is keyed by it, a thread is a
41
29
  * conversation the org has recorded, and an agent is who you are talking to
42
30
  * before a thread exists. Prefixed so a room called `x` and a thread called `x`
43
- * cannot land on one entry.
44
- *
45
- * `new` is a real answer and not a fallback: a room nobody has spoken in yet is
46
- * somewhere a reader can open tabs, and they should still be there when the
47
- * first turn mints a thread. It is the ONE key that several conversations pass
48
- * through, which is the honest cost of not having an id to key on yet.
31
+ * cannot land on one entry. `new` is a room nobody has spoken in yet.
49
32
  */
50
33
  export function channel(open) {
51
34
  if (open.room)
@@ -56,9 +39,6 @@ export function channel(open) {
56
39
  return `agent:${open.agent}`;
57
40
  return 'new';
58
41
  }
59
- // The panes this document has actually touched. A switch back reads from here
60
- // rather than re-parsing JSON, so moving between two conversations costs one
61
- // read each and nothing after that.
62
42
  const live = new Map();
63
43
  const listeners = new Set();
64
44
  function announce() {
@@ -71,145 +51,19 @@ function subscribe(l) {
71
51
  listeners.delete(l);
72
52
  };
73
53
  }
74
- /**
75
- * The pane for a conversation, reading it in the first time it is asked for.
76
- *
77
- * A BAD ENTRY IS DROPPED, NOT THROWN. Storage holds whatever an older build
78
- * wrote, and one unparseable pane must not take the room down with it — the
79
- * reader loses some tabs, which is what they would have had anyway.
80
- */
81
- function read(key) {
82
- const found = live.get(key);
83
- if (found)
84
- return found;
85
- let pane = BARE;
86
- try {
87
- const raw = typeof window === 'undefined' ? null : window.localStorage.getItem(where(key));
88
- if (raw) {
89
- const kept = JSON.parse(raw);
90
- const tabs = Array.isArray(kept.tabs)
91
- ? kept.tabs.filter((t) => !!t && typeof t.id === 'string' && typeof t.url === 'string' && typeof t.title === 'string')
92
- : [];
93
- pane = {
94
- tabs,
95
- // A chosen tab that is no longer open is not a choice. Restoring one
96
- // leaves the strip with nothing marked and the panel drawing a tab that
97
- // is not in it.
98
- at: tabs.some((t) => t.id === kept.at) ? kept.at : (tabs[0]?.id ?? null),
99
- pinned: kept.pinned !== false,
100
- held: [],
101
- refused: null,
102
- };
103
- }
104
- }
105
- catch {
106
- pane = BARE;
107
- }
108
- live.set(key, pane);
109
- return pane;
110
- }
111
- /** The server renders no storage, so every pane starts bare there. Returning
112
- * the shared object keeps the snapshot stable across reads. */
54
+ const read = (key) => live.get(key) ?? BARE;
55
+ /** The server renders nothing held, so every pane starts bare there. */
113
56
  const onServer = () => BARE;
114
57
  function write(key, next) {
115
58
  live.set(key, next);
116
- try {
117
- if (typeof window === 'undefined')
118
- return;
119
- const kept = { tabs: next.tabs, at: next.at, pinned: next.pinned };
120
- window.localStorage.setItem(where(key), JSON.stringify(kept));
121
- }
122
- catch {
123
- // A browser refusing to store — private mode, a full quota — is not a
124
- // reason to refuse the change on screen. The pane stands for this document.
125
- }
126
59
  announce();
127
60
  }
128
61
  /** Applies a change to one conversation's pane. */
129
62
  function edit(key, change) {
130
63
  write(key, change(read(key)));
131
64
  }
132
- /** The host and path of an address, which is as much as a title can honestly be. */
133
- export function name(url) {
134
- try {
135
- const u = new URL(url);
136
- return u.host + (u.pathname === '/' ? '' : u.pathname);
137
- }
138
- catch {
139
- return url;
140
- }
141
- }
142
- /**
143
- * An address as typed, made into one that can be opened.
144
- *
145
- * A bare host is `https`, because that is the web now; a loopback name is
146
- * `http`, because nothing is listening on 443 of a laptop and offering a
147
- * reader a URL that cannot connect is worse than guessing.
148
- */
149
- export function address(typed) {
150
- const t = typed.trim();
151
- if (!t)
152
- return '';
153
- if (/^[a-z][a-z0-9+.-]*:/i.test(t))
154
- return t;
155
- return /^(localhost|127\.0\.0\.1|\[::1\])(:|\/|$)/i.test(t) ? `http://${t}` : `https://${t}`;
156
- }
157
65
  let minted = 0;
158
66
  const mint = (prefix) => `${prefix}${Date.now().toString(36)}${(minted++).toString(36)}`;
159
- /**
160
- * Opens an address in this conversation, and chooses it.
161
- *
162
- * `called` is for the things that HAVE a name of their own — an answer's
163
- * `index.html`, a file off the reader's disk — because their address is a blob
164
- * and `name()` can read nothing useful out of one. Without it the host and path
165
- * are the honest title.
166
- */
167
- export function openTab(key, url, called) {
168
- const at = address(url);
169
- if (!at)
170
- return;
171
- edit(key, (was) => {
172
- const leaf = { id: mint('t'), url: at, title: called || name(at) };
173
- return { ...was, tabs: [...was.tabs, leaf], at: leaf.id };
174
- });
175
- }
176
- /** Points an already-open tab somewhere else. */
177
- export function goTab(key, id, url) {
178
- const at = address(url);
179
- if (!at)
180
- return;
181
- edit(key, (was) => ({
182
- ...was,
183
- tabs: was.tabs.map((t) => (t.id === id ? { ...t, url: at, title: name(at) } : t)),
184
- }));
185
- }
186
- /** Chooses an open tab. */
187
- export function pickTab(key, id) {
188
- edit(key, (was) => (was.at === id ? was : { ...was, at: id }));
189
- }
190
- /**
191
- * Shuts a tab.
192
- *
193
- * THE NEIGHBOUR IS THE ONE TO ITS RIGHT, falling back to its left — what every
194
- * browser does, and the only choice that does not send the reader somewhere
195
- * they were not looking. Closing the last tab leaves none chosen rather than
196
- * refusing, because a pane with no tabs is a state this panel draws.
197
- */
198
- export function shutTab(key, id) {
199
- edit(key, (was) => {
200
- const gone = was.tabs.findIndex((t) => t.id === id);
201
- if (gone < 0)
202
- return was;
203
- const tabs = was.tabs.filter((t) => t.id !== id);
204
- if (was.at !== id)
205
- return { ...was, tabs };
206
- return { ...was, tabs, at: (tabs[gone] ?? tabs[gone - 1])?.id ?? null };
207
- });
208
- }
209
- /** Shows or hides the summary column beside this conversation's browser. */
210
- export function pin(key, on) {
211
- edit(key, (was) => (was.pinned === on ? was : { ...was, pinned: on }));
212
- }
213
67
  // THE WORKSPACE FILES THIS DOCUMENT HAS SEEN, by id, as the index last said.
214
68
  // A chip over the field and a chip in a sent turn read one record, so a file
215
69
  // that finishes indexing after the message went shows ready in both.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanzo/rooms",
3
- "version": "0.1.38",
3
+ "version": "0.1.40",
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",
@@ -49,13 +49,12 @@
49
49
  "peerDependencies": {
50
50
  "@hanzo/ai": ">=0.6.23",
51
51
  "@hanzo/appearance": ">=0.2.6",
52
- "@hanzo/build": ">=0.2.34",
52
+ "@hanzo/build": ">=0.2.53",
53
53
  "@hanzo/composer": ">=0.2.0",
54
54
  "@hanzo/event": ">=0.3.56",
55
55
  "@hanzo/gui": "^8.3.5",
56
56
  "@hanzo/iam": ">=0.21.28",
57
57
  "@hanzo/logo": ">=1.0.26",
58
- "@hanzo/plans": ">=1.8.9",
59
58
  "@hanzo/ui": ">=8.27.67",
60
59
  "@hanzo/usage": ">=0.1.18",
61
60
  "@hanzo/voice": ">=0.1.16",
@@ -67,14 +66,13 @@
67
66
  "devDependencies": {
68
67
  "@hanzo/ai": "0.6.23",
69
68
  "@hanzo/appearance": "0.2.8",
70
- "@hanzo/build": "0.2.34",
69
+ "@hanzo/build": "0.2.53",
71
70
  "@hanzo/composer": "0.2.0",
72
71
  "@hanzo/design": "0.5.34",
73
72
  "@hanzo/event": "0.3.56",
74
73
  "@hanzo/gui": "8.3.5",
75
74
  "@hanzo/iam": "0.21.28",
76
75
  "@hanzo/logo": "1.0.26",
77
- "@hanzo/plans": "1.8.9",
78
76
  "@hanzo/ui": "8.27.67",
79
77
  "@hanzo/usage": "0.1.18",
80
78
  "@hanzo/voice": "0.1.16",
package/dist/Billing.d.ts DELETED
@@ -1 +0,0 @@
1
- export declare function Billing(): import("react").JSX.Element;
package/dist/Billing.js DELETED
@@ -1,32 +0,0 @@
1
- 'use client';
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- /**
4
- * WHAT THE WORKSPACE BOUGHT, AND WHAT IT MAY SPEND — as Billing, not as the
5
- * person's card. Credits belong to the workspace (an org-level pool), so they
6
- * are read here and pressed through to billing at hanzo.ai/pay for the org this
7
- * workspace is.
8
- *
9
- * NOTHING HERE IS DRAWN FROM A GUESS. A figure shows only when the read
10
- * SUCCEEDED: a balance that could not be read is unknown, and unknown is not
11
- * zero, so it renders as the plain word rather than as `$0.00`.
12
- */
13
- import { Wallet } from 'lucide-react';
14
- import { Box, Text, XStack } from '@hanzo/ui';
15
- import { useIam } from '@hanzo/iam/react';
16
- import { formatCents } from '@hanzo/usage';
17
- import { renewal, useSubscription, useTier } from './lib/tier.js';
18
- import { payPage } from './lib/pay.js';
19
- import { planName } from './lib/plans.js';
20
- export function Billing() {
21
- const { isAuthenticated } = useIam();
22
- const { tier } = useTier(isAuthenticated);
23
- const sub = useSubscription(isAuthenticated);
24
- if (!isAuthenticated)
25
- return null;
26
- // WHAT THE CUSTOMER BOUGHT, not the coarser class it is served under: a
27
- // Max 20x subscriber runs on `pro` limits, and printing that read as "Pro".
28
- // A subscription is named with its term; its usage never draws the balance,
29
- // so the balance stands beside a plan only where there is no subscription.
30
- const plan = tier ? planName(tier.plan) || tier.tier.displayName || tier.tier.name : null;
31
- return (_jsx(Box, { render: "button", onClick: () => window.open(payPage(), '_blank', 'noopener'), "aria-label": "Billing", px: "$2.5", py: "$1.5", rounded: "$2", hoverStyle: { bg: '$hover' }, children: _jsxs(XStack, { items: "center", gap: "$2", children: [_jsx(Wallet, { size: 15, "aria-hidden": true }), _jsx(Text, { fontSize: "$2", color: "$ink", children: plan ? `${plan} · ${sub ? renewal(sub) : `${formatCents(tier.balance.effectiveAvailable)} available`}` : 'Billing' })] }) }));
32
- }
package/dist/enso.d.ts DELETED
@@ -1,72 +0,0 @@
1
- import { type ModelCatalogEntry, type PauseSource } from '@hanzo/ui/models';
2
- /**
3
- * How hard to think — and, one press inside, what thinks.
4
- *
5
- * EFFORT IS ON THE BAR AND THE MODEL IS NOT. That is the product's position
6
- * rather than a layout preference: Enso routes the ask, so naming a model is an
7
- * override and belongs inside this panel, not on the row where it asked every
8
- * reader to hold an opinion about a catalogue before they could type a
9
- * sentence.
10
- *
11
- * Effort earns the bar because it belongs to the ASK. The same person wants a
12
- * fast answer to one question and a careful one to the next; the model they
13
- * want rarely changes between two messages, which is what makes it a
14
- * preference and this a control.
15
- *
16
- * `reasoning_effort` is an OpenAI-compatible completion parameter and rides
17
- * `useChat`'s `params`, so on /chat it reaches the API. A surface that cannot
18
- * carry it passes no `onEffort` and the chip names the router instead — the
19
- * coding plane takes a `model` and has no effort field, and a control that
20
- * reaches nothing is worse than one that is absent.
21
- */
22
- export declare const EFFORTS: readonly [{
23
- readonly id: 'minimal';
24
- readonly label: 'Instant';
25
- readonly note: 'Answer now, no deliberation';
26
- }, {
27
- readonly id: 'low';
28
- readonly label: 'Low';
29
- readonly note: 'A quick pass';
30
- }, {
31
- readonly id: 'medium';
32
- readonly label: 'Medium';
33
- readonly note: 'Thinks before it writes';
34
- }, {
35
- readonly id: 'high';
36
- readonly label: 'High';
37
- readonly note: 'Slow and careful, for hard problems';
38
- }];
39
- export type EffortId = (typeof EFFORTS)[number]['id'];
40
- /**
41
- * The effort preference, shared by every surface that sends a completion.
42
- *
43
- * Same shape as `useModel` — one storage key, one change event, read on mount
44
- * rather than in the initialiser so the server and the first client render
45
- * agree and hydration does not warn.
46
- */
47
- export declare function useEffort(): readonly [EffortId, (next: EffortId) => void];
48
- /** The name a served model goes by: its row's label, its name, or its id made readable. */
49
- export declare function nameOf(models: readonly {
50
- id: string;
51
- }[], id: string): string;
52
- export interface EnsoProps {
53
- /** Current effort. Omit `onEffort` where the surface cannot send one. */
54
- effort?: EffortId;
55
- onEffort?: (next: EffortId) => void;
56
- /** The override. `models` empty hides it rather than offering an empty menu. */
57
- model?: string;
58
- onModel?: (id: string) => void;
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;
63
- disabled?: boolean;
64
- }
65
- /**
66
- * The chip that sits beside send, and the panel it opens.
67
- *
68
- * The chip names what answers the next message: Enso while Enso routes it
69
- * (`ENSO`, or `FREE` on the free route), and the model itself once one is
70
- * picked. Beside the name it states the effort where the surface sends one.
71
- */
72
- export declare function Enso({ effort, onEffort, model, onModel, models, limits, disabled }: EnsoProps): import("react").JSX.Element;
package/dist/enso.js DELETED
@@ -1,112 +0,0 @@
1
- 'use client';
2
- import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { useEffect, useState } from 'react';
4
- import { Check, ChevronDown } from 'lucide-react';
5
- import { Box, Popover, PopoverContent, PopoverTrigger, Separator, Text, XStack, YStack, } from '@hanzo/ui';
6
- import { ModelPicker } from '@hanzo/ui/models';
7
- import { ProviderMark } from './ProviderMark.js';
8
- import { ENSO, FREE } from './lib/ai.js';
9
- import { modelName } from './lib/models.js';
10
- /**
11
- * How hard to think — and, one press inside, what thinks.
12
- *
13
- * EFFORT IS ON THE BAR AND THE MODEL IS NOT. That is the product's position
14
- * rather than a layout preference: Enso routes the ask, so naming a model is an
15
- * override and belongs inside this panel, not on the row where it asked every
16
- * reader to hold an opinion about a catalogue before they could type a
17
- * sentence.
18
- *
19
- * Effort earns the bar because it belongs to the ASK. The same person wants a
20
- * fast answer to one question and a careful one to the next; the model they
21
- * want rarely changes between two messages, which is what makes it a
22
- * preference and this a control.
23
- *
24
- * `reasoning_effort` is an OpenAI-compatible completion parameter and rides
25
- * `useChat`'s `params`, so on /chat it reaches the API. A surface that cannot
26
- * carry it passes no `onEffort` and the chip names the router instead — the
27
- * coding plane takes a `model` and has no effort field, and a control that
28
- * reaches nothing is worse than one that is absent.
29
- */
30
- export const EFFORTS = [
31
- { id: 'minimal', label: 'Instant', note: 'Answer now, no deliberation' },
32
- { id: 'low', label: 'Low', note: 'A quick pass' },
33
- { id: 'medium', label: 'Medium', note: 'Thinks before it writes' },
34
- { id: 'high', label: 'High', note: 'Slow and careful, for hard problems' },
35
- ];
36
- const DEFAULT = 'medium';
37
- const KEY = 'hanzo.chat.effort';
38
- const CHANGE = 'hanzo:effort';
39
- const read = () => {
40
- if (typeof window === 'undefined')
41
- return DEFAULT;
42
- try {
43
- const held = window.localStorage.getItem(KEY);
44
- return EFFORTS.some((e) => e.id === held) ? held : DEFAULT;
45
- }
46
- catch {
47
- return DEFAULT;
48
- }
49
- };
50
- /**
51
- * The effort preference, shared by every surface that sends a completion.
52
- *
53
- * Same shape as `useModel` — one storage key, one change event, read on mount
54
- * rather than in the initialiser so the server and the first client render
55
- * agree and hydration does not warn.
56
- */
57
- export function useEffort() {
58
- const [effort, setEffort] = useState(DEFAULT);
59
- useEffect(() => {
60
- setEffort(read());
61
- const sync = () => setEffort(read());
62
- window.addEventListener('storage', sync);
63
- window.addEventListener(CHANGE, sync);
64
- return () => {
65
- window.removeEventListener('storage', sync);
66
- window.removeEventListener(CHANGE, sync);
67
- };
68
- }, []);
69
- const choose = (next) => {
70
- setEffort(next);
71
- try {
72
- window.localStorage.setItem(KEY, next);
73
- }
74
- catch {
75
- // A private browser may refuse persistence; this tab still keeps the choice.
76
- }
77
- window.dispatchEvent(new Event(CHANGE));
78
- };
79
- return [effort, choose];
80
- }
81
- /** The name a served model goes by: its row's label, its name, or its id made readable. */
82
- export function nameOf(models, id) {
83
- const row = models.find((m) => m.id === id);
84
- return modelName({ id, name: String(row?.label ?? row?.name ?? '') });
85
- }
86
- /**
87
- * The chip that sits beside send, and the panel it opens.
88
- *
89
- * The chip names what answers the next message: Enso while Enso routes it
90
- * (`ENSO`, or `FREE` on the free route), and the model itself once one is
91
- * picked. Beside the name it states the effort where the surface sends one.
92
- */
93
- export function Enso({ effort, onEffort, model, onModel, models = [], limits, disabled }) {
94
- const [open, setOpen] = useState(false);
95
- const chosen = EFFORTS.find((e) => e.id === effort);
96
- const pace = onEffort && chosen ? chosen.label : null;
97
- const picked = model && model !== ENSO && model !== FREE ? model : undefined;
98
- const name = picked ? nameOf(models, picked) : 'Enso';
99
- // The panel opens above the chip when there is no room below it, so the model
100
- // picker at its foot stays on screen on a laptop-height window. It is SOLID:
101
- // it stands over a transcript that streams, which would read through glass,
102
- // and the model list it opens inherits the same ground.
103
- return (_jsxs(Popover, { open: open, onOpenChange: setOpen, allowFlip: true, children: [_jsx(PopoverTrigger, { disabled: disabled, children: _jsxs(XStack, { "data-slot": "enso", items: "center", gap: "$1.5", px: "$2.5", height: 32, maxW: 220, rounded: "$10", borderWidth: 1, borderColor: "$borderColor", hoverStyle: { bg: '$hover' }, opacity: disabled ? 0.4 : 1, "aria-label": pace ? `${name}: ${pace}` : name, children: [_jsx(ProviderMark, { provider: "enso", model: picked, size: 14 }), _jsx(Text, { fontSize: "$1", color: "$ink", fontWeight: "600", numberOfLines: 1, children: name }), pace ? (_jsx(Text, { fontSize: "$1", color: "$soft", fontWeight: "500", children: pace })) : null, _jsx(ChevronDown, { size: 12, "aria-hidden": true })] }) }), _jsx(PopoverContent, { solid: true, align: "end", p: 6, width: 268, children: _jsxs(YStack, { gap: "$1", children: [onEffort
104
- ? EFFORTS.map((e) => (_jsx(Box, { render: "button", onClick: () => {
105
- onEffort(e.id);
106
- setOpen(false);
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(ModelPicker, { models: models, limits: limits, value: model, onChange: (id) => {
109
- onModel(id);
110
- setOpen(false);
111
- }, size: "sm" })] })] })) : null] }) })] }));
112
- }
package/dist/model.d.ts DELETED
@@ -1,4 +0,0 @@
1
- /** Make `model` the chat default, in this browser, for every open chat. */
2
- export declare function remember(model: string): void;
3
- /** The signed-in chat default, shared by Settings and every new conversation. */
4
- export declare function useModel(): readonly [string, (model: string) => void];
package/dist/model.js DELETED
@@ -1,44 +0,0 @@
1
- "use client";
2
- import { useEffect, useState } from "react";
3
- import { ENSO } from "./lib/ai.js";
4
- const KEY = "hanzo.chat.model";
5
- const CHANGE = "hanzo:model";
6
- function read() {
7
- if (typeof window === "undefined")
8
- return ENSO;
9
- try {
10
- return window.localStorage.getItem(KEY) || ENSO;
11
- }
12
- catch {
13
- return ENSO;
14
- }
15
- }
16
- /** Make `model` the chat default, in this browser, for every open chat. */
17
- export function remember(model) {
18
- try {
19
- window.localStorage.setItem(KEY, model);
20
- }
21
- catch {
22
- // A private browser may refuse persistence; this tab still keeps the choice.
23
- }
24
- window.dispatchEvent(new Event(CHANGE));
25
- }
26
- /** The signed-in chat default, shared by Settings and every new conversation. */
27
- export function useModel() {
28
- const [model, setModel] = useState(ENSO);
29
- useEffect(() => {
30
- setModel(read());
31
- const sync = () => setModel(read());
32
- window.addEventListener("storage", sync);
33
- window.addEventListener(CHANGE, sync);
34
- return () => {
35
- window.removeEventListener("storage", sync);
36
- window.removeEventListener(CHANGE, sync);
37
- };
38
- }, []);
39
- const choose = (next) => {
40
- setModel(next);
41
- remember(next);
42
- };
43
- return [model, choose];
44
- }