@hanzo/rooms 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (235) hide show
  1. package/LICENSE-APACHE +205 -0
  2. package/LICENSE-MIT +21 -0
  3. package/README.md +23 -0
  4. package/dist/Account.d.ts +8 -0
  5. package/dist/Account.js +58 -0
  6. package/dist/Beluga.d.ts +12 -0
  7. package/dist/Beluga.js +152 -0
  8. package/dist/Billing.d.ts +1 -0
  9. package/dist/Billing.js +31 -0
  10. package/dist/Board.d.ts +9 -0
  11. package/dist/Board.js +447 -0
  12. package/dist/Boxes.d.ts +1 -0
  13. package/dist/Boxes.js +173 -0
  14. package/dist/CalScheduler.d.ts +15 -0
  15. package/dist/CalScheduler.js +196 -0
  16. package/dist/Chat.d.ts +35 -0
  17. package/dist/Chat.js +1049 -0
  18. package/dist/Column.d.ts +15 -0
  19. package/dist/Column.js +88 -0
  20. package/dist/Contacts.d.ts +1 -0
  21. package/dist/Contacts.js +301 -0
  22. package/dist/Directory.d.ts +24 -0
  23. package/dist/Directory.js +356 -0
  24. package/dist/Drive.d.ts +2 -0
  25. package/dist/Drive.js +466 -0
  26. package/dist/Find.d.ts +7 -0
  27. package/dist/Find.js +82 -0
  28. package/dist/Guide.d.ts +6 -0
  29. package/dist/Guide.js +272 -0
  30. package/dist/Home.d.ts +39 -0
  31. package/dist/Home.js +306 -0
  32. package/dist/Inbox.d.ts +9 -0
  33. package/dist/Inbox.js +193 -0
  34. package/dist/Market.d.ts +3 -0
  35. package/dist/Market.js +149 -0
  36. package/dist/Me.d.ts +27 -0
  37. package/dist/Me.js +251 -0
  38. package/dist/Meet.d.ts +9 -0
  39. package/dist/Meet.js +370 -0
  40. package/dist/ModeMark.d.ts +8 -0
  41. package/dist/ModeMark.js +18 -0
  42. package/dist/OrgSwitcher.d.ts +31 -0
  43. package/dist/OrgSwitcher.js +120 -0
  44. package/dist/Orgs.d.ts +10 -0
  45. package/dist/Orgs.js +582 -0
  46. package/dist/Profile.d.ts +4 -0
  47. package/dist/Profile.js +67 -0
  48. package/dist/Prose.d.ts +5 -0
  49. package/dist/Prose.js +111 -0
  50. package/dist/ProviderMark.d.ts +17 -0
  51. package/dist/ProviderMark.js +277 -0
  52. package/dist/Recents.d.ts +10 -0
  53. package/dist/Recents.js +91 -0
  54. package/dist/RightPane.d.ts +13 -0
  55. package/dist/RightPane.js +426 -0
  56. package/dist/Room.d.ts +8 -0
  57. package/dist/Room.js +50 -0
  58. package/dist/Scene.d.ts +12 -0
  59. package/dist/Scene.js +105 -0
  60. package/dist/Settings.d.ts +46 -0
  61. package/dist/Settings.js +321 -0
  62. package/dist/Setup.d.ts +1 -0
  63. package/dist/Setup.js +36 -0
  64. package/dist/Share.d.ts +3 -0
  65. package/dist/Share.js +130 -0
  66. package/dist/Shared.d.ts +1 -0
  67. package/dist/Shared.js +113 -0
  68. package/dist/Shell.d.ts +63 -0
  69. package/dist/Shell.js +1225 -0
  70. package/dist/Start.d.ts +1 -0
  71. package/dist/Start.js +96 -0
  72. package/dist/Support.d.ts +14 -0
  73. package/dist/Support.js +122 -0
  74. package/dist/Talk.d.ts +4 -0
  75. package/dist/Talk.js +107 -0
  76. package/dist/Upgrade.d.ts +11 -0
  77. package/dist/Upgrade.js +75 -0
  78. package/dist/Usage.d.ts +1 -0
  79. package/dist/Usage.js +21 -0
  80. package/dist/Vibe.d.ts +2 -0
  81. package/dist/Vibe.js +188 -0
  82. package/dist/Work.d.ts +12 -0
  83. package/dist/Work.js +256 -0
  84. package/dist/bots/Bots.d.ts +2 -0
  85. package/dist/bots/Bots.js +456 -0
  86. package/dist/bots/Lights.d.ts +8 -0
  87. package/dist/bots/Lights.js +13 -0
  88. package/dist/bots/api.d.ts +120 -0
  89. package/dist/bots/api.js +75 -0
  90. package/dist/bots/face.d.ts +5 -0
  91. package/dist/bots/face.js +13 -0
  92. package/dist/bots/ink.d.ts +7 -0
  93. package/dist/bots/ink.js +29 -0
  94. package/dist/bots/kit.d.ts +113 -0
  95. package/dist/bots/kit.js +43 -0
  96. package/dist/bots/runtime.d.ts +4 -0
  97. package/dist/bots/runtime.js +326 -0
  98. package/dist/card.d.ts +52 -0
  99. package/dist/card.js +52 -0
  100. package/dist/cast.d.ts +143 -0
  101. package/dist/cast.js +251 -0
  102. package/dist/connect.d.ts +29 -0
  103. package/dist/connect.js +64 -0
  104. package/dist/contacts-store.d.ts +111 -0
  105. package/dist/contacts-store.js +276 -0
  106. package/dist/conversations.d.ts +54 -0
  107. package/dist/conversations.js +98 -0
  108. package/dist/copy.d.ts +23 -0
  109. package/dist/copy.js +39 -0
  110. package/dist/crew.d.ts +36 -0
  111. package/dist/crew.js +304 -0
  112. package/dist/dev.d.ts +3 -0
  113. package/dist/dev.js +102 -0
  114. package/dist/enso.d.ts +70 -0
  115. package/dist/enso.js +112 -0
  116. package/dist/failure.d.ts +57 -0
  117. package/dist/failure.js +143 -0
  118. package/dist/ground.d.ts +32 -0
  119. package/dist/ground.js +32 -0
  120. package/dist/host.d.ts +76 -0
  121. package/dist/host.js +76 -0
  122. package/dist/index.d.ts +14 -0
  123. package/dist/index.js +12 -0
  124. package/dist/kept.d.ts +5 -0
  125. package/dist/kept.js +27 -0
  126. package/dist/lib/account.d.ts +49 -0
  127. package/dist/lib/account.js +229 -0
  128. package/dist/lib/ai.d.ts +46 -0
  129. package/dist/lib/ai.js +140 -0
  130. package/dist/lib/api.d.ts +43 -0
  131. package/dist/lib/api.js +62 -0
  132. package/dist/lib/bare.d.ts +9 -0
  133. package/dist/lib/bare.js +7 -0
  134. package/dist/lib/coding.d.ts +105 -0
  135. package/dist/lib/coding.js +87 -0
  136. package/dist/lib/destination.d.ts +102 -0
  137. package/dist/lib/destination.js +260 -0
  138. package/dist/lib/durable.d.ts +66 -0
  139. package/dist/lib/durable.js +107 -0
  140. package/dist/lib/first.d.ts +11 -0
  141. package/dist/lib/first.js +23 -0
  142. package/dist/lib/home.d.ts +61 -0
  143. package/dist/lib/home.js +54 -0
  144. package/dist/lib/host.d.ts +104 -0
  145. package/dist/lib/host.js +132 -0
  146. package/dist/lib/hostname.d.ts +1 -0
  147. package/dist/lib/hostname.js +14 -0
  148. package/dist/lib/hydrated.d.ts +1 -0
  149. package/dist/lib/hydrated.js +20 -0
  150. package/dist/lib/integrations.d.ts +21 -0
  151. package/dist/lib/integrations.js +1 -0
  152. package/dist/lib/intent.d.ts +6 -0
  153. package/dist/lib/intent.js +71 -0
  154. package/dist/lib/invite.d.ts +155 -0
  155. package/dist/lib/invite.js +245 -0
  156. package/dist/lib/limits.d.ts +55 -0
  157. package/dist/lib/limits.js +119 -0
  158. package/dist/lib/list.d.ts +9 -0
  159. package/dist/lib/list.js +11 -0
  160. package/dist/lib/mark.d.ts +13 -0
  161. package/dist/lib/mark.js +85 -0
  162. package/dist/lib/mix.d.ts +22 -0
  163. package/dist/lib/mix.js +22 -0
  164. package/dist/lib/models.d.ts +27 -0
  165. package/dist/lib/models.js +106 -0
  166. package/dist/lib/org.d.ts +109 -0
  167. package/dist/lib/org.js +140 -0
  168. package/dist/lib/path.d.ts +9 -0
  169. package/dist/lib/path.js +29 -0
  170. package/dist/lib/plans.d.ts +193 -0
  171. package/dist/lib/plans.js +310 -0
  172. package/dist/lib/prose.d.ts +13 -0
  173. package/dist/lib/prose.js +62 -0
  174. package/dist/lib/reach.d.ts +17 -0
  175. package/dist/lib/reach.js +70 -0
  176. package/dist/lib/rooms.json +23 -0
  177. package/dist/lib/served.d.ts +20 -0
  178. package/dist/lib/served.js +92 -0
  179. package/dist/lib/session.d.ts +211 -0
  180. package/dist/lib/session.js +493 -0
  181. package/dist/lib/share.d.ts +131 -0
  182. package/dist/lib/share.js +216 -0
  183. package/dist/lib/signout.d.ts +14 -0
  184. package/dist/lib/signout.js +40 -0
  185. package/dist/lib/tags.d.ts +27 -0
  186. package/dist/lib/tags.js +46 -0
  187. package/dist/lib/tap.d.ts +17 -0
  188. package/dist/lib/tap.js +9 -0
  189. package/dist/lib/tier.d.ts +115 -0
  190. package/dist/lib/tier.js +207 -0
  191. package/dist/lib/tiers.d.ts +9 -0
  192. package/dist/lib/tiers.js +141 -0
  193. package/dist/lib/todo.d.ts +125 -0
  194. package/dist/lib/todo.js +379 -0
  195. package/dist/look.d.ts +1 -0
  196. package/dist/look.js +21 -0
  197. package/dist/meters.d.ts +8 -0
  198. package/dist/meters.js +31 -0
  199. package/dist/model.d.ts +4 -0
  200. package/dist/model.js +44 -0
  201. package/dist/network.d.ts +64 -0
  202. package/dist/network.js +216 -0
  203. package/dist/open.d.ts +81 -0
  204. package/dist/open.js +149 -0
  205. package/dist/pane.d.ts +92 -0
  206. package/dist/pane.js +231 -0
  207. package/dist/progress.d.ts +46 -0
  208. package/dist/progress.js +51 -0
  209. package/dist/recent.d.ts +67 -0
  210. package/dist/recent.js +161 -0
  211. package/dist/rooms.d.ts +131 -0
  212. package/dist/rooms.js +195 -0
  213. package/dist/roster.d.ts +28 -0
  214. package/dist/roster.js +70 -0
  215. package/dist/span.d.ts +14 -0
  216. package/dist/span.js +50 -0
  217. package/dist/speech.d.ts +44 -0
  218. package/dist/speech.js +229 -0
  219. package/dist/stars.d.ts +16 -0
  220. package/dist/stars.js +75 -0
  221. package/dist/tabs.d.ts +26 -0
  222. package/dist/tabs.js +25 -0
  223. package/dist/team.d.ts +11 -0
  224. package/dist/team.gen.d.ts +10 -0
  225. package/dist/team.gen.js +11 -0
  226. package/dist/team.js +30 -0
  227. package/dist/tools.d.ts +35 -0
  228. package/dist/tools.js +143 -0
  229. package/dist/tours.d.ts +24 -0
  230. package/dist/tours.js +311 -0
  231. package/dist/waiting.d.ts +15 -0
  232. package/dist/waiting.js +17 -0
  233. package/dist/where.d.ts +54 -0
  234. package/dist/where.js +14 -0
  235. package/package.json +108 -0
package/dist/Chat.js ADDED
@@ -0,0 +1,1049 @@
1
+ "use client";
2
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // /chat's pane: the conversation.
4
+ //
5
+ // `@hanzo/ai` holds the thread and `@hanzo/ui/chat` draws it — `<Chat {...useChat()} />`
6
+ // is one call by design, and the two packages were shaped to make exactly that
7
+ // call typecheck. Nothing about the conversation is written here.
8
+ //
9
+ // WHO CAN SEND: an account. `/v1/agents/chat` answers a signed-out caller
10
+ // "a validated principal is required" — measured on production, with and
11
+ // without the publishable key the bundle ships and with a browser's own Origin
12
+ // — so a visitor's first message comes back refused however the room is drawn.
13
+ // The room says that in the reader's words and offers the way past it; what it
14
+ // must not do is describe an account they do not have.
15
+ //
16
+ // The free MODEL is a separate question and still exists: `anonymous` opens on
17
+ // the free pool, so what changes at sign-in is the menu, not the room.
18
+ //
19
+ // The FREE lane is about PRICE, not identity: a free turn spends no balance and
20
+ // is data-shared in exchange, which is what the consent block asks about, at
21
+ // SEND rather than on arrival — consent is owed when someone sends, not when
22
+ // they walk in. Signing in is what buys the paid models and a history that
23
+ // follows you; it is never the price of asking a question.
24
+ //
25
+ // This file used to branch on `isAuthenticated` and hand a visitor a composer
26
+ // whose only action was a trip to hanzo.id, captioned "Sign in to send". The
27
+ // branch is gone. If the gateway refuses an anonymous free turn, that answer
28
+ // belongs in the thread where the reader can see it, not in a caption that
29
+ // pre-emptively refuses on the gateway's behalf.
30
+ import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
31
+ import { capped, detail, familyOf, planRequired, plainly, refused } from "./failure.js";
32
+ import { Prose } from "./Prose.js";
33
+ import { useAgent, useAi, useChat, useModels, } from "@hanzo/ai/react";
34
+ import { freeCopy, grantConsent, hasConsent, isFree, } from "@hanzo/ai";
35
+ import { ASK, Chat as ChatSurface, Code, Failure } from "@hanzo/ui/chat";
36
+ import { Face, brief as roomBrief, join, roleOf, roomTurn, roster as rosterLine, rosterOf, speakers, speaksOf, voiceOf } from "./cast.js";
37
+ import { SpeakButton, speakAgent } from "./speech.js";
38
+ import { Crew, HOUSE, useCrew } from "./crew.js";
39
+ import { called } from "./team.js";
40
+ import { Anchor, Box, Button, Text, View, XStack, YStack } from "@hanzo/ui";
41
+ import { Control } from "@hanzo/composer";
42
+ import { useIam, useIamToken } from "@hanzo/iam/react";
43
+ import { hasSession, org } from "./lib/session.js";
44
+ import { clock, sku, useLimits } from "./lib/limits.js";
45
+ import { Meters } from "./meters.js";
46
+ import { useHydrated } from "./lib/hydrated.js";
47
+ import { base, served } from "./lib/ai.js";
48
+ import { onServed } from "./lib/served.js";
49
+ import { speech, useVoice, Voice } from "@hanzo/voice";
50
+ import { ArrowUp, Mic, Square, Star, PanelRight, X } from "lucide-react";
51
+ import { ENSO, FREE } from "./lib/ai.js";
52
+ import { openThread, useOpen } from "./open.js";
53
+ import { Beside, Framed } from "./Shell.js";
54
+ import { pane } from "./ground.js";
55
+ import { RightPane, usePinned } from "./RightPane.js";
56
+ import { Take } from "./copy.js";
57
+ import { Share } from "./Share.js";
58
+ import { useStarred } from "./stars.js";
59
+ import { useModel } from "./model.js";
60
+ import { Enso, nameOf, useEffort } from "./enso.js";
61
+ import { checkoutUrl, onFree, planName } from './lib/plans.js';
62
+ import { Upgrade, useFree } from './Upgrade.js';
63
+ import { enter, LOGIN } from './lib/destination.js';
64
+ import { first } from './lib/first.js';
65
+ import { mix } from "./lib/mix.js";
66
+ import { site } from './where.js';
67
+ /** The veil under the free lane's consent sheet. */
68
+ const SCRIM = mix("var(--pure-black)", 72, "srgb");
69
+ // NO REASONING PANEL HERE. Every assistant turn opened a "Thinking" disclosure
70
+ // listing seven steps — "Planning CSS extraction for chat styles", "Designing
71
+ // responsive top bar grid layout" — typed into this file as a literal array.
72
+ // They were the same seven under every answer in every conversation, and they
73
+ // described the writing of this component rather than anything a model did. A
74
+ // reasoning trace belongs to a turn; when the wire carries one it can be drawn
75
+ // from the turn, and until then the answer stands on its own.
76
+ //
77
+ // The queued-message box went with it: a second unmounted component, holding
78
+ // the file's last use of the chevrons imported for the panel.
79
+ /**
80
+ * The room's reading measure: the design system's prose width, which is what
81
+ * the Width setting (@hanzo/appearance) retunes. 48rem is 768px, the width this
82
+ * room has always read at, so an untouched setting changes nothing.
83
+ */
84
+ const MEASURE = "var(--container-prose, 48rem)";
85
+ /**
86
+ * WHAT AN EMPTY ROOM OFFERS.
87
+ *
88
+ * A blank field asks the reader to invent the first move, and the commonest
89
+ * answer to "what can I help with?" is not knowing what to ask for. These are
90
+ * four shapes of question rather than four topics — read the code you have,
91
+ * find the fault in what it printed, plan the thing you have not built, and
92
+ * shorten something long — so between them they say what kind of help this is
93
+ * without claiming a skill nobody verified.
94
+ *
95
+ * Pressing one FILLS the field and does not send it. Each is the opening of a
96
+ * question the reader has to finish with their own material, and sending it as
97
+ * written would ask about nothing.
98
+ */
99
+ const STARTERS = [
100
+ "Explain what this code does",
101
+ "Find the fault in this stack trace",
102
+ "Plan how to ship this feature",
103
+ "Summarise this document",
104
+ ];
105
+ /**
106
+ * The store, or null when the browser will not give us one.
107
+ *
108
+ * Reading `window.localStorage` THROWS `SecurityError` where site data is
109
+ * blocked — third-party contexts, Safari's Block All Cookies, a hardened
110
+ * profile — and this is called from inside a send handler, so an unguarded
111
+ * access takes the send down rather than the storage.
112
+ */
113
+ const store = () => {
114
+ if (typeof window === "undefined")
115
+ return null;
116
+ try {
117
+ return window.localStorage;
118
+ }
119
+ catch {
120
+ return null;
121
+ }
122
+ };
123
+ /**
124
+ * The text this screen arrived with, TYPED INTO THE COMPOSER — never sent.
125
+ *
126
+ * `?q=` means "a question came with the page", and the previous answer to that
127
+ * was to send it on mount. That was wrong in a way one send did not fix: a link
128
+ * is not consent to spend. The
129
+ * question reached two routes (/chat and, once /models mounted `Ask`, that page
130
+ * too), so `hanzo.ai/models?q=…` was a URL that spent a stranger's credits on
131
+ * text a stranger chose and wrote it into their history. Prefilling removes the
132
+ * whole class rather than capping it at one — a human presses send, or nothing
133
+ * is spent.
134
+ *
135
+ * `q` is still CONSUMED from the address: it has done its job once it is in the
136
+ * box, and leaving it there means a reload re-types over whatever was written
137
+ * since.
138
+ */
139
+ function useOpening() {
140
+ const [text, setText] = useState("");
141
+ const [thread, setThread] = useState(null);
142
+ const done = useRef(false);
143
+ useEffect(() => {
144
+ if (done.current)
145
+ return;
146
+ done.current = true;
147
+ const url = new URL(window.location.href);
148
+ const asked = url.searchParams.get("q");
149
+ // A conversation carried in from hanzo.chat. The free chat records the thread
150
+ // under the reader's own account and sends its id, never the transcript; this
151
+ // room opens that thread once the reader is signed in. Struck from the bar
152
+ // like `?q=`, for the same reasons.
153
+ const carried = url.searchParams.get("thread");
154
+ if (asked !== null || carried !== null) {
155
+ url.searchParams.delete("q");
156
+ url.searchParams.delete("thread");
157
+ window.history.replaceState(null, "", `${url.pathname}${url.search}${url.hash}`);
158
+ }
159
+ const found = (asked || "").trim();
160
+ if (found)
161
+ setText(found);
162
+ const id = (carried || "").trim();
163
+ if (id)
164
+ setThread(id);
165
+ }, []);
166
+ return { text, thread };
167
+ }
168
+ /** Types an arriving question into a composer once it lands. */
169
+ function useSeed(initial, set) {
170
+ const apply = useRef(set);
171
+ apply.current = set;
172
+ useEffect(() => {
173
+ if (initial)
174
+ apply.current(initial);
175
+ }, [initial]);
176
+ }
177
+ /** The circle both controls are cut from: @hanzo/composer's `Control`, at the
178
+ * 30px a line of this composer's footer row holds. */
179
+ const ROUND = 30;
180
+ export function Chat() {
181
+ const { isAuthenticated, isLoading } = useIam();
182
+ const hydrated = useHydrated();
183
+ // Read HERE, not in the child, and it stays here now that there is one child:
184
+ // two components consuming `?q=` meant whichever mounted first stripped it and
185
+ // the one that ended up on screen found nothing.
186
+ const { text: opening, thread: carried } = useOpening();
187
+ const settled = hydrated && !isLoading;
188
+ const signedIn = isAuthenticated || hasSession();
189
+ // The carried thread opens once there is somebody to open it for. Before
190
+ // sign-in it waits; `rememberDestination` already keeps `?thread=` through the
191
+ // trip to the issuer and back, so the id arrives here a second time and opens.
192
+ useEffect(() => {
193
+ if (carried && settled && signedIn)
194
+ openThread(carried);
195
+ }, [carried, settled, signedIn]);
196
+ // The client is the SHELL's — one per room, so the thread and the roster share
197
+ // a session rather than each minting its own. `Thread` is therefore only ever
198
+ // mounted under the provider the shell already put above it.
199
+ //
200
+ // ONE room, for everyone. `anonymous` changes which models are on offer and
201
+ // nothing else — the composer, the consent and the transcript are the same
202
+ // components either way, because a visitor is having the same conversation.
203
+ //
204
+ // The conversations are NOT drawn here. `Threads` is the list and the shell
205
+ // owns the column it sits in — one sidebar for six rooms, already built, and a
206
+ // second one in this pane would be two designs for one job. This file draws
207
+ // the conversation; the shell draws the room.
208
+ return _jsx(Thread, { initial: opening, anonymous: settled && !signedIn });
209
+ }
210
+ /**
211
+ * The same conversation, embedded in a page that is not /chat.
212
+ *
213
+ * R8: there were TWO live chat surfaces with different consent semantics — this
214
+ * one asks at SEND and fails closed, the other gated on ARRIVAL through a
215
+ * separate copy of the consent code. Two answers to one question is how they
216
+ * drift, so /models mounts this and the second surface is gone. It carries no
217
+ * heading of its own: the host page already has one, and a second `<h1>` is the
218
+ * defect that took the landings off /chat and /dev.
219
+ *
220
+ * The host supplies the `AiProvider`, because the host is what knows whether it
221
+ * already has one.
222
+ */
223
+ export function Ask({ placeholder, idle, }) {
224
+ const { isAuthenticated, isLoading } = useIam();
225
+ const hydrated = useHydrated();
226
+ const { text: opening } = useOpening();
227
+ const signedIn = isAuthenticated || hasSession();
228
+ if (!(hydrated && !isLoading))
229
+ return _jsx(_Fragment, { children: idle });
230
+ return (_jsx(Thread, { placeholder: placeholder, initial: opening, anonymous: !signedIn, heading: null }));
231
+ }
232
+ /**
233
+ * The resting placeholder.
234
+ *
235
+ * The package defaults to "Ask anything" and argues for it against
236
+ * "Message <model>" — a name that moves renames the control every time the
237
+ * model does. This is not that: it is the PRODUCT's name, one constant string,
238
+ * and it is already how this file addresses a character (`Message
239
+ * ${persona.name}`). `label` stays "Ask anything", so the accessible name does
240
+ * not move when the placeholder does.
241
+ */
242
+ const MESSAGE = "Message Hanzo AI";
243
+ /** The id the room's system turn is held under in the thread. */
244
+ const ROOM = "room";
245
+ /** The models that route a turn rather than name the one that answers it. */
246
+ const ROUTERS = new Set(["enso", "enso-auto", "enso-free", "hanzo/enso", "auto", "free"]);
247
+ /** The id of the turn drawn in the thread when a turn is refused. */
248
+ const FAILED = "failed";
249
+ /** The turns with the room's system turn first, or without one for an empty room. */
250
+ function seated(turns, turn) {
251
+ const rest = turns.filter((one) => one.id !== ROOM);
252
+ return turn ? [{ id: ROOM, role: "system", content: turn }, ...rest] : rest;
253
+ }
254
+ /** An answer that came back with nothing in it. */
255
+ const blank = (one) => one.role === "assistant" && !String(one.content ?? "").trim() && !one.tool_calls?.length;
256
+ /**
257
+ * The agents a question speaks to: those it names, or the whole room when it
258
+ * names nobody — the rule the room's brief gives the model (cast.tsx `brief`).
259
+ */
260
+ export function addressed(question, room) {
261
+ const named = room.filter((name) => new RegExp(`(^|[^\\p{L}\\p{N}])${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}($|[^\\p{L}\\p{N}])`, "iu").test(question));
262
+ return named.length ? named : room;
263
+ }
264
+ /**
265
+ * Which kind of refusal a failed turn met, read off the platform's own codes:
266
+ * the caller's wallet (`insufficient_balance`, the ai service's spend check), a visitor's
267
+ * spent free messages (`public_allowance_spent`), or a model provider that did
268
+ * not answer (`providers_exhausted`, or any 5xx that is not the balance lookup).
269
+ */
270
+ export function refusalOf(error) {
271
+ const status = error.status;
272
+ const body = error.body;
273
+ const codes = [body?.code, body?.error?.code, error.code].filter((one) => typeof one === "string");
274
+ const has = (code) => codes.includes(code);
275
+ return {
276
+ wallet: has("insufficient_balance"),
277
+ visitor: has("public_allowance_spent"),
278
+ provider: !has("balance_unavailable") &&
279
+ !has("insufficient_balance") &&
280
+ (has("providers_exhausted") || (typeof status === "number" && status >= 500)),
281
+ };
282
+ }
283
+ /** What the server said about a failure, folded under its plain line. */
284
+ function Why({ text }) {
285
+ const [open, setOpen] = useState(false);
286
+ if (!text)
287
+ return null;
288
+ return (_jsxs(YStack, { gap: "$1", children: [_jsx(XStack, { render: "button", "aria-expanded": open, onPress: () => setOpen((v) => !v), cursor: "pointer", children: _jsx(Text, { fontSize: "$2", color: "$soft", children: open ? "Hide details" : "Details" }) }), open ? (_jsx(Text, { fontSize: "$2", color: "$soft", children: text })) : null] }));
289
+ }
290
+ /**
291
+ * How long a turn may stream without a word before it is stopped and said to
292
+ * have failed. Long enough for a model that reasons at High effort before it
293
+ * writes, which streams no words while it does.
294
+ */
295
+ const STALL = 300_000;
296
+ /**
297
+ * The conversation. One component for a visitor and for an account.
298
+ *
299
+ * `anonymous` opens on the free pool and offers only what the free pool can
300
+ * answer, because listing a paid model to someone with no balance is an offer
301
+ * that fails at send. Everything else — the transcript, the composer, the
302
+ * consent — is identical, which is the point: signing in later changes the
303
+ * models on the menu, not the room.
304
+ */
305
+ function Thread({ placeholder = MESSAGE, initial, anonymous = false, heading, }) {
306
+ /**
307
+ * The model, and WHO decides it.
308
+ *
309
+ * `useState(anonymous ? FREE : ENSO)` read the flag ONCE, on the first render
310
+ * — which is the render before hydration settles, where `anonymous` is always
311
+ * false. So every visitor opened on ENSO while the composer beside them said
312
+ * "Enso Free", `isFree(model)` was false, and the two things that hang off it
313
+ * both went quiet: the data-sharing notice never appeared, and the consent
314
+ * block never asked. A visitor was sending on the paid pool having agreed to
315
+ * nothing, because a state initialiser cannot see a value that arrives later.
316
+ *
317
+ * A CHOICE, or the identity. `null` is "nobody has chosen", which is a
318
+ * different fact from either model and the only one that can defer to a flag
319
+ * that settles after mount. Picking from the menu still wins, permanently.
320
+ */
321
+ const [asking, setAsking] = useState(null);
322
+ // STARRED HERE, READ IN THE COLUMN — the shape a room and a project already
323
+ // have. hanzo.chat called it a bookmark; the word this site uses is starred,
324
+ // and one word for one act is what keeps the three lists reading alike.
325
+ const [starred, star] = useStarred("threads");
326
+ // The setter is taken now, not just the value: the override moved from
327
+ // Settings into the composer's own panel, so this is where it is written.
328
+ const [preferred, setPreferred] = useModel();
329
+ const [effort, setEffort] = useEffort();
330
+ const ai = useAi();
331
+ // In the app's frame the composer floats on the pane as a pane of its own.
332
+ const framed = useContext(Framed);
333
+ const { thread: open, open: reopen, agents: room, openAgent, seat, emptied, aside, showAside, showSettings } = useOpen();
334
+ // The first of them: what a surface that addresses one person reads.
335
+ const withWhom = room[0] ?? null;
336
+ // Whether the column beside this conversation is showing its summary, and the
337
+ // switch for it. Kept per conversation by `pane.ts`, so the header's control
338
+ // reflects THIS room rather than the last one that was open.
339
+ const [pinned, flipPin] = usePinned();
340
+ /**
341
+ * WHO YOU ARE TALKING TO, when it is somebody rather than the plain model.
342
+ *
343
+ * Pressing a name in the roster selected a RUN, which this pane never reads,
344
+ * so the chat opened saying nothing about them. `agent` names them, the detail
345
+ * carries their `instructions`, and those instructions ARE the attachment:
346
+ * seeded as the thread's system turn they ride with every message on the wire,
347
+ * so the reply comes back in their voice rather than the house one. No second
348
+ * send path and no `preset` running parallel to `messages` — a conversation
349
+ * already has a place for who is speaking, and this is it.
350
+ */
351
+ // THE COLUMN'S OWN LIST, because that is what draws the faces beside this
352
+ // room. `useAgent` asks the API for one agent BY REF and an agent is
353
+ // addressed here by the name a reader picked, so it answers null for every
354
+ // row whose id is not its name — which is why the hero had no picture and no
355
+ // sentence for an agent the column had just drawn a memoji for.
356
+ const { agents: roster, loading: gathering } = useCrew();
357
+ // THE HANDLE A NAME IS FILED UNDER. The room holds the names a person reads
358
+ // ("Dev"); the platform files a core member under its lower-case handle
359
+ // ("dev"), so a read goes out under the org's own spelling.
360
+ const handle = useCallback((name) => (Array.isArray(roster) ? roster : []).find((one) => one.name.toLowerCase() === name.toLowerCase())?.name ?? name, [roster]);
361
+ const { agent: read, loading: reading } = useAgent(withWhom && !gathering ? handle(withWhom) : null);
362
+ // THIS agent's record only: the hook keeps the last agent's row while it reads
363
+ // the next, so a row whose name is not the one in the room is not theirs.
364
+ const persona = read && withWhom && read.name?.toLowerCase() === withWhom.toLowerCase() ? read : null;
365
+ // Until the record answers, the model they are pinned to is unknown, so a send
366
+ // waits for it rather than going out on the reader's own model.
367
+ const resolving = Boolean(withWhom) && (gathering || reading);
368
+ const resolvingNow = useRef(resolving);
369
+ resolvingNow.current = resolving;
370
+ // THE ROOM'S FACES, by name: what the column's own list says each one looks
371
+ // like, which is what the header, the hero and a reply's byline all draw.
372
+ const faces = useMemo(() => {
373
+ const rows = Array.isArray(roster) ? roster : [];
374
+ return Object.fromEntries(room.map((name) => [name, rows.find((one) => one.name.toLowerCase() === name.toLowerCase())]));
375
+ }, [roster, room]);
376
+ const mate = withWhom ? faces[withWhom] : undefined;
377
+ // ONE SPELLING PER AGENT. A core member is named as hanzoai/personas names
378
+ // them whatever the org's handle ("des" is Des, `called`); any other agent is
379
+ // named as the org spells it, so a face pressed before the org's agents arrive
380
+ // is named again once they do, and its row, picture and brief are the org's.
381
+ useEffect(() => {
382
+ const rows = Array.isArray(roster) ? roster : [];
383
+ const named = room.map((name) => called(rows.find((one) => one.name.toLowerCase() === name.toLowerCase())?.name ?? name));
384
+ if (named.some((name, i) => name !== room[i]))
385
+ seat(named);
386
+ }, [roster, room, seat]);
387
+ // WHO A REPLY MAY NAME: the room, the org's agents and the house crew. A turn
388
+ // said while someone was in the room stays split by speaker after they leave.
389
+ const known = useMemo(() => [...new Set([...room, ...(Array.isArray(roster) ? roster.map((one) => called(one.name)) : []), ...HOUSE])], [room, roster]);
390
+ // EVERYONE ELSE IN THE ROOM. `useAgent` reads the first by ref; the rest are
391
+ // read here once each and kept, so a member who steps out and back in does
392
+ // not cost a second read. A name the platform does not hold reads null and
393
+ // falls to what the cast says of them.
394
+ const [briefs, setBriefs] = useState({});
395
+ const asked = useRef(new Set());
396
+ useEffect(() => {
397
+ for (const name of room) {
398
+ const ref = handle(name);
399
+ if (name === withWhom || asked.current.has(ref))
400
+ continue;
401
+ asked.current.add(ref);
402
+ ai.agents
403
+ .get(ref)
404
+ .then((found) => setBriefs((all) => ({ ...all, [name]: found?.instructions?.trim() || null })))
405
+ .catch(() => {
406
+ asked.current.delete(ref);
407
+ setBriefs((all) => ({ ...all, [name]: null }));
408
+ });
409
+ }
410
+ }, [ai, room, withWhom, handle]);
411
+ // THE SYSTEM TURN: one brief as it is, or the whole room. The org's row
412
+ // speaks for an agent it holds — the cloud keeps a core member's row in step
413
+ // with hanzoai/personas, and an agent the org made under the same name keeps
414
+ // its own brief — and a core member the org holds no row for speaks as
415
+ // hanzoai/personas writes them (`speaksOf`), so a visitor pressing Feynman
416
+ // gets Feynman rather than the house model.
417
+ const brief = useMemo(() => roomBrief(room.map((name) => ({
418
+ name,
419
+ says: (name === withWhom ? persona?.instructions?.trim() : briefs[name]) || speaksOf(name) || null,
420
+ }))), [room, withWhom, persona?.instructions, briefs]);
421
+ // AN AGENT BRINGS ITS OWN MODEL. It is a field on the row, so choosing one
422
+ // for them would be answering a question they have already answered. The
423
+ // default for plain chats lives in Settings, so this room has no second model
424
+ // control that can drift from it.
425
+ //
426
+ // NAMED, THEN CHECKED AGAINST WHAT THE GATEWAY SERVES. `enso` is the house
427
+ // router and the right preference, but a deployment carrying a different
428
+ // catalogue does not answer to it — and an unserved name reaches the agents
429
+ // round as a completion for a model nobody has, which the round returns as a
430
+ // bare "not found", after it has already recorded the question. `served`
431
+ // keeps the house name where it is served and otherwise falls to the first
432
+ // model that is, so a room on any catalogue can be spoken to.
433
+ const { models } = useModels();
434
+ // HANZO MODELS ALONE are offered here, to everyone: an Enso or Zen id
435
+ // (`sku`). A preference kept from before for any other model is not one this
436
+ // room offers, so the house router is asked for instead, and `served` keeps
437
+ // to the house models wherever the catalog carries one. An agent's own model
438
+ // is sent as it is named, never swapped for another the catalog lists; the
439
+ // gateway says which model answered (`servedBy`).
440
+ const offered = useMemo(() => models.filter((m) => sku(m.id)), [models]);
441
+ const wanted = sku(preferred) ? preferred : ENSO;
442
+ const model = persona?.model || served(models, anonymous ? FREE : wanted);
443
+ // WHAT THE PLAN HAS LEFT, as shares of the session, the day and the month
444
+ // (lib/hanzo/limits.ts). Read again whenever a send settles, below.
445
+ const { limits, reload: reread } = useLimits(!anonymous, org());
446
+ const held = Boolean(limits?.plan);
447
+ // A MODEL THE FREE PLAN DOES NOT INCLUDE IS ASKED FOR, NOT CHOSEN. On Free,
448
+ // picking a model the plan's row does not run (`onFree`) leaves the choice
449
+ // where it was and opens the upgrade, titled with the model; so does a turn
450
+ // the gateway refuses `plan_required`. A plan the limits name is not Free,
451
+ // whatever the tier says, so every Hanzo model is simply chosen on it.
452
+ // `isFree` still drives the free-lane notice.
453
+ const free = useFree(!anonymous) && !held;
454
+ const [ask, setAsk] = useState(null);
455
+ const choose = (id) => (free && !onFree(id) ? setAsk({ model: nameOf(models, id) }) : setPreferred(id));
456
+ // KEEPING THE CONVERSATION is the reader's account, not this pane's choice:
457
+ // the store is per-org, so a signed-in reader has somewhere for it to go and
458
+ // an anonymous one does not. `thread` names the conversation being continued
459
+ // — the Recent they opened — so a reply lands in it rather than beside it.
460
+ const chat = useChat({
461
+ model,
462
+ keep: !anonymous,
463
+ // THE PILL THAT WAS CUT READ A SETTING NOTHING SET, and that was the right
464
+ // reason to cut it. This is the other half: `params` is forwarded to the
465
+ // completion, so the effort chosen on the bar rides the request. An
466
+ // upstream that does not read `reasoning_effort` answers as it would have,
467
+ // the way it does for `temperature`.
468
+ params: { reasoning_effort: effort },
469
+ ...(open ? { thread: open } : {}),
470
+ onError: (e) => {
471
+ const needs = planRequired(e);
472
+ if (needs && free)
473
+ setAsk({ ...needs, model: nameOf(models, model) });
474
+ },
475
+ });
476
+ const { send, set, error, thread: kept } = chat;
477
+ // Threads THIS pane opened by writing them. Their transcript is already on
478
+ // the screen — it is what was just said — so the restore below has nothing to
479
+ // fetch for one, and fetching anyway would race a live conversation to
480
+ // replace it with the server's copy mid-sentence.
481
+ const written = useRef(new Set());
482
+ // The room each thread last recorded, by thread, as the names joined.
483
+ const recorded = useRef(new Map());
484
+ // A conversation that has just been written down becomes the OPEN one, so the
485
+ // rail marks the room the reader is standing in. Only on the mint, which is
486
+ // `kept` changing: when the reader opens another Recent, `open` moves first and
487
+ // `kept` follows it a render later (the hook takes `thread` in an effect), and
488
+ // reopening the stale `kept` then sent the two chasing each other until React
489
+ // stopped the page (#185).
490
+ const minted = useRef(kept);
491
+ useEffect(() => {
492
+ if (kept === minted.current)
493
+ return;
494
+ minted.current = kept;
495
+ if (kept && kept !== open) {
496
+ written.current.add(kept);
497
+ reopen(kept);
498
+ }
499
+ }, [kept, open, reopen]);
500
+ // The thread's turns, replaced wholesale or through an updater. `set` is the
501
+ // hook's own state setter, so it takes either.
502
+ const apply = useRef(set);
503
+ apply.current = set;
504
+ // THE ROOM'S SYSTEM TURN: who is in the conversation (`roster`), then how each
505
+ // of them talks. It is the first turn every request carries and it follows the room, so
506
+ // pressing a face adds them to the conversation that is open rather than
507
+ // starting another; the turns already said stay where they are.
508
+ const turn = useMemo(() => roomTurn(room, brief), [room, brief]);
509
+ const turnNow = useRef(turn);
510
+ turnNow.current = turn;
511
+ const roomNow = useRef(room);
512
+ roomNow.current = room;
513
+ useEffect(() => {
514
+ apply.current((held) => seated(held, turn));
515
+ }, [turn]);
516
+ // A NEW CONVERSATION holds the room's turn and nothing else: on arrival, on
517
+ // "New chat" (`emptied` counts the presses, so a press on an empty room still
518
+ // lands), and when the open thread is left for none.
519
+ useEffect(() => {
520
+ if (open)
521
+ return;
522
+ apply.current(seated([], turnNow.current));
523
+ }, [open, emptied]);
524
+ // A RECENT THAT OPENS. `threads.get` answers null for an id this caller may
525
+ // not read — the route replies 200 with no messages rather than 404 — so an
526
+ // empty transcript is read as absent instead of drawn as a real but blank
527
+ // conversation. The last roster turn it holds names its room, which is seated
528
+ // as it arrives; a thread that never recorded one has nobody in it.
529
+ //
530
+ // Its own effect, apart from the room's turn: a character arriving while the
531
+ // read is out must not abort it.
532
+ useEffect(() => {
533
+ if (!open)
534
+ return;
535
+ // Our own. The turns are on the screen already.
536
+ if (written.current.has(open))
537
+ return;
538
+ const stop = new AbortController();
539
+ void ai.threads
540
+ .get(open, { signal: stop.signal })
541
+ .then((found) => {
542
+ const turns = (found?.messages ?? []).map((m, i) => ({
543
+ // A store row always has an id; a turn without one is keyed by its place.
544
+ id: m.id || `${open}#${i}`,
545
+ role: m.role,
546
+ content: m.content,
547
+ }));
548
+ let names = [];
549
+ for (const one of turns) {
550
+ const held = one.role === "system" ? rosterOf(one.content) : null;
551
+ if (held)
552
+ names = held;
553
+ }
554
+ recorded.current.set(open, names.join("\n"));
555
+ // The room already seated keeps its briefs; another is seated by name
556
+ // and its briefs follow as they are read.
557
+ const same = names.join("\n") === roomNow.current.join("\n");
558
+ seat(names);
559
+ apply.current(seated(turns.filter((one) => !(one.role === "system" && rosterOf(one.content))), same ? turnNow.current : roomTurn(names, null)));
560
+ })
561
+ .catch(() => apply.current(seated([], turnNow.current)));
562
+ return () => stop.abort();
563
+ }, [ai, open, seat]);
564
+ // WHO IS IN THE CONVERSATION IS KEPT WITH IT. A thread records its room as a
565
+ // roster turn when it is first written down with somebody in it, and again
566
+ // whenever the room changes; reopening it — after a reload, on another
567
+ // device — seats the same faces. The briefs are not recorded: they are read
568
+ // fresh from the agents each time the room is seated.
569
+ useEffect(() => {
570
+ if (!open || anonymous)
571
+ return;
572
+ const now = room.join("\n");
573
+ const was = recorded.current.get(open);
574
+ if (was === now)
575
+ return;
576
+ // A thread read back from the store is seated by the read above; until it
577
+ // lands there is nothing to compare against.
578
+ if (was === undefined && !written.current.has(open))
579
+ return;
580
+ recorded.current.set(open, now);
581
+ if (was === undefined && room.length === 0)
582
+ return;
583
+ void ai.threads.record([{ role: "system", content: rosterLine(room) }], open).catch(() => {
584
+ recorded.current.delete(open);
585
+ });
586
+ }, [ai, open, room, anonymous]);
587
+ // `send` is read through a ref and NEVER through a dependency array. Its
588
+ // identity changes on every `setMessages` — `useChat` closes over `messages` —
589
+ // so an effect depending on it re-runs after each streamed token.
590
+ const latest = useRef(send);
591
+ latest.current = send;
592
+ // THE COMPOSER'S DRAFT IS HELD HERE, so an arriving question can be typed into
593
+ // it. `Chat` owns a draft of its own and spreads `composer` after it, so
594
+ // passing value/onChange/onSend takes that ownership over rather than fighting
595
+ // it.
596
+ const [draft, setDraft] = useState("");
597
+ useSeed(initial, setDraft);
598
+ // DICTATION, the same one the site's own composer offers. `speech()` defaults
599
+ // to the Hanzo gateway and `base()` is the one place a host is read from, so
600
+ // pointing a local cloud at one points both. The token is the reader's own —
601
+ // the package never invents a credential — and where a browser has no
602
+ // recogniser `<Voice/>` wears the reason rather than failing silently.
603
+ const { token } = useIamToken();
604
+ const agentVoice = voiceOf(withWhom);
605
+ const ear = useMemo(() => speech({
606
+ ...base(),
607
+ ...(token ? { token } : {}),
608
+ voice: { name: agentVoice },
609
+ }), [token, agentVoice]);
610
+ const voice = useVoice({
611
+ speech: ear,
612
+ onPartial: setDraft,
613
+ onUtterance: setDraft,
614
+ });
615
+ // When voice dictation is open, automatically speak incoming assistant replies
616
+ const wasStreaming = useRef(false);
617
+ useEffect(() => {
618
+ if (wasStreaming.current && !chat.streaming && voice.open) {
619
+ const msgs = chat.messages;
620
+ const lastMsg = msgs[msgs.length - 1];
621
+ if (lastMsg && lastMsg.role === "assistant" && typeof lastMsg.content === "string") {
622
+ // A room's reply is read part by part, each in its speaker's voice.
623
+ const parts = room.length > 1 ? speakers(lastMsg.content, room) : [{ who: withWhom, text: lastMsg.content }];
624
+ const read = (i) => {
625
+ if (i >= parts.length)
626
+ return;
627
+ speakAgent(parts[i].text, { agent: parts[i].who ?? withWhom ?? undefined, onEnd: () => read(i + 1) });
628
+ };
629
+ read(0);
630
+ }
631
+ }
632
+ wasStreaming.current = chat.streaming;
633
+ }, [chat.streaming, voice.open, chat.messages, withWhom, room]);
634
+ // A SEND THAT WAITS ONE RENDER. `send` closes over the turns it was rendered
635
+ // with, so a send that first tidies the thread is held here and made from the
636
+ // next render, where the tidied turns are the ones it sends.
637
+ const [queued, setQueued] = useState(null);
638
+ useEffect(() => {
639
+ if (queued === null || resolving)
640
+ return;
641
+ setQueued(null);
642
+ void latest.current(queued);
643
+ }, [queued, resolving]);
644
+ const streaming = useRef(false);
645
+ streaming.current = chat.streaming;
646
+ const turns = useRef(chat.messages);
647
+ turns.current = chat.messages;
648
+ /**
649
+ * WHAT EACH UNANSWERED QUESTION WAS TOLD, by the question's own turn id: the
650
+ * refusal it met, or that its answer came back empty, or that it streamed
651
+ * nothing until `STALL`. A question with no entry here and no answer after it
652
+ * — a thread read back from the store — is still said to have gone unanswered.
653
+ */
654
+ const [failures, setFailures] = useState({});
655
+ const note = useCallback((failed) => {
656
+ const id = [...turns.current].reverse().find((m) => m.role === "user")?.id;
657
+ if (id)
658
+ setFailures((all) => ({ ...all, [id]: failed }));
659
+ }, []);
660
+ useEffect(() => {
661
+ if (error && !chat.streaming)
662
+ note({ error });
663
+ }, [error, chat.streaming, note]);
664
+ // EVERY SEND THAT SETTLES re-reads the plan's limits: a stream that ended, a
665
+ // stop, or a refusal — a 429 for a spent window included, which is read
666
+ // again on the pass that delivers it. An error with no stream before it
667
+ // still counts, so a refusal raised before the first frame is not missed.
668
+ const counting = useRef(false);
669
+ useEffect(() => {
670
+ if (chat.streaming) {
671
+ counting.current = true;
672
+ return;
673
+ }
674
+ if (!counting.current && !error)
675
+ return;
676
+ counting.current = false;
677
+ reread();
678
+ }, [chat.streaming, error, reread]);
679
+ const wasBusy = useRef(false);
680
+ useEffect(() => {
681
+ const held = turns.current[turns.current.length - 1];
682
+ if (wasBusy.current && !chat.streaming && !error && held && blank(held))
683
+ note({ error: null, empty: true });
684
+ wasBusy.current = chat.streaming;
685
+ }, [chat.streaming, error, note]);
686
+ /**
687
+ * Sends a question. A new question takes any empty answer off the thread
688
+ * first, because an upstream may refuse a request carrying an assistant turn
689
+ * with nothing in it. `again` names an unanswered question to ask once more:
690
+ * it leaves its place in the thread and is asked at the end, so it is asked
691
+ * once and not twice.
692
+ */
693
+ const speak = useCallback((text, again) => {
694
+ if (!text)
695
+ return;
696
+ // Activation: the first question this browser asks (a retry is not one).
697
+ if (!again)
698
+ first("chat_message");
699
+ if (!streaming.current && (again || resolvingNow.current || turns.current.some(blank))) {
700
+ apply.current((held) => held.filter((one) => one.id !== again && !blank(one)));
701
+ if (again)
702
+ setFailures((all) => {
703
+ const { [again]: _gone, ...rest } = all;
704
+ return rest;
705
+ });
706
+ setQueued(text);
707
+ return;
708
+ }
709
+ void latest.current(text);
710
+ }, []);
711
+ // A TURN THAT NEVER SPEAKS IS STOPPED. A stream that stays open with nothing
712
+ // in it would leave a Stop button and no answer for as long as the upstream
713
+ // holds the connection; after `STALL` it is ended and its question is said to
714
+ // have gone unanswered, with Try again.
715
+ useEffect(() => {
716
+ if (!chat.streaming)
717
+ return;
718
+ const clock = window.setTimeout(() => {
719
+ const held = turns.current[turns.current.length - 1];
720
+ if (held && blank(held)) {
721
+ note({ error: null, stalled: true });
722
+ chat.stop();
723
+ }
724
+ }, STALL);
725
+ return () => window.clearTimeout(clock);
726
+ // Armed once per turn; `chat.stop` is the hook's stable callback.
727
+ // eslint-disable-next-line react-hooks/exhaustive-deps
728
+ }, [chat.streaming]);
729
+ // WHICH MODEL ANSWERED each reply, by the reply's turn id, as the gateway named
730
+ // it while the reply streamed (lib/served.ts).
731
+ const [servedBy, setServedBy] = useState({});
732
+ useEffect(() => onServed((heard) => {
733
+ const answer = [...turns.current].reverse().find((m) => m.role === "assistant");
734
+ if (answer && streaming.current)
735
+ setServedBy((all) => ({ ...all, [answer.id]: heard }));
736
+ }), []);
737
+ const byline = (id) => {
738
+ const heard = servedBy[id];
739
+ // A router named back as itself says nothing about who answered.
740
+ if (!heard || (heard.served === heard.asked && ROUTERS.has(heard.asked)))
741
+ return null;
742
+ const fell = heard.asked && heard.asked !== heard.served && !ROUTERS.has(heard.asked);
743
+ return (_jsxs(Text, { fontSize: "$1", color: "$soft", children: ["answered by ", heard.served, fell ? ` · ${heard.asked} was unavailable` : ""] }));
744
+ };
745
+ // WHAT WAS SAID, which is not everything the thread holds. The persona rides
746
+ // as a system turn so the wire carries it; drawing it would print a character
747
+ // brief in the transcript as though somebody had typed it, and would make a
748
+ // room nobody has spoken in look like a conversation already under way.
749
+ const said = useMemo(() => chat.messages.filter((m) => m.role !== "system"), [chat.messages]);
750
+ // THE LIKENESS, WHERE ONE WAS DRAWN FOR THIS CHARACTER. `cast.tsx` is the one
751
+ // table of who looks like what, and it says a name it does not hold "is not a
752
+ // problem" — that name wears initials, never another character's face.
753
+ // WHAT THE AGENT CARRIES, THEN WHAT WE CAST. The cast answers for a character
754
+ // it has a part for; an agent named after its job — "Hanzo Researcher" — has
755
+ // no part, so the hero drew nothing while the column beside it drew the
756
+ // record's own picture. Its own answer comes first, which is the order
757
+ // `Face` already states for every other surface.
758
+ const portrait = mate?.avatar ?? persona?.avatar ?? roleOf(withWhom)?.portrait;
759
+ /** Whether the field has focus, so the FRAME can wear the ring the field is
760
+ * no longer allowed to draw. `:focus-within` would say this in CSS; the
761
+ * stylesheet that would hold it cannot reach past gui's own rule, so the
762
+ * state is read where the event is. */
763
+ const guarded = useCallback((text) => {
764
+ const s = store();
765
+ // FAILS CLOSED. This read `s && !hasConsent(s)`, so a browser that gives
766
+ // us no store answered "no consent needed" and sent a data-shared turn
767
+ // having asked nobody. No store means no record, and no record means ask.
768
+ if (isFree(model) && (!s || !hasConsent(s))) {
769
+ setAsking(text);
770
+ return;
771
+ }
772
+ speak(text);
773
+ }, [model, speak]);
774
+ /*
775
+ WHAT AN UNANSWERED QUESTION LOOKS LIKE: a turn of its own directly under it,
776
+ naming who was asked, saying what went wrong in the reader's words, and
777
+ offering Try again. Every question ends in an answer or in one of these,
778
+ never in nothing: the thread drawn is `shown`, which puts one after every
779
+ question with no answer after it.
780
+
781
+ The refusals the reader can act on keep the server's sentence and its way
782
+ out — Add credit only for the caller's OWN wallet (`insufficient_balance`),
783
+ Upgrade for a plan limit, Sign in for a visitor. A model provider that did
784
+ not answer (`providers_exhausted`, or any 5xx on the turn) is ours, and says
785
+ so plainly with Try again; the upstream account named inside it is not the
786
+ reader's to fund.
787
+ */
788
+ const shown = useMemo(() => {
789
+ const out = [];
790
+ const held = said.filter((one, i) => !blank(one) || (chat.streaming && i === said.length - 1));
791
+ held.forEach((one, i) => {
792
+ out.push(one);
793
+ if (one.role !== "user")
794
+ return;
795
+ const next = held[i + 1];
796
+ if (!next || next.role === "user")
797
+ out.push({ id: `${FAILED}:${one.id}`, role: "assistant", content: "" });
798
+ });
799
+ // A question on its way out has no answer yet and no failure either.
800
+ if (queued !== null && out.length && String(out[out.length - 1].id).startsWith(`${FAILED}:`))
801
+ out.pop();
802
+ return out;
803
+ }, [said, chat.streaming, queued]);
804
+ const failure = (question) => {
805
+ const failed = failures[question.id];
806
+ const text = String(question.content ?? "");
807
+ const whom = addressed(text, room);
808
+ const wrong = failed?.error ?? null;
809
+ const how = wrong ? refusalOf(wrong) : null;
810
+ // A SPENT WINDOW is the plan holder's own limit, said with its reset and,
811
+ // below the top plan, the next plan's checkout. Asking again before the
812
+ // reset meets the same answer, so it offers no Try again.
813
+ const cap = wrong ? capped(wrong) : null;
814
+ const next = cap?.href ? planName(limits?.upgrade) : null;
815
+ const resign = !cap && !!wrong && (refused(wrong) || /audience not allowed|invalid access token/i.test(wrong.message));
816
+ const lift = free ? planRequired(wrong) : null;
817
+ const said = !failed
818
+ ? "No answer came back for this question."
819
+ : failed.stalled
820
+ ? `${nameOf(models, model)} sent nothing for ${STALL / 60_000} minutes, so the turn was stopped.`
821
+ : failed.empty
822
+ ? `${nameOf(models, model)} answered with nothing.`
823
+ : cap
824
+ ? `You've used ${cap.limit === "session" ? "this session's" : "today's"} limit.${cap.resets ? ` Resets at ${clock(cap.resets)}.` : ""}`
825
+ : resign
826
+ ? anonymous
827
+ ? "Sign in to ask this."
828
+ : "Your session needs to be refreshed. Sign in to reconnect."
829
+ : how?.provider
830
+ ? `${familyOf(model) || nameOf(models, model)} is unavailable right now.`
831
+ : plainly(wrong, "this conversation");
832
+ 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: () => {
833
+ if (typeof window !== "undefined") {
834
+ localStorage.removeItem("hanzo_iam_access_token");
835
+ localStorage.removeItem("hanzo_iam_refresh_token");
836
+ }
837
+ enter(LOGIN);
838
+ }, children: "Sign in" })) : lift ? (_jsx(Button, { size: "sm", variant: "outline", onClick: () => setAsk({ ...lift, model: nameOf(models, model) }), children: "Upgrade" })) : how?.visitor ? (_jsx(Button, { size: "sm", variant: "outline", onClick: () => enter(), children: "Sign in to keep going" })) : (_jsx(Anchor, { href: checkoutUrl(), children: _jsx(Button, { size: "sm", variant: "outline", children: "Add credit" }) })) })) : null] }) }));
839
+ };
840
+ // The agreement is asked OVER the room, not instead of it. Returning the
841
+ // consent on its own replaced the whole pane: the question the reader had
842
+ // just typed left the screen, and the first thing a stranger saw of the
843
+ // product was a page about training data. The room stays; the sheet sits on
844
+ // top of it, and the question is still there behind it.
845
+ const consent = asking === null ? null : (_jsx(Consent, { question: asking, onAgree: () => {
846
+ const s = store();
847
+ // Nothing to persist into is not a reason to refuse the turn the
848
+ // reader just agreed to; it means the question is asked again next
849
+ // time, which is the honest cost of a browser that stores nothing.
850
+ if (s)
851
+ grantConsent(s);
852
+ const held = asking;
853
+ setAsking(null);
854
+ if (held)
855
+ speak(held);
856
+ }, onCancel: () => {
857
+ setAsking(null);
858
+ } }));
859
+ return (_jsxs(YStack, { flex: 1, minH: 0, width: "100%", position: "relative", children: [_jsxs(XStack, { height: 48, items: "center", justify: "space-between", px: "$3", borderBottomWidth: 1, borderColor: "$borderColor", children: [_jsxs(XStack, { items: "center", gap: "$2", minW: 0, children: [_jsx(Box, { render: "button", onClick: () => open && star(open), "aria-label": open && starred.has(open) ? "Unstar this conversation" : "Star this conversation", "aria-pressed": !!open && starred.has(open), p: "$1.5", rounded: "$2", hoverStyle: { bg: '$hover' }, children: _jsx(Star, { size: 15, "aria-hidden": true, color: open && starred.has(open) ? "var(--foreground)" : "var(--muted-foreground)", fill: open && starred.has(open) ? "currentColor" : "none" }) }), room.length ? (_jsx(XStack, { role: "list", "aria-label": "Agents in this chat", items: "center", gap: "$1.5", minW: 0, overflowX: "auto", "$platform-web": { scrollbarWidth: "none" }, children: room.map((name) => (_jsxs(XStack, { role: "listitem", items: "center", gap: "$1.5", pl: "$1", pr: "$0.5", py: "$0.5", rounded: 999, borderWidth: 1, borderColor: "$borderColor", shrink: 0, children: [_jsx(Face, { src: faces[name]?.avatar ?? (name === withWhom ? portrait : undefined), emoji: faces[name]?.emoji ?? (name === withWhom ? persona?.emoji : undefined), name: name, size: 22 }), _jsx(Text, { fontSize: "$2", fontWeight: "600", color: "$color", numberOfLines: 1, display: "none", $sm: { display: "flex" }, children: name }), _jsx(Box, { render: "button", onClick: () => openAgent(name), "aria-label": `Remove ${name} from this chat`, title: `Remove ${name}`, width: 24, height: 24, rounded: 999, items: "center", justify: "center", hoverStyle: { bg: "$hover" }, children: _jsx(X, { size: 13, "aria-hidden": true, color: "var(--soft, var(--muted-foreground))" }) })] }, name))) })) : null] }), _jsxs(XStack, { items: "center", gap: "$1", children: [_jsx(Box, { render: "button", onClick: () => {
860
+ if (aside === null) {
861
+ showAside("tasks");
862
+ if (!pinned)
863
+ flipPin();
864
+ return;
865
+ }
866
+ flipPin();
867
+ }, "aria-label": "Toggle pinned summary", "aria-pressed": aside !== null && pinned,
868
+ // NOT ON A PHONE, because there is nothing for it to show there.
869
+ // The frame draws the column only above `$lg` — it spends the whole
870
+ // width on the room below that (`Shell.tsx`) — so this control
871
+ // would press, change the stored pin, and move nothing on screen.
872
+ // A button whose effect is invisible is a button that does nothing.
873
+ // Same breakpoint as the column, so the two cannot drift apart.
874
+ display: "none", $lg: { display: "flex" }, p: "$2", rounded: "$2", hoverStyle: { bg: "$hover" }, children: _jsx(PanelRight, { size: 15, color: "var(--soft, var(--muted-foreground))" }) }), open ? _jsx(Share, { thread: open }) : null] })] }), _jsxs(Beside, { children: [_jsx(RightPane, { said: said, onClose: () => showAside(null) }), _jsx(Work, { persona: persona ?? null, room: room, model: model, messages: said })] }), isFree(model) ? (_jsxs(Text, { fontSize: "$2", color: "$soft", text: "center", maxW: MEASURE, mx: "auto", px: "$4", py: "$2", children: [freeCopy.notice, " ", _jsx(Anchor, { href: site("/legal/privacy"), children: freeCopy.noticeCta })] })) : null, _jsx(ChatSurface, { ...chat, messages: shown, send: guarded,
875
+ // AN EMPTY ROOM CENTRES ITS COMPOSER. While nothing has been said the
876
+ // opening (the heading and the starters) and the composer sit as one
877
+ // group in the middle of the height, with or without the plan bars in
878
+ // the composer's foot; the moment a turn exists the transcript takes the
879
+ // height and the composer docks under it.
880
+ center: true,
881
+ // AS IT WAS WRITTEN. The package renders a turn's words plainly and
882
+ // says so — it ships no markdown pipeline because the plugin set is the
883
+ // surface's to choose — so an answer arrived here reading "```ts" with
884
+ // the backticks on screen. `Prose` is the site's one renderer, reading
885
+ // with the same `marked` the legal pages parse with and drawing every
886
+ // token in gui elements, and the shared view draws through it too.
887
+ body: (turn) => {
888
+ if (String(turn.id).startsWith(`${FAILED}:`)) {
889
+ const question = shown.find((one) => one.id === String(turn.id).slice(FAILED.length + 1));
890
+ return question ? failure(question) : null;
891
+ }
892
+ const said = typeof turn.content === "string" ? turn.content : "";
893
+ // WHO IS ANSWERING, while the answer has no words yet: the agents the
894
+ // question addresses, or the whole room when it names nobody.
895
+ if (turn.role === "assistant" && !said && chat.streaming && room.length) {
896
+ const q = [...shown].reverse().find((one) => one.role === "user");
897
+ const whom = addressed(String(q?.content ?? ""), room);
898
+ return (_jsxs(Text, { fontSize: "$2", color: "$soft", children: [join(whom), " ", whom.length > 1 ? "are" : "is", " answering\u2026"] }));
899
+ }
900
+ // A ROOM'S ANSWER IS SEVERAL PEOPLE'S. Each opens a line with their
901
+ // name, as the brief asks, so each is drawn under their own face and
902
+ // read in their own voice. A reply that names nobody is drawn whole.
903
+ const parts = turn.role === "assistant" ? speakers(said, known) : null;
904
+ if (parts && parts.some((part) => part.who)) {
905
+ // Asked and silent: an agent the question addressed who has no part
906
+ // in this reply.
907
+ const at = shown.findIndex((one) => one.id === turn.id);
908
+ const q = shown.slice(0, at).reverse().find((one) => one.role === "user");
909
+ const silent = room.length > 1 && !(chat.streaming && at === shown.length - 1)
910
+ ? addressed(String(q?.content ?? ""), room).filter((name) => !parts.some((part) => part.who?.toLowerCase() === name.toLowerCase()))
911
+ : [];
912
+ return (_jsxs(YStack, { gap: "$3", children: [parts.map((part, i) => (_jsxs(YStack, { gap: "$1", children: [part.who ? (_jsxs(XStack, { items: "center", gap: "$2", children: [_jsx(Face, { src: faces[part.who]?.avatar, emoji: faces[part.who]?.emoji, name: part.who, size: 20 }), _jsx(Text, { fontSize: "$2", fontWeight: "600", color: "$ink", children: part.who })] })) : null, _jsx(Prose, { text: part.text }), part.text ? (_jsxs(XStack, { ml: "$-1", items: "center", gap: "$2", children: [_jsx(Take, { text: part.text, says: "this answer" }), _jsx(SpeakButton, { text: part.text, agentName: part.who ?? withWhom ?? undefined })] })) : null] }, i))), silent.length ? (_jsxs(Text, { fontSize: "$2", color: "$soft", children: ["No answer from ", join(silent), " in this reply."] })) : null, byline(turn.id)] }));
913
+ }
914
+ return (_jsxs(YStack, { gap: "$1", children: [_jsx(Prose, { text: said }), turn.role === "assistant" && said ? (_jsxs(XStack, { ml: "$-1", items: "center", gap: "$2", children: [_jsx(Take, { text: said, says: "this answer" }), _jsx(SpeakButton, { text: said, agentName: withWhom })] })) : null, turn.role === "assistant" && said ? byline(turn.id) : null] }));
915
+ },
916
+ // The page's ONE heading, and it belongs to the empty transcript rather
917
+ // than to a signed-out branch — the branch is gone, and the heading went
918
+ // with it when it did. `Ask` passes its own, because the page embedding
919
+ // it already has an `<h1>` and a second is the defect that took the
920
+ // landings off /chat and /dev.
921
+ empty: heading === null ? undefined : (_jsxs(YStack, { "data-slot": "opening", items: "center", width: "100%", py: "$6", children: [room.length > 1 ? (_jsx(XStack, { items: "center", justify: "center", mb: "$4", children: room.map((name, i) => (_jsx(Box, { ml: i ? -16 : 0, rounded: 40, borderWidth: 2, borderColor: "$background", z: room.length - i, children: _jsx(Face, { src: faces[name]?.avatar, emoji: faces[name]?.emoji, name: name, size: 72 }) }, name))) })) : portrait ? (_jsx(Box, { width: 160, height: 160, rounded: 24, overflow: "hidden", borderWidth: 2, borderColor: "var(--white-15)", boxShadow: "0 10px 24px color-mix(in srgb, var(--pure-black) 70%, transparent)", mb: "$4", bg: "var(--neutral-900)", children: _jsx(View, { render: _jsx("img", { src: portrait, alt: withWhom }), width: "100%", height: "100%", objectFit: "cover" }) })) : null, room.length === 1 && !portrait ? (_jsx(Box, { mb: "$3", children: _jsx(Face, { emoji: mate?.emoji ?? persona?.emoji, name: withWhom, size: 72 }) })) : null, _jsx(Text, { render: "h1", fontSize: "$7", lineHeight: "$7", fontWeight: "700", color: "$ink", text: "center", mb: "$1", children: join(room) || heading || "What can I help with?" }), room.length === 1 && (mate?.description ?? persona?.description) ? (_jsx(Text, { fontSize: "$3", color: "$soft", text: "center", maxW: 500, mb: "$4", lineHeight: "$4", children: mate?.description ?? persona?.description })) : null, _jsx(XStack, { flexWrap: "wrap", justify: "center", gap: "$2", maxW: 560, px: "$3", pb: "$4", children: STARTERS.map((one) => (_jsx(Box, { render: "button", onClick: () => {
922
+ setDraft(`${one} `);
923
+ }, px: "$3", py: "$2", rounded: "$4", borderWidth: 1, borderColor: "$borderColor", hoverStyle: { bg: "$raised" }, children: _jsx(Text, { fontSize: "$2", color: "$soft", children: one }) }, one))) })] })),
924
+ // The transcript reads at the same measure. `Thread` defaults to 768
925
+ // itself; handing it the column here is what lets Width reach it.
926
+ column: { maxW: MEASURE }, composer: {
927
+ value: draft,
928
+ onChange: setDraft,
929
+ onSend: () => {
930
+ const text = draft.trim();
931
+ if (!text)
932
+ return;
933
+ setDraft("");
934
+ guarded(text);
935
+ },
936
+ // A CHARACTER IS WHO YOU ARE WRITING TO, so the field says so — and
937
+ // `label` keeps the accessible name constant while it does, which is
938
+ // the reason the component ships both: a placeholder that changes with
939
+ // the room would otherwise rename the control every time you switch.
940
+ placeholder: room.length ? `Message ${join(room)}` : placeholder,
941
+ label: ASK,
942
+ // CENTRED WHILE NOTHING HAS BEEN SAID, docked once something has —
943
+ // `center` on the surface, which spaces the pair from outside it,
944
+ // because with a foot the composer is a shell around this frame and
945
+ // a margin given here would land inside the shell.
946
+ // THE SAME MEASURE THE REST OF THIS FILE READS AT. The notice line and
947
+ // the error block are centred. A line of prose has a
948
+ // comfortable length and a field people write prose into has the same
949
+ // one.
950
+ width: "calc(100% - 32px)",
951
+ maxW: `calc(${MEASURE} - 3rem)`,
952
+ mx: "auto",
953
+ ...(framed ? pane() : null),
954
+ // THE COMPONENT'S OWN SHAPE, and nothing here reaching into it.
955
+ //
956
+ // This carried about twenty style props that rebuilt it into a single
957
+ // capsule: `flexDirection: "row"` above all, which lays the footer row
958
+ // BESIDE a `flex: 1` field instead of under it. The footer row has no
959
+ // width of its own in that arrangement, so send left the viewport at
960
+ // every size but the widest — measured at 1280 it ended at 1315, at
961
+ // 834 at 902, at 390 at 466, and on a phone the field stood 200px tall
962
+ // where a line is 24. Each override was answering damage from the one
963
+ // before it.
964
+ //
965
+ // Composer is a field over a footer row. CHOOSING a model is a
966
+ // preference and not part of every message, so Settings owns the
967
+ // choice — but the row still NAMES what is about to answer, because
968
+ // `served()` may not send what was asked for.
969
+ //
970
+ // That fallback is written against this: a preference the gateway
971
+ // does not carry gives way to a model it does, "because a chat that
972
+ // works on a model the reader can see beats a chat that refuses on a
973
+ // name nobody serves". Measured on production, neither `enso` nor
974
+ // `free` is in the catalogue — every turn lands on the first served
975
+ // model — so without this the substitution happens in silence and the
976
+ // sentence justifying it is not true.
977
+ //
978
+ // Pressing it opens the pane that owns the choice, which is the one
979
+ // place to change it.
980
+ // Everything that is not the field, at the end of the row: Enso, the
981
+ // mic, send. Send is drawn here because this slot replaces the
982
+ // package's button and `streaming` still has to become stop; it keeps
983
+ // `data-slot="composer-send"` so a driver finds it, and it is the same
984
+ // `Control` circle as the mic, filled.
985
+ 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, children: (state) => (_jsx(Mic, { size: 15, fill: state === "idle" ? "none" : "currentColor" })) }) }), _jsx(Control, { type: "button", size: ROUND, fill: true, "data-slot": "composer-send", "aria-label": chat.streaming ? "Stop" : "Send", opacity: chat.streaming || draft.trim() ? 1 : 0.4, onClick: () => {
986
+ if (chat.streaming)
987
+ return chat.stop();
988
+ const text = draft.trim();
989
+ if (!text)
990
+ return;
991
+ setDraft("");
992
+ guarded(text);
993
+ }, children: chat.streaming ? (_jsx(Square, { size: 13, fill: "currentColor" })) : (_jsx(ArrowUp, { size: 15, strokeWidth: 2.5 })) })] })),
994
+ // WHAT THE PLAN HAS LEFT, under the frame and at its measure, for a
995
+ // plan holder only: Free keeps the free lane's own notice.
996
+ 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,
997
+ } }), _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) })] }));
998
+ }
999
+ /** The agreement free asks for, in @hanzo/ai's words so every surface says one thing. */
1000
+ function Consent({ question, onAgree, onCancel, }) {
1001
+ return (_jsx(YStack, { position: "absolute", t: 0, l: 0, r: 0, b: 0, items: "center", justify: "center", p: "$4", z: 40, bg: SCRIM, children: _jsxs(YStack, { width: "100%", maxW: 512, px: "$5", py: "$5", gap: "$4", rounded: "$4", borderWidth: 1, borderColor: "$borderColor", bg: "$background", children: [_jsx(Text, { render: "h2", fontSize: "$6", fontWeight: "500", color: "$ink", children: freeCopy.consentTitle }), question ? (_jsxs(Text, { fontSize: "$3", color: "$soft", numberOfLines: 2, children: ["Your question is waiting: \u201C", question, "\u201D"] })) : null, _jsx(Text, { fontSize: "$4", color: "$soft", children: freeCopy.consentBody }), _jsx(YStack, { gap: "$2", children: freeCopy.consentPoints.map((point) => (_jsx(Text, { fontSize: "$3", color: "$soft", children: point }, point))) }), _jsxs(XStack, { gap: "$3", flexWrap: "wrap", children: [_jsx(Button, { onClick: onAgree, children: freeCopy.consentAgreeCta }), _jsx(Button, { variant: "outline", onClick: onCancel, children: freeCopy.consentCancelCta })] }), _jsxs(Text, { fontSize: "$2", color: "$soft", children: [freeCopy.termsText, " ", _jsx(Anchor, { href: site("/legal/terms"), children: "Terms" }), " · ", _jsx(Anchor, { href: site("/legal/privacy"), children: "Privacy" })] })] }) }));
1002
+ }
1003
+ function Work({ persona, room, model, messages, }) {
1004
+ const done = useMemo(() => {
1005
+ // A result arrives as its own turn, keyed back to the call that asked for
1006
+ // it — so the pairing is a lookup and not an assumption about order.
1007
+ const back = new Map();
1008
+ for (const m of messages) {
1009
+ if (m.role === "tool" && m.tool_call_id) {
1010
+ back.set(m.tool_call_id, typeof m.content === "string" ? m.content : "");
1011
+ }
1012
+ }
1013
+ return messages
1014
+ .flatMap((m) => m.tool_calls ?? [])
1015
+ .map((call) => ({
1016
+ id: call.id,
1017
+ name: call.function.name,
1018
+ asked: call.function.arguments,
1019
+ got: back.get(call.id) ?? null,
1020
+ }));
1021
+ }, [messages]);
1022
+ /**
1023
+ * WHAT THE CONVERSATION MADE.
1024
+ *
1025
+ * A fenced block in an answer is a thing rather than a sentence — a file, a
1026
+ * command, a page — and read inline it scrolls away with the talk. Here it
1027
+ * keeps a label and a copy control, which is what somebody came to a code
1028
+ * block to do.
1029
+ *
1030
+ * Read from the turns rather than tracked as it arrives: the transcript is
1031
+ * already the record, and a second list of what was made would be a second
1032
+ * thing to keep in step with it.
1033
+ */
1034
+ const made = useMemo(() => {
1035
+ const out = [];
1036
+ for (const m of messages) {
1037
+ if (m.role !== "assistant")
1038
+ continue;
1039
+ const text = typeof m.content === "string" ? m.content : "";
1040
+ for (const block of text.matchAll(/```([\w.+-]*)\n([\s\S]*?)```/g)) {
1041
+ const code = block[2].replace(/\s+$/, "");
1042
+ if (code)
1043
+ out.push({ id: `${m.id}-${out.length}`, language: block[1] || "text", code });
1044
+ }
1045
+ }
1046
+ return out;
1047
+ }, [messages]);
1048
+ return (_jsxs(YStack, { p: "$3", gap: "$4", children: [_jsxs(YStack, { gap: "$1", children: [_jsx(Text, { fontSize: "$1", color: "$soft", textTransform: "uppercase", children: "Answering" }), room.length ? (room.map((name) => (_jsxs(XStack, { items: "center", gap: "$2", children: [_jsx(Face, { name: name, size: 22 }), _jsx(Text, { fontSize: "$2", color: "$ink", numberOfLines: 1, children: name })] }, name)))) : (_jsx(Text, { fontSize: "$2", color: "$ink", numberOfLines: 1, children: model })), room.length === 1 && persona ? (_jsx(Text, { fontSize: "$1", color: "$soft", numberOfLines: 3, children: persona.description || model })) : null] }), made.length > 0 ? (_jsxs(YStack, { gap: "$2", children: [_jsx(Text, { fontSize: "$1", color: "$soft", textTransform: "uppercase", children: "Artifacts" }), made.map((one) => (_jsx(Code, { language: one.language, value: one.code, children: one.code }, one.id)))] })) : null, _jsxs(YStack, { gap: "$2", children: [_jsx(Text, { fontSize: "$1", color: "$soft", textTransform: "uppercase", children: "Work" }), done.length === 0 ? (_jsx(Text, { fontSize: "$2", color: "$soft", children: "Nothing was looked up for this conversation yet." })) : (done.map((call) => (_jsxs(YStack, { gap: "$1", p: "$2", rounded: "$3", borderWidth: 1, borderColor: "$borderColor", children: [_jsx(Text, { fontSize: "$2", color: "$ink", children: call.name }), _jsx(Text, { fontSize: "$1", color: "$soft", numberOfLines: 2, children: call.asked }), _jsx(Text, { fontSize: "$1", color: "$soft", numberOfLines: 6, children: call.got ?? "Running…" })] }, call.id))))] })] }));
1049
+ }