fauna-api 0.0.0-stage → 1.0.679

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 (171) hide show
  1. package/dist/brands.d.ts +2 -0
  2. package/dist/brands.js +2 -0
  3. package/dist/index.d.ts +64 -0
  4. package/dist/index.js +121 -0
  5. package/dist/src/ant/chat.d.ts +130 -0
  6. package/dist/src/ant/chat.js +467 -0
  7. package/dist/src/ant/format.d.ts +40 -0
  8. package/dist/src/ant/format.js +143 -0
  9. package/dist/src/ant/graph.d.ts +130 -0
  10. package/dist/src/ant/graph.js +449 -0
  11. package/dist/src/ant/index.d.ts +16 -0
  12. package/dist/src/ant/index.js +61 -0
  13. package/dist/src/ant/persona.d.ts +151 -0
  14. package/dist/src/ant/persona.js +136 -0
  15. package/dist/src/ant/timeline.d.ts +283 -0
  16. package/dist/src/ant/timeline.js +1025 -0
  17. package/dist/src/authentification.d.ts +45 -0
  18. package/dist/src/authentification.js +194 -0
  19. package/dist/src/billing.d.ts +135 -0
  20. package/dist/src/billing.js +128 -0
  21. package/dist/src/calendar.d.ts +83 -0
  22. package/dist/src/calendar.js +133 -0
  23. package/dist/src/chats.d.ts +113 -0
  24. package/dist/src/chats.js +100 -0
  25. package/dist/src/cicada/contacts.d.ts +31 -0
  26. package/dist/src/cicada/contacts.js +28 -0
  27. package/dist/src/cicada/conversations.d.ts +73 -0
  28. package/dist/src/cicada/conversations.js +236 -0
  29. package/dist/src/cicada/inboxes.d.ts +48 -0
  30. package/dist/src/cicada/inboxes.js +82 -0
  31. package/dist/src/cicada/index.d.ts +16 -0
  32. package/dist/src/cicada/index.js +61 -0
  33. package/dist/src/cicada/phone.d.ts +48 -0
  34. package/dist/src/cicada/phone.js +306 -0
  35. package/dist/src/drive.d.ts +80 -0
  36. package/dist/src/drive.js +472 -0
  37. package/dist/src/extensions/index.d.ts +4 -0
  38. package/dist/src/extensions/index.js +5 -0
  39. package/dist/src/extensions/web/index.d.ts +6 -0
  40. package/dist/src/extensions/web/index.js +7 -0
  41. package/dist/src/extensions/web/session.d.ts +108 -0
  42. package/dist/src/extensions/web/session.js +470 -0
  43. package/dist/src/extensions/web/tools.d.ts +13 -0
  44. package/dist/src/extensions/web/tools.js +72 -0
  45. package/dist/src/friends.d.ts +39 -0
  46. package/dist/src/friends.js +119 -0
  47. package/dist/src/groups.d.ts +82 -0
  48. package/dist/src/groups.js +269 -0
  49. package/dist/src/inbox.d.ts +11 -0
  50. package/dist/src/inbox.js +35 -0
  51. package/dist/src/integrations.d.ts +52 -0
  52. package/dist/src/integrations.js +205 -0
  53. package/dist/src/magpie/explorer.d.ts +14 -0
  54. package/dist/src/magpie/explorer.js +36 -0
  55. package/dist/src/magpie/index.d.ts +16 -0
  56. package/dist/src/magpie/index.js +61 -0
  57. package/dist/src/magpie/portfolio.d.ts +52 -0
  58. package/dist/src/magpie/portfolio.js +179 -0
  59. package/dist/src/magpie/quotes.d.ts +35 -0
  60. package/dist/src/magpie/quotes.js +47 -0
  61. package/dist/src/magpie/room.d.ts +61 -0
  62. package/dist/src/magpie/room.js +238 -0
  63. package/dist/src/maps.d.ts +159 -0
  64. package/dist/src/maps.js +171 -0
  65. package/dist/src/marmot/browser.d.ts +139 -0
  66. package/dist/src/marmot/browser.js +389 -0
  67. package/dist/src/marmot/index.d.ts +20 -0
  68. package/dist/src/marmot/index.js +65 -0
  69. package/dist/src/marmot/media.d.ts +56 -0
  70. package/dist/src/marmot/media.js +265 -0
  71. package/dist/src/marmot/research.d.ts +337 -0
  72. package/dist/src/marmot/research.js +1138 -0
  73. package/dist/src/marmot/scraper.d.ts +59 -0
  74. package/dist/src/marmot/scraper.js +94 -0
  75. package/dist/src/marmot/web.d.ts +402 -0
  76. package/dist/src/marmot/web.js +2586 -0
  77. package/dist/src/marmot/workspace.d.ts +81 -0
  78. package/dist/src/marmot/workspace.js +263 -0
  79. package/dist/src/parrot/agent.d.ts +53 -0
  80. package/dist/src/parrot/agent.js +93 -0
  81. package/dist/src/parrot/chat.d.ts +84 -0
  82. package/dist/src/parrot/chat.js +237 -0
  83. package/dist/src/parrot/format.d.ts +128 -0
  84. package/dist/src/parrot/format.js +639 -0
  85. package/dist/src/parrot/generative.d.ts +261 -0
  86. package/dist/src/parrot/generative.js +185 -0
  87. package/dist/src/parrot/index.d.ts +22 -0
  88. package/dist/src/parrot/index.js +67 -0
  89. package/dist/src/parrot/room.d.ts +225 -0
  90. package/dist/src/parrot/room.js +466 -0
  91. package/dist/src/parrot/routing.d.ts +90 -0
  92. package/dist/src/parrot/routing.js +78 -0
  93. package/dist/src/parrot/share.d.ts +126 -0
  94. package/dist/src/parrot/share.js +261 -0
  95. package/dist/src/parrot/socket.d.ts +41 -0
  96. package/dist/src/parrot/socket.js +478 -0
  97. package/dist/src/payments.d.ts +73 -0
  98. package/dist/src/payments.js +107 -0
  99. package/dist/src/private/cards.css +75 -0
  100. package/dist/src/rooms.d.ts +76 -0
  101. package/dist/src/rooms.js +168 -0
  102. package/dist/src/shared/schema.d.ts +24 -0
  103. package/dist/src/shared/schema.js +1 -0
  104. package/dist/src/shared/server.d.ts +65 -0
  105. package/dist/src/shared/server.js +302 -0
  106. package/dist/src/termite/environment.d.ts +110 -0
  107. package/dist/src/termite/environment.js +263 -0
  108. package/dist/src/termite/index.d.ts +14 -0
  109. package/dist/src/termite/index.js +59 -0
  110. package/dist/src/termite/os.d.ts +326 -0
  111. package/dist/src/termite/os.js +572 -0
  112. package/dist/src/termite/versioning.d.ts +94 -0
  113. package/dist/src/termite/versioning.js +217 -0
  114. package/dist/src/tickets.d.ts +43 -0
  115. package/dist/src/tickets.js +138 -0
  116. package/dist/src/travel.d.ts +85 -0
  117. package/dist/src/travel.js +44 -0
  118. package/dist/src/ui/authentification.d.ts +24 -0
  119. package/dist/src/ui/authentification.js +177 -0
  120. package/dist/src/ui/brands.css +11 -0
  121. package/dist/src/ui/brands.d.ts +7 -0
  122. package/dist/src/ui/brands.js +14 -0
  123. package/dist/src/ui/browser.d.ts +74 -0
  124. package/dist/src/ui/browser.js +470 -0
  125. package/dist/src/ui/chat.d.ts +183 -0
  126. package/dist/src/ui/chat.js +2379 -0
  127. package/dist/src/ui/chatPanel.d.ts +109 -0
  128. package/dist/src/ui/chatPanel.js +768 -0
  129. package/dist/src/ui/chatSequence.d.ts +36 -0
  130. package/dist/src/ui/chatSequence.js +42 -0
  131. package/dist/src/ui/crown.d.ts +19 -0
  132. package/dist/src/ui/crown.js +199 -0
  133. package/dist/src/ui/cursor.d.ts +73 -0
  134. package/dist/src/ui/cursor.js +175 -0
  135. package/dist/src/ui/device.d.ts +33 -0
  136. package/dist/src/ui/device.js +49 -0
  137. package/dist/src/ui/drive.d.ts +69 -0
  138. package/dist/src/ui/drive.js +1243 -0
  139. package/dist/src/ui/generative.d.ts +39 -0
  140. package/dist/src/ui/generative.js +313 -0
  141. package/dist/src/ui/hover.d.ts +6 -0
  142. package/dist/src/ui/hover.js +210 -0
  143. package/dist/src/ui/index.d.ts +42 -0
  144. package/dist/src/ui/index.js +87 -0
  145. package/dist/src/ui/integrations.d.ts +13 -0
  146. package/dist/src/ui/integrations.js +175 -0
  147. package/dist/src/ui/maps.d.ts +87 -0
  148. package/dist/src/ui/maps.js +547 -0
  149. package/dist/src/ui/roster.d.ts +32 -0
  150. package/dist/src/ui/roster.js +163 -0
  151. package/dist/src/ui/talk.d.ts +12 -0
  152. package/dist/src/ui/talk.js +560 -0
  153. package/dist/src/ui/tickets.d.ts +13 -0
  154. package/dist/src/ui/tickets.js +966 -0
  155. package/dist/src/ui/toast.d.ts +7 -0
  156. package/dist/src/ui/toast.js +258 -0
  157. package/dist/src/ui/tokenText.d.ts +18 -0
  158. package/dist/src/ui/tokenText.js +96 -0
  159. package/dist/src/ui/tokenized.d.ts +42 -0
  160. package/dist/src/ui/tokenized.js +622 -0
  161. package/dist/src/ui/ui.css +4763 -0
  162. package/dist/src/ui/view.d.ts +23 -0
  163. package/dist/src/ui/view.js +584 -0
  164. package/dist/src/view.d.ts +113 -0
  165. package/dist/src/view.js +386 -0
  166. package/dist/src/webhooks.d.ts +66 -0
  167. package/dist/src/webhooks.js +78 -0
  168. package/dist/ui.d.ts +1 -0
  169. package/dist/ui.js +1 -0
  170. package/package.json +87 -4
  171. package/README.md +0 -3
@@ -0,0 +1,2379 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useLayoutEffect, useImperativeHandle, useMemo, useRef, useState, forwardRef } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { flushSync } from "react-dom";
5
+ import gsap from "gsap";
6
+ import { marked } from "marked";
7
+ import { Fauna } from "fauna-api";
8
+ import { mimeFromFileName } from "../marmot/media.js";
9
+ import { createTokenizedInput, parseRichText, toPlainText, hasTokens } from "./tokenized.js";
10
+ import { roomPartsToChatFields } from "./chatSequence.js";
11
+ import { ChatPanel, askForComputer, attachmentOf, buildAttachmentCard, chatToolLabel, createPanelBus, execOutput, mountRepositoryControl, openViewModal, paintPreview, roomHas, showPreview } from "./chatPanel.js";
12
+ export { RepositoryControl, askForComputer, pickedRepository } from "./chatPanel.js";
13
+ import { createTooltip } from "./hover.js";
14
+ import { sendInformaticToast } from "./toast.js";
15
+ import { buildAvatar, createSelector, stampModelAvatar, stampUserAvatar } from "./generative.js";
16
+ import { authorColorSeed, colorFromSeed } from "./cursor.js";
17
+ // Re-export so apps can flatten serialized token markers (e.g. before sending
18
+ // history to the LLM) without reaching into the tokenized-input module.
19
+ export { toPlainText } from "./tokenized.js";
20
+ // ---- Message builders ----
21
+ function buildThinkingIndicator() {
22
+ const indicator = document.createElement('div');
23
+ indicator.className = 'chat-thinking';
24
+ const icon = document.createElement('span');
25
+ icon.className = 'material-symbols-outlined';
26
+ icon.textContent = 'neurology';
27
+ indicator.appendChild(icon);
28
+ const label = document.createElement('span');
29
+ label.textContent = 'Thinking';
30
+ indicator.appendChild(label);
31
+ return indicator;
32
+ }
33
+ export function toolChip(call, icon, label) {
34
+ const chip = document.createElement('div');
35
+ chip.className = 'chat-tool-chip';
36
+ if (call.status)
37
+ chip.classList.add(`chat-tool-chip-${call.status}`);
38
+ const iconEl = document.createElement('span');
39
+ iconEl.className = 'material-symbols-outlined';
40
+ iconEl.textContent = icon;
41
+ chip.appendChild(iconEl);
42
+ const labelEl = document.createElement('span');
43
+ labelEl.className = 'chat-tool-chip-label';
44
+ labelEl.textContent = label;
45
+ chip.appendChild(labelEl);
46
+ return chip;
47
+ }
48
+ function webResults(call, query) {
49
+ const root = document.createElement('details');
50
+ root.className = 'chat-tool-web';
51
+ const summary = document.createElement('summary');
52
+ summary.appendChild(toolChip(call, 'travel_explore', `Searched the web for ${query}`));
53
+ root.appendChild(summary);
54
+ const list = document.createElement('div');
55
+ list.className = 'chat-tool-web-sources';
56
+ for (const result of JSON.parse(String(call.result))) {
57
+ const link = document.createElement('a');
58
+ link.className = 'chat-tool-web-source';
59
+ link.href = result.url;
60
+ link.target = '_blank';
61
+ link.rel = 'noopener';
62
+ const title = document.createElement('span');
63
+ title.className = 'chat-tool-web-source-title';
64
+ title.textContent = result.title || result.url;
65
+ const host = document.createElement('span');
66
+ host.className = 'chat-tool-web-source-host';
67
+ host.textContent = new URL(result.url).hostname;
68
+ link.append(title, host);
69
+ list.appendChild(link);
70
+ }
71
+ root.appendChild(list);
72
+ return root;
73
+ }
74
+ function renderTool(call) {
75
+ const { name, args, status } = call;
76
+ if (name === 'observe_memory') {
77
+ const observation = String(args.observation ?? '').trim().slice(0, 72);
78
+ const label = status === 'success'
79
+ ? `Saved memory, ${observation}`
80
+ : status === 'error'
81
+ ? `Memory was not saved, ${observation}`
82
+ : `Remembering ${observation}`;
83
+ return toolChip(call, 'psychology', label);
84
+ }
85
+ if (name === 'query_web') {
86
+ const query = String(args.query ?? '').trim();
87
+ if (status === 'success')
88
+ return webResults(call, query);
89
+ return toolChip(call, 'travel_explore', status === 'error' ? 'Web search failed' : `Searching the web for ${query}`);
90
+ }
91
+ const chat = chatToolLabel(call);
92
+ if (chat) {
93
+ const output = name === 'exec' && status !== 'running' ? execOutput(call.result) : status === 'error' ? String(call.result ?? '').replace(/^Error:\s*/, '') : '';
94
+ if (!output)
95
+ return toolChip(call, chat.icon, chat.label);
96
+ const root = document.createElement('details');
97
+ root.className = 'chat-tool-web chat-tool-output';
98
+ const summary = document.createElement('summary');
99
+ summary.appendChild(toolChip(call, chat.icon, chat.label));
100
+ const pre = document.createElement('pre');
101
+ pre.textContent = output.length > 20000 ? `${output.slice(0, 20000)}\n\u2026` : output;
102
+ root.append(summary, pre);
103
+ return root;
104
+ }
105
+ return toolChip(call, 'build', status === 'error' ? `${name} failed` : name);
106
+ }
107
+ function renderToolCallElement(call, override) {
108
+ return override?.(call) ?? renderTool(call);
109
+ }
110
+ function buildFileElement(file, iconMap, cards) {
111
+ const code = typeof file.part?.accessCode === 'string' ? file.part.accessCode : '';
112
+ const preview = code && cards?.authorization
113
+ ? {
114
+ key: file.part.ref || code,
115
+ file: Fauna.Drive.fromAccessCode(cards.authorization, code, { name: file.name, contentType: file.mime, contentLength: Number(file.part.contentLength ?? 0) }),
116
+ }
117
+ : undefined;
118
+ const open = cards?.open;
119
+ const card = buildAttachmentCard(file.name, file.mime, open && file.part ? () => open(file) : undefined, iconMap, preview);
120
+ if (file.part?.ref)
121
+ card.dataset.ref = file.part.ref;
122
+ return card;
123
+ }
124
+ // A read-only token chip for rendered messages — mirrors the composer's
125
+ // .tokenized-token visually but without the editable/remove affordances.
126
+ function buildMessageToken(token, opts) {
127
+ const span = document.createElement('span');
128
+ span.className = 'chat-message-token';
129
+ if (opts?.onClick) {
130
+ span.style.cursor = 'pointer';
131
+ span.addEventListener('click', (e) => {
132
+ e.stopPropagation();
133
+ opts.onClick?.(token);
134
+ });
135
+ }
136
+ if (token.icon) {
137
+ const icon = document.createElement('span');
138
+ icon.className = 'material-symbols-outlined chat-message-token-icon';
139
+ icon.textContent = token.icon;
140
+ if (opts?.color)
141
+ icon.style.color = opts.color;
142
+ span.appendChild(icon);
143
+ }
144
+ const label = document.createElement('span');
145
+ label.className = 'chat-message-token-label';
146
+ label.textContent = token.label || token.value;
147
+ span.appendChild(label);
148
+ return span;
149
+ }
150
+ // Render a (possibly token-bearing) message string into `textEl`. When the
151
+ // string carries no token markers we keep the original markdown rendering, so
152
+ // model output and plain user text are unchanged. When tokens are present we
153
+ // render text segments as plain text and hydrate each marker into a chip.
154
+ function renderRichText(textEl, text, opts) {
155
+ if (!hasTokens(text)) {
156
+ textEl.innerHTML = marked.parse(text);
157
+ return;
158
+ }
159
+ for (const part of parseRichText(text)) {
160
+ if (part.kind === 'token') {
161
+ textEl.appendChild(buildMessageToken(part.token, {
162
+ onClick: opts?.onTokenClick,
163
+ color: opts?.tokenColor,
164
+ }));
165
+ continue;
166
+ }
167
+ const lines = part.text.split('\n');
168
+ for (let i = 0; i < lines.length; i++) {
169
+ if (lines[i].length > 0)
170
+ textEl.appendChild(document.createTextNode(lines[i]));
171
+ if (i < lines.length - 1)
172
+ textEl.appendChild(document.createElement('br'));
173
+ }
174
+ }
175
+ }
176
+ function getOrCreateActions(el) {
177
+ let actions = el.querySelector('.chat-message-actions');
178
+ if (!actions) {
179
+ actions = document.createElement('div');
180
+ actions.className = 'chat-message-actions';
181
+ el.appendChild(actions);
182
+ }
183
+ return actions;
184
+ }
185
+ function formatMessageTime(at) {
186
+ const d = at instanceof Date ? at : new Date(at);
187
+ const day = d.getDate();
188
+ const teen = day % 100;
189
+ const ones = day % 10;
190
+ const ord = teen >= 11 && teen <= 13 ? "th" : ones === 1 ? "st" : ones === 2 ? "nd" : ones === 3 ? "rd" : "th";
191
+ const month = d.toLocaleString(undefined, { month: "long" });
192
+ const year = d.getFullYear();
193
+ const time = d.toLocaleString(undefined, { hour: "numeric", minute: "2-digit", hour12: true });
194
+ return `${month} ${day}${ord}, ${year} ${time}`;
195
+ }
196
+ function addCopyButton(el, getText, createdAt) {
197
+ const actions = getOrCreateActions(el);
198
+ if (!actions.querySelector(".chat-message-time")) {
199
+ const time = document.createElement("span");
200
+ time.className = "chat-message-time";
201
+ time.textContent = formatMessageTime(createdAt);
202
+ actions.appendChild(time);
203
+ }
204
+ const copyBtn = document.createElement('span');
205
+ copyBtn.className = 'material-symbols-outlined';
206
+ copyBtn.textContent = 'content_copy';
207
+ copyBtn.onclick = (e) => {
208
+ e.stopPropagation();
209
+ navigator.clipboard.writeText(getText());
210
+ copyBtn.textContent = 'check';
211
+ setTimeout(() => { copyBtn.textContent = 'content_copy'; }, 1500);
212
+ };
213
+ actions.appendChild(copyBtn);
214
+ }
215
+ function chatMode(mode) {
216
+ return mode === 'thread' ? 'thread' : 'assistant';
217
+ }
218
+ function isIncomingAuthor(message, mode = 'assistant', selfEmail) {
219
+ if (message.role !== 'user')
220
+ return false;
221
+ if (mode !== 'thread')
222
+ return false;
223
+ const email = message.author?.id;
224
+ if (!email || !selfEmail)
225
+ return false;
226
+ return email !== selfEmail;
227
+ }
228
+ function applyUserBubbleLayout(el, incoming, profile) {
229
+ el.querySelector('.chat-message-model-avatar')?.remove();
230
+ el.querySelector(':scope > .chat-message-avatar:not(.chat-message-model-avatar)')?.remove();
231
+ el.classList.remove('chat-message-user', 'chat-message-model', 'chat-message-authored', 'chat-message-model-authored');
232
+ if (incoming) {
233
+ el.classList.add('chat-message-model');
234
+ stampIncomingAvatar(el, profile);
235
+ }
236
+ else {
237
+ el.classList.add('chat-message-user');
238
+ stampUserAvatar(el, profile);
239
+ }
240
+ }
241
+ function lockedModels(models) {
242
+ if (models === undefined)
243
+ return undefined;
244
+ return Array.isArray(models) ? models : [models];
245
+ }
246
+ function isModelLocked(models) {
247
+ const list = lockedModels(models);
248
+ return Boolean(list && list.length === 1);
249
+ }
250
+ /** A people only room takes the layout ticket chats use once staff join:
251
+ * threaded bubbles and no model picker. */
252
+ function roomLayout(options) {
253
+ if (!options.room?.room.isHuman())
254
+ return options;
255
+ return { ...options, mode: 'thread', models: Fauna.Parrot.Generative.Text.AUTO };
256
+ }
257
+ const QUICK_REACTIONS = ['👍', '❤️', '😂', '🎉', '😮', '👎'];
258
+ function reactionOf(message) {
259
+ const part = message.parts?.[0];
260
+ return part?.type === 'REACTION' ? part : undefined;
261
+ }
262
+ function stampIncomingAvatar(el, profile) {
263
+ el.querySelector('.chat-message-model-avatar')?.remove();
264
+ el.classList.add('chat-message-model-authored');
265
+ const avatar = buildAvatar(profile);
266
+ avatar.classList.add('chat-message-model-avatar');
267
+ el.appendChild(avatar);
268
+ }
269
+ function appendTextBlock(el, text, message, onTokenClick) {
270
+ const textEl = document.createElement('div');
271
+ textEl.className = 'chat-message-text';
272
+ const authorSeed = authorColorSeed(message.author);
273
+ renderRichText(textEl, text, {
274
+ onTokenClick,
275
+ tokenColor: authorSeed ? colorFromSeed(authorSeed) : undefined,
276
+ });
277
+ el.appendChild(textEl);
278
+ }
279
+ async function buildMessageElement(message, iconMap, modelIcons, renderToolCall, mode, selfUserId, onTokenClick, fileCards) {
280
+ const incoming = isIncomingAuthor(message, chatMode(mode), selfUserId);
281
+ const el = document.createElement('div');
282
+ el.className = (message.role === 'user' && !incoming) ? 'chat-message-user' : 'chat-message-model';
283
+ if (message.role === 'user' && message.author?.id)
284
+ el.dataset.author = message.author.id;
285
+ if (message.sequence?.length) {
286
+ for (const block of message.sequence) {
287
+ if (block.type === 'text')
288
+ appendTextBlock(el, block.text, message, onTokenClick);
289
+ else if (block.type === 'tool')
290
+ el.appendChild(renderToolCallElement(block.call, renderToolCall));
291
+ else
292
+ el.appendChild(buildFileElement(block, iconMap, fileCards));
293
+ }
294
+ }
295
+ else {
296
+ if (message.text)
297
+ appendTextBlock(el, message.text, message, onTokenClick);
298
+ if (message.toolCalls) {
299
+ for (const call of message.toolCalls) {
300
+ el.appendChild(renderToolCallElement(call, renderToolCall));
301
+ }
302
+ }
303
+ if (message.files) {
304
+ for (const file of message.files) {
305
+ el.appendChild(buildFileElement(file, iconMap, fileCards));
306
+ }
307
+ }
308
+ }
309
+ if (message.text) {
310
+ addCopyButton(el, () => toPlainText(message.text), message.createdAt);
311
+ }
312
+ if (incoming && message.author) {
313
+ stampIncomingAvatar(el, message.author.profile);
314
+ }
315
+ else if (message.role === 'user' && message.author) {
316
+ stampUserAvatar(el, message.author.profile);
317
+ }
318
+ if (message.role === 'model') {
319
+ stampModelAvatar(el, message.model, modelIcons);
320
+ }
321
+ return el;
322
+ }
323
+ // ---- Streaming ----
324
+ function nearBottom(messagesEl) {
325
+ return messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight < 80;
326
+ }
327
+ /** Keeps the newest message in view when the reader was at the bottom. Pass
328
+ * `stick` measured before inserting, or a tall message reads as scrolled away. */
329
+ function autoScroll(messagesEl, stick = nearBottom(messagesEl)) {
330
+ if (stick) {
331
+ messagesEl.scrollTop = messagesEl.scrollHeight;
332
+ }
333
+ }
334
+ function createStreamRenderer(modelEl, messagesEl, renderToolCall) {
335
+ // Text is rendered in *segments* — each run of text between tool calls
336
+ // gets its own .chat-message-text div. This way tool chips persist in the
337
+ // DOM between segments, and the visual paragraph break between pre- and
338
+ // post-tool-call text comes for free from the segment divs being separate
339
+ // block-level elements.
340
+ let currentBuffer = '';
341
+ let currentTextEl = null;
342
+ let dotsCleared = false;
343
+ let shown = 0; // chars of currentBuffer currently revealed (typewriter)
344
+ let rafId = null;
345
+ const toolCalls = [];
346
+ const toolElements = new Map();
347
+ const completedSegments = [];
348
+ function clearDots() {
349
+ if (!dotsCleared) {
350
+ // Keep a stamped model avatar (left badge) across the thinking→stream swap.
351
+ for (const child of [...modelEl.childNodes]) {
352
+ if (child.classList?.contains('chat-message-model-avatar'))
353
+ continue;
354
+ child.remove();
355
+ }
356
+ dotsCleared = true;
357
+ }
358
+ }
359
+ function ensureTextEl() {
360
+ if (!currentTextEl) {
361
+ currentTextEl = document.createElement('div');
362
+ currentTextEl.className = 'chat-message-text';
363
+ modelEl.appendChild(currentTextEl);
364
+ }
365
+ }
366
+ // Smoothly reveal buffered text a little each frame so bursty network deltas
367
+ // flow in like a typewriter instead of landing in blocks. The step scales
368
+ // with the backlog: big chunks drain quickly while a steady stream stays
369
+ // just behind the incoming text.
370
+ function renderShown() {
371
+ if (currentTextEl)
372
+ currentTextEl.innerHTML = marked.parse(currentBuffer.slice(0, shown));
373
+ }
374
+ function stopReveal() {
375
+ if (rafId !== null) {
376
+ cancelAnimationFrame(rafId);
377
+ rafId = null;
378
+ }
379
+ }
380
+ function tick() {
381
+ rafId = null;
382
+ if (!modelEl.isConnected)
383
+ return; // message element was removed — stop.
384
+ const remaining = currentBuffer.length - shown;
385
+ if (remaining > 0) {
386
+ shown = Math.min(currentBuffer.length, shown + Math.max(2, Math.ceil(remaining / 4)));
387
+ renderShown();
388
+ autoScroll(messagesEl);
389
+ }
390
+ if (shown < currentBuffer.length)
391
+ rafId = requestAnimationFrame(tick);
392
+ }
393
+ return {
394
+ feedText(delta) {
395
+ currentBuffer += delta;
396
+ clearDots();
397
+ ensureTextEl();
398
+ if (rafId === null)
399
+ rafId = requestAnimationFrame(tick);
400
+ },
401
+ feedToolCall(id, name, args) {
402
+ this.syncToolCalls([{ id, name, args, status: 'running' }]);
403
+ },
404
+ feedToolProgress(name, progress) {
405
+ const stored = [...toolCalls].reverse().find((item) => item.name === name && item.status === 'running');
406
+ if (!stored)
407
+ return;
408
+ this.syncToolCalls([{ ...stored, progress }]);
409
+ },
410
+ syncToolCalls(calls) {
411
+ for (const call of calls) {
412
+ const id = call.id ?? `${call.name}:${JSON.stringify(call.args)}`;
413
+ const view = { ...call, id };
414
+ const existing = toolElements.get(id);
415
+ if (existing) {
416
+ // Room deltas resend every tool on each text chunk; re-rendering
417
+ // unchanged calls would collapse open sources and remount charts.
418
+ const stored = toolCalls.find((item) => item.id === id);
419
+ const changed = stored.status !== call.status
420
+ || stored.result !== call.result
421
+ || stored.session?.code !== call.session?.code
422
+ || (call.status === 'running' && stored.progress !== call.progress);
423
+ if (!changed)
424
+ continue;
425
+ Object.assign(stored, call, { id });
426
+ if (call.status === 'error')
427
+ console.error('[chat] tool failed', call.name, call.result ?? '');
428
+ const el = renderToolCallElement(view, renderToolCall);
429
+ existing.replaceWith(el);
430
+ toolElements.set(id, el);
431
+ continue;
432
+ }
433
+ stopReveal();
434
+ clearDots();
435
+ if (currentBuffer) {
436
+ shown = currentBuffer.length;
437
+ renderShown();
438
+ completedSegments.push(currentBuffer);
439
+ currentBuffer = '';
440
+ currentTextEl = null;
441
+ shown = 0;
442
+ }
443
+ const stored = { ...call, id };
444
+ toolCalls.push(stored);
445
+ const chip = renderToolCallElement(view, renderToolCall);
446
+ toolElements.set(id, chip);
447
+ modelEl.appendChild(chip);
448
+ gsap.from(chip, { opacity: 0, x: -10, duration: 0.3, ease: 'power2.out' });
449
+ }
450
+ autoScroll(messagesEl);
451
+ },
452
+ feedError(message) {
453
+ stopReveal();
454
+ clearDots();
455
+ ensureTextEl();
456
+ currentBuffer = message;
457
+ shown = message.length;
458
+ if (currentTextEl)
459
+ currentTextEl.textContent = message;
460
+ },
461
+ async finalize() {
462
+ stopReveal();
463
+ // Final markdown re-render of the trailing segment + lock it in.
464
+ if (currentBuffer) {
465
+ completedSegments.push(currentBuffer);
466
+ if (currentTextEl) {
467
+ currentTextEl.innerHTML = await marked.parse(currentBuffer);
468
+ }
469
+ }
470
+ autoScroll(messagesEl);
471
+ return {
472
+ role: 'model',
473
+ text: completedSegments.join('\n\n'),
474
+ createdAt: new Date(),
475
+ toolCalls: toolCalls.length > 0 ? toolCalls : undefined
476
+ };
477
+ },
478
+ getText() {
479
+ return [...completedSegments, currentBuffer].filter(Boolean).join('\n\n');
480
+ },
481
+ /** Prefer a longer persisted body over a short live buffer. */
482
+ syncText(full) {
483
+ if (!full)
484
+ return;
485
+ const live = this.getText();
486
+ if (full.length <= live.length)
487
+ return;
488
+ stopReveal();
489
+ clearDots();
490
+ const prefix = completedSegments.join('\n');
491
+ let trailing = full;
492
+ if (prefix && (full.startsWith(prefix) || full.startsWith(completedSegments.join('\n\n')))) {
493
+ trailing = full.slice(prefix.length).replace(/^\n+/, '');
494
+ }
495
+ else if (completedSegments.length === 0) {
496
+ trailing = full;
497
+ }
498
+ currentBuffer = trailing;
499
+ shown = trailing.length;
500
+ ensureTextEl();
501
+ if (currentTextEl)
502
+ currentTextEl.innerHTML = marked.parse(trailing);
503
+ },
504
+ };
505
+ }
506
+ function chatAuthorization(options) {
507
+ return options.authorization ?? options.room?.room.getAuthorization();
508
+ }
509
+ function isSystemRoomMessage(message) {
510
+ return message.role === Fauna.Parrot.Generative.Role.SYSTEM || message.role === 'SYSTEM';
511
+ }
512
+ class ChatEngine {
513
+ get fileCards() {
514
+ return { open: this.openFile, authorization: chatAuthorization(this.options) };
515
+ }
516
+ /** File cards for a streamed reply's attached files, each once. */
517
+ paintStreamFiles(el, files) {
518
+ const room = this.options.room?.room;
519
+ if (!room)
520
+ return;
521
+ let added = false;
522
+ for (const attachment of files) {
523
+ if (!attachment.ref || el.querySelector(`[data-ref="${CSS.escape(attachment.ref)}"]`))
524
+ continue;
525
+ const part = { type: 'FILE', ref: attachment.ref, name: attachment.file.name, contentType: attachment.file.contentType, accessCode: attachment.file.authorization.accessCode };
526
+ const card = buildFileElement({ mime: attachment.file.contentType, name: attachment.file.name, part }, this.options.acceptedTypes, this.fileCards);
527
+ const actions = el.querySelector(':scope > .chat-message-actions');
528
+ if (actions)
529
+ el.insertBefore(card, actions);
530
+ else
531
+ el.appendChild(card);
532
+ added = true;
533
+ }
534
+ if (added) {
535
+ this.refs.panel?.added();
536
+ autoScroll(this.refs.messagesEl);
537
+ }
538
+ }
539
+ constructor(refs, options) {
540
+ Object.defineProperty(this, "refs", {
541
+ enumerable: true,
542
+ configurable: true,
543
+ writable: true,
544
+ value: refs
545
+ });
546
+ Object.defineProperty(this, "options", {
547
+ enumerable: true,
548
+ configurable: true,
549
+ writable: true,
550
+ value: options
551
+ });
552
+ Object.defineProperty(this, "history", {
553
+ enumerable: true,
554
+ configurable: true,
555
+ writable: true,
556
+ value: []
557
+ });
558
+ Object.defineProperty(this, "queue", {
559
+ enumerable: true,
560
+ configurable: true,
561
+ writable: true,
562
+ value: []
563
+ });
564
+ Object.defineProperty(this, "generating", {
565
+ enumerable: true,
566
+ configurable: true,
567
+ writable: true,
568
+ value: false
569
+ });
570
+ Object.defineProperty(this, "generation", {
571
+ enumerable: true,
572
+ configurable: true,
573
+ writable: true,
574
+ value: 0
575
+ });
576
+ Object.defineProperty(this, "destroyed", {
577
+ enumerable: true,
578
+ configurable: true,
579
+ writable: true,
580
+ value: false
581
+ });
582
+ Object.defineProperty(this, "runningParts", {
583
+ enumerable: true,
584
+ configurable: true,
585
+ writable: true,
586
+ value: []
587
+ });
588
+ /** Object URLs for the images waiting to be sent, freed once they're sent or removed. */
589
+ Object.defineProperty(this, "localPreviews", {
590
+ enumerable: true,
591
+ configurable: true,
592
+ writable: true,
593
+ value: []
594
+ });
595
+ Object.defineProperty(this, "activeController", {
596
+ enumerable: true,
597
+ configurable: true,
598
+ writable: true,
599
+ value: null
600
+ });
601
+ Object.defineProperty(this, "roomToolAbort", {
602
+ enumerable: true,
603
+ configurable: true,
604
+ writable: true,
605
+ value: new AbortController()
606
+ });
607
+ Object.defineProperty(this, "steerTarget", {
608
+ enumerable: true,
609
+ configurable: true,
610
+ writable: true,
611
+ value: null
612
+ });
613
+ Object.defineProperty(this, "stopRequested", {
614
+ enumerable: true,
615
+ configurable: true,
616
+ writable: true,
617
+ value: false
618
+ });
619
+ Object.defineProperty(this, "currentModel", {
620
+ enumerable: true,
621
+ configurable: true,
622
+ writable: true,
623
+ value: void 0
624
+ });
625
+ Object.defineProperty(this, "currentReasoning", {
626
+ enumerable: true,
627
+ configurable: true,
628
+ writable: true,
629
+ value: void 0
630
+ });
631
+ /** Catalog icons keyed by text model id — used for MODEL bubble avatars. */
632
+ Object.defineProperty(this, "modelIcons", {
633
+ enumerable: true,
634
+ configurable: true,
635
+ writable: true,
636
+ value: {}
637
+ });
638
+ Object.defineProperty(this, "modelDropdown", {
639
+ enumerable: true,
640
+ configurable: true,
641
+ writable: true,
642
+ value: null
643
+ });
644
+ Object.defineProperty(this, "tokenizedInput", {
645
+ enumerable: true,
646
+ configurable: true,
647
+ writable: true,
648
+ value: void 0
649
+ });
650
+ Object.defineProperty(this, "inputWatchers", {
651
+ enumerable: true,
652
+ configurable: true,
653
+ writable: true,
654
+ value: new Set()
655
+ });
656
+ Object.defineProperty(this, "roomTypingHandler", {
657
+ enumerable: true,
658
+ configurable: true,
659
+ writable: true,
660
+ value: null
661
+ });
662
+ Object.defineProperty(this, "roomCleanup", {
663
+ enumerable: true,
664
+ configurable: true,
665
+ writable: true,
666
+ value: null
667
+ });
668
+ Object.defineProperty(this, "roomThinkingEl", {
669
+ enumerable: true,
670
+ configurable: true,
671
+ writable: true,
672
+ value: null
673
+ });
674
+ // Live streaming state for the room's model reply (server-owned turn).
675
+ Object.defineProperty(this, "roomStream", {
676
+ enumerable: true,
677
+ configurable: true,
678
+ writable: true,
679
+ value: null
680
+ });
681
+ // Originator-only tool progress (e.g. poll 0/n), keyed by tool name. The
682
+ // Parrot SDK does not carry chip/token UI — chat maps this onto chips.
683
+ Object.defineProperty(this, "toolProgress", {
684
+ enumerable: true,
685
+ configurable: true,
686
+ writable: true,
687
+ value: new Map()
688
+ });
689
+ // True while the server is streaming/thinking a room reply. Sends made now
690
+ // are buffered by the server into the shared queue (surfaced via onQueue),
691
+ // not the transcript. Cleared when the reply lands / thinking ends.
692
+ Object.defineProperty(this, "roomThinking", {
693
+ enumerable: true,
694
+ configurable: true,
695
+ writable: true,
696
+ value: false
697
+ });
698
+ /** The person said no to making a repository for computer use. Never saved, so reopening the chat asks again. */
699
+ Object.defineProperty(this, "declinedComputer", {
700
+ enumerable: true,
701
+ configurable: true,
702
+ writable: true,
703
+ value: false
704
+ });
705
+ Object.defineProperty(this, "repositoryControl", {
706
+ enumerable: true,
707
+ configurable: true,
708
+ writable: true,
709
+ value: null
710
+ });
711
+ // Queue chips for the shared server-side queue (buffered sends while the
712
+ // model replies), keyed by durable queue-row id, plus the "Steer all" header.
713
+ // Driven entirely by the room's onQueue hook — never by the local send path.
714
+ Object.defineProperty(this, "roomQueue", {
715
+ enumerable: true,
716
+ configurable: true,
717
+ writable: true,
718
+ value: new Map()
719
+ });
720
+ Object.defineProperty(this, "roomSteerAllEl", {
721
+ enumerable: true,
722
+ configurable: true,
723
+ writable: true,
724
+ value: null
725
+ });
726
+ // Reactions folded onto their target message: target position → emoji →
727
+ // reactor email → the latest state, by the reaction message's position.
728
+ Object.defineProperty(this, "reactions", {
729
+ enumerable: true,
730
+ configurable: true,
731
+ writable: true,
732
+ value: new Map()
733
+ });
734
+ /** Open a file from a message: a tab in the side panel when there is one, else a popup. */
735
+ Object.defineProperty(this, "openFile", {
736
+ enumerable: true,
737
+ configurable: true,
738
+ writable: true,
739
+ value: (file) => {
740
+ const room = this.options.room?.room;
741
+ if (!room || !file.part)
742
+ return;
743
+ const attachment = attachmentOf(room, file.part);
744
+ if (this.refs.panel)
745
+ this.refs.panel.open(attachment);
746
+ else
747
+ openViewModal(attachment);
748
+ }
749
+ });
750
+ // ── Send button ──
751
+ Object.defineProperty(this, "sendTooltip", {
752
+ enumerable: true,
753
+ configurable: true,
754
+ writable: true,
755
+ value: null
756
+ });
757
+ const locked = lockedModels(options.models);
758
+ const start = options.model && (!locked || locked.includes(options.model)) ? options.model : Fauna.Parrot.Generative.Text.AUTO;
759
+ this.currentModel = locked?.length === 1 ? locked[0] : start;
760
+ this.currentReasoning = this.currentModel === start ? options.reasoning : undefined;
761
+ this.tokenizedInput = createTokenizedInput(refs.rowEl, {
762
+ placeholder: options.placeholder ?? 'Type a message...',
763
+ triggers: options.triggers,
764
+ multiline: false,
765
+ onInput: (parts) => {
766
+ this.roomTypingHandler?.();
767
+ for (const handler of this.inputWatchers)
768
+ handler(parts);
769
+ },
770
+ onSubmit: () => {
771
+ this.doSend(this.tokenizedInput.getText(), this.tokenizedInput.getParts(), this.runningParts.length > 0 ? [...this.runningParts] : undefined);
772
+ this.tokenizedInput.clear();
773
+ this.clearParts();
774
+ }
775
+ });
776
+ this.wireModelSelect();
777
+ this.syncModelSelectVisibility();
778
+ this.loadModelIcons();
779
+ this.wireAttach();
780
+ this.wireSend();
781
+ if (options.initialHistory) {
782
+ const gen = this.generation;
783
+ for (const message of options.initialHistory) {
784
+ this.history.push(message);
785
+ buildMessageElement(message, options.acceptedTypes, this.modelIcons, options.renderToolCall, options.mode, options.room?.self.profile.email, options.onTokenClick, this.fileCards).then((el) => {
786
+ if (this.destroyed || this.generation !== gen)
787
+ return;
788
+ refs.messagesEl.appendChild(el);
789
+ });
790
+ }
791
+ }
792
+ if (options.room) {
793
+ this.setupRoomMode(options.room.room, options.room.self);
794
+ }
795
+ }
796
+ // ── Model selector (custom dropdown with provider icons) ──
797
+ syncModelSelectVisibility() {
798
+ const el = this.refs.modelSelect;
799
+ if (!el)
800
+ return;
801
+ el.style.display = isModelLocked(this.options.models) ? 'none' : '';
802
+ }
803
+ wireModelSelect() {
804
+ const container = this.refs.modelSelect;
805
+ if (isModelLocked(this.options.models) || !container || this.modelDropdown)
806
+ return;
807
+ this.modelDropdown = createSelector(container, {
808
+ value: this.currentModel,
809
+ reasoning: this.currentReasoning,
810
+ onChange: (model) => {
811
+ this.currentModel = model;
812
+ this.options.onModelChange?.(this.currentModel);
813
+ },
814
+ onReasoningChange: (reasoning) => {
815
+ this.currentReasoning = reasoning;
816
+ this.options.onReasoningChange?.(reasoning);
817
+ },
818
+ });
819
+ Fauna.Parrot.Routing.getAll()
820
+ .then((catalog) => {
821
+ const textRows = catalog.text ?? [];
822
+ this.rememberModelIcons(textRows);
823
+ const allowed = lockedModels(this.options.models);
824
+ const textModels = (allowed
825
+ ? textRows.filter((l) => allowed.includes(l.model))
826
+ : textRows).map((l) => ({ value: l.model, name: l.name, icon: l.icon, reasoning: l.reasoning }));
827
+ this.modelDropdown?.setOptions(textModels);
828
+ this.modelDropdown?.setValue(this.currentModel);
829
+ this.modelDropdown?.setReasoning(this.currentReasoning);
830
+ })
831
+ .catch((err) => console.error('[chat] failed to load models:', err));
832
+ }
833
+ /** Ensure catalog icons are available even when the model picker is fixed. */
834
+ loadModelIcons() {
835
+ if (this.refs.modelSelect && !isModelLocked(this.options.models))
836
+ return;
837
+ Fauna.Parrot.Routing.getAll()
838
+ .then(({ text }) => this.rememberModelIcons(text ?? []))
839
+ .catch((err) => console.error('[chat] failed to load model icons:', err));
840
+ }
841
+ rememberModelIcons(llms) {
842
+ const next = {};
843
+ for (const l of llms)
844
+ next[l.model] = l.icon;
845
+ this.modelIcons = next;
846
+ // Re-stamp any already-rendered MODEL bubbles that were waiting on icons.
847
+ for (const el of this.refs.messagesEl.querySelectorAll('.chat-message-model')) {
848
+ const model = el.dataset.model || undefined;
849
+ if (model && this.modelIcons[model])
850
+ stampModelAvatar(el, model, this.modelIcons);
851
+ }
852
+ }
853
+ // ── Attach button ──
854
+ wireAttach() {
855
+ const attachBtn = this.refs.attachBtn;
856
+ if (!attachBtn)
857
+ return;
858
+ attachBtn.onmouseenter = () => { gsap.to(attachBtn, { duration: 0.3, rotate: 180, color: 'var(--chat-text, #1a1a1a)', ease: 'power2.inOut' }); };
859
+ attachBtn.onmouseleave = () => { gsap.to(attachBtn, { duration: 0.3, rotate: 0, color: 'var(--chat-accent, #888)', ease: 'power2.inOut' }); };
860
+ attachBtn.onclick = () => {
861
+ if (!this.driveAuth()) {
862
+ this.options.callbacks?.onError?.('Guests cannot attach files.');
863
+ return;
864
+ }
865
+ const input = document.createElement('input');
866
+ input.type = 'file';
867
+ input.style.display = 'none';
868
+ const accepted = this.options.acceptedTypes ?? {};
869
+ if (Object.keys(accepted).length > 0) {
870
+ // Include extension globs so HEIC picks work when browsers omit MIME.
871
+ const accept = [
872
+ ...Object.keys(accepted),
873
+ '.heic', '.heif',
874
+ ];
875
+ input.accept = [...new Set(accept)].join(',');
876
+ }
877
+ input.addEventListener('change', () => {
878
+ if (!input.files)
879
+ return;
880
+ for (const file of input.files) {
881
+ const mime = file.type
882
+ || mimeFromFileName(file.name)
883
+ || '';
884
+ if (Object.keys(accepted).length > 0 && !accepted[mime])
885
+ continue;
886
+ const reader = new FileReader();
887
+ reader.onload = (e) => {
888
+ const buffer = e.target?.result;
889
+ void this.attachPart(mime, file.name, new Uint8Array(buffer));
890
+ };
891
+ reader.readAsArrayBuffer(file);
892
+ }
893
+ });
894
+ document.body.appendChild(input);
895
+ input.click();
896
+ input.remove();
897
+ };
898
+ }
899
+ wireSend() {
900
+ const sendBtn = this.refs.sendBtn;
901
+ this.sendTooltip = createTooltip(sendBtn, 'Send');
902
+ // Rests pointing right (arrow_forward), tilts up on hover.
903
+ sendBtn.onmouseenter = () => { gsap.to(sendBtn, { duration: 0.3, rotate: (this.generating || this.roomThinking) ? 0 : -90, color: 'var(--chat-text, #1a1a1a)', ease: 'power2.inOut' }); };
904
+ sendBtn.onmouseleave = () => { gsap.to(sendBtn, { duration: 0.3, rotate: 0, color: 'var(--chat-accent, #888)', ease: 'power2.inOut' }); };
905
+ sendBtn.onclick = () => {
906
+ // While a reply is running, the button is a stop control. In room mode
907
+ // the model runs server-side, so "stop" means steer the room (cancel
908
+ // the in-flight reply); in solo mode it aborts the local stream.
909
+ if (this.options.room) {
910
+ // Stop control while a reply runs: queue present → steer-all
911
+ // (flush + re-answer). Empty queue → cancel and stay stopped.
912
+ if (this.roomThinking) {
913
+ if (this.roomQueue.size > 0)
914
+ this.steerAllRoom();
915
+ else
916
+ this.stopRoom();
917
+ return;
918
+ }
919
+ }
920
+ else if (this.generating) {
921
+ this.stopGeneration();
922
+ return;
923
+ }
924
+ this.doSend(this.tokenizedInput.getText(), this.tokenizedInput.getParts(), this.runningParts.length > 0 ? [...this.runningParts] : undefined);
925
+ this.tokenizedInput.clear();
926
+ this.clearParts();
927
+ };
928
+ }
929
+ setComposerGenerating(on) {
930
+ const sendBtn = this.refs.sendBtn;
931
+ sendBtn.textContent = on ? 'stop' : 'arrow_forward';
932
+ this.sendTooltip?.setText(on ? 'Stop generating' : 'Send');
933
+ sendBtn.classList.toggle('chat-stop', on);
934
+ }
935
+ stopGeneration() {
936
+ this.steerTarget = null;
937
+ this.stopRequested = true;
938
+ this.activeController?.abort();
939
+ }
940
+ driveAuth() {
941
+ return chatAuthorization(this.options);
942
+ }
943
+ async uploadBytes(mime, name, data) {
944
+ const auth = this.driveAuth();
945
+ if (!auth) {
946
+ this.options.callbacks?.onError?.('Guests cannot attach files.');
947
+ return undefined;
948
+ }
949
+ const dest = `tmp/${crypto.randomUUID()}-${name}`;
950
+ const file = Fauna.Drive.fromBytes(auth, dest, mime, data);
951
+ try {
952
+ await file.upload();
953
+ return file;
954
+ }
955
+ catch (err) {
956
+ this.options.callbacks?.onError?.(err?.message ?? 'Could not upload the file.');
957
+ return undefined;
958
+ }
959
+ }
960
+ // ── Attachment parts ──
961
+ async attachPart(mime, name, data) {
962
+ if (this.runningParts.some((p) => p.name === name || p.name.endsWith(`-${name}`)))
963
+ return;
964
+ const file = await this.uploadBytes(mime, name, data);
965
+ if (!file)
966
+ return;
967
+ if (this.runningParts.length === 0) {
968
+ gsap.to(this.refs.partsEl, { duration: 0.5, height: 120, opacity: 1, ease: 'power2.inOut' });
969
+ }
970
+ const partEl = document.createElement('div');
971
+ partEl.className = 'chat-input-part';
972
+ // Images show from the bytes being sent; browsers can't draw HEIC, so it waits for the server's preview like the rest.
973
+ const local = mime.startsWith('image/') && mime !== 'image/heic' && mime !== 'image/heif'
974
+ ? URL.createObjectURL(new Blob([new Uint8Array(data)], { type: mime }))
975
+ : null;
976
+ if (local)
977
+ this.localPreviews.push(local);
978
+ const deleteBtn = document.createElement('span');
979
+ deleteBtn.className = 'material-symbols-outlined chat-input-part-delete';
980
+ deleteBtn.textContent = 'close';
981
+ partEl.appendChild(deleteBtn);
982
+ deleteBtn.onclick = async () => {
983
+ this.runningParts = this.runningParts.filter((p) => p !== file);
984
+ if (file.online) {
985
+ try {
986
+ await file.remove();
987
+ }
988
+ catch { /* already gone */ }
989
+ }
990
+ await new Promise((resolve) => {
991
+ gsap.to(partEl, { duration: 0.5, opacity: 0, ease: 'power2.inOut', onComplete: resolve });
992
+ });
993
+ if (local) {
994
+ URL.revokeObjectURL(local);
995
+ this.localPreviews = this.localPreviews.filter((url) => url !== local);
996
+ }
997
+ partEl.remove();
998
+ if (this.runningParts.length === 0)
999
+ this.clearParts();
1000
+ };
1001
+ const thumb = document.createElement('span');
1002
+ thumb.className = 'chat-file-thumb chat-input-part-thumb';
1003
+ const previewIcon = document.createElement('span');
1004
+ previewIcon.className = 'material-symbols-outlined chat-input-part-preview';
1005
+ previewIcon.textContent = (this.options.acceptedTypes ?? {})[mime] ?? 'description';
1006
+ thumb.appendChild(previewIcon);
1007
+ partEl.appendChild(thumb);
1008
+ if (local)
1009
+ paintPreview(thumb, local);
1010
+ else
1011
+ showPreview(thumb, { key: `upload:${file.authorization.path ?? name}`, file });
1012
+ const labelEl = document.createElement('a');
1013
+ labelEl.className = 'chat-input-part-label';
1014
+ labelEl.textContent = name;
1015
+ partEl.appendChild(labelEl);
1016
+ this.refs.partsEl.appendChild(partEl);
1017
+ gsap.from(partEl, { duration: 0.5, opacity: 0, ease: 'power2.inOut' });
1018
+ this.runningParts.push(file);
1019
+ }
1020
+ clearParts() {
1021
+ this.runningParts = [];
1022
+ for (const url of this.localPreviews.splice(0))
1023
+ URL.revokeObjectURL(url);
1024
+ while (this.refs.partsEl.firstChild)
1025
+ this.refs.partsEl.firstChild.remove();
1026
+ gsap.to(this.refs.partsEl, { duration: 0.5, height: 0, opacity: 0, ease: 'power2.inOut' });
1027
+ }
1028
+ // ── Send / Queue ──
1029
+ renderUserMessage(message) {
1030
+ const incoming = isIncomingAuthor(message, chatMode(this.options.mode), this.options.room?.self.profile.email);
1031
+ const userEl = document.createElement('div');
1032
+ userEl.className = incoming ? 'chat-message-model' : 'chat-message-user';
1033
+ if (message.role === 'user' && message.author?.id)
1034
+ userEl.dataset.author = message.author.id;
1035
+ if (message.text) {
1036
+ const textEl = document.createElement('div');
1037
+ textEl.className = 'chat-message-text';
1038
+ const authorSeed = authorColorSeed(message.author);
1039
+ renderRichText(textEl, message.text, {
1040
+ onTokenClick: this.options.onTokenClick,
1041
+ tokenColor: authorSeed ? colorFromSeed(authorSeed) : undefined,
1042
+ });
1043
+ userEl.appendChild(textEl);
1044
+ }
1045
+ if (message.files) {
1046
+ for (const file of message.files) {
1047
+ userEl.appendChild(buildFileElement(file, this.options.acceptedTypes, this.fileCards));
1048
+ }
1049
+ }
1050
+ if (message.text) {
1051
+ addCopyButton(userEl, () => toPlainText(message.text), message.createdAt);
1052
+ }
1053
+ if (incoming && message.author) {
1054
+ stampIncomingAvatar(userEl, message.author.profile);
1055
+ }
1056
+ else if (message.author) {
1057
+ stampUserAvatar(userEl, message.author.profile);
1058
+ }
1059
+ return userEl;
1060
+ }
1061
+ doSend(text, parts, files) {
1062
+ if (!text.trim() && (!files || files.length === 0))
1063
+ return;
1064
+ // Room mode: the server owns turn-taking AND append-vs-queue. Just send;
1065
+ // the message either lands in the transcript (fans back as chat-message)
1066
+ // or is buffered into the shared queue (fans back as chat-queue-add). We
1067
+ // never render locally or add a chip here — the room's events are the sole
1068
+ // source of truth, so ordering + the queue stay consistent across members.
1069
+ if (this.options.room) {
1070
+ const roomParts = [];
1071
+ if (text.trim())
1072
+ roomParts.push({ type: 'TEXT', text: text.trim() });
1073
+ for (const file of files ?? [])
1074
+ roomParts.push({ type: 'FILE', file });
1075
+ this.options.room.self.setStatus('');
1076
+ if (this.options.room.room.isHuman()) {
1077
+ void this.options.room.room.sendMessage(Fauna.Parrot.Generative.Role.USER, roomParts)
1078
+ .catch((err) => { this.options.callbacks?.onError?.(err.message); });
1079
+ return;
1080
+ }
1081
+ const toolsOpt = this.options.room.tools;
1082
+ const toolSchemas = toolsOpt && !Array.isArray(toolsOpt)
1083
+ ? toolsOpt.function
1084
+ : [
1085
+ ...(toolsOpt ?? []).map((t) => t.function),
1086
+ ...(this.offersComputer() ? [Fauna.Termite.OS.desktopTool(null)] : []),
1087
+ ];
1088
+ const sent = Array.isArray(toolSchemas) && toolSchemas.length === 0 ? undefined : toolSchemas;
1089
+ void this.options.room.room.sendMessage(Fauna.Parrot.Generative.Role.USER, roomParts, {
1090
+ model: this.currentModel,
1091
+ reasoning: this.currentReasoning,
1092
+ ...(sent !== undefined ? { tools: sent } : {}),
1093
+ })
1094
+ .catch((err) => { this.options.callbacks?.onError?.(err.message); });
1095
+ return;
1096
+ }
1097
+ const userMessage = {
1098
+ role: 'user',
1099
+ text: text.trim(),
1100
+ createdAt: new Date(),
1101
+ parts: parts,
1102
+ files: files?.map((f) => ({ mime: f.contentType, name: f.name }))
1103
+ };
1104
+ if (this.generating) {
1105
+ this.enqueueMessage(text.trim(), parts, files);
1106
+ return;
1107
+ }
1108
+ const userEl = this.renderUserMessage(userMessage);
1109
+ this.refs.messagesEl.appendChild(userEl);
1110
+ gsap.from(userEl, { duration: 0.5, y: 100, opacity: 0, ease: 'power2.inOut' });
1111
+ this.history.push(userMessage);
1112
+ this.options.callbacks?.onMessage?.(userMessage);
1113
+ this.processGeneration(files);
1114
+ }
1115
+ enqueueMessage(text, parts, files) {
1116
+ const chip = document.createElement('div');
1117
+ chip.className = 'chat-queue-item';
1118
+ const icon = document.createElement('span');
1119
+ icon.className = 'material-symbols-outlined chat-queue-item-icon';
1120
+ icon.textContent = 'schedule';
1121
+ chip.appendChild(icon);
1122
+ const label = document.createElement('div');
1123
+ label.className = 'chat-queue-item-label';
1124
+ const preview = toPlainText(text).trim();
1125
+ const fileNote = files && files.length > 0 ? `${files.length} file${files.length > 1 ? 's' : ''}` : '';
1126
+ label.textContent = preview || fileNote || 'Attachment';
1127
+ if (preview && fileNote)
1128
+ label.title = `${preview} · ${fileNote}`;
1129
+ chip.appendChild(label);
1130
+ const actions = document.createElement('div');
1131
+ actions.className = 'chat-queue-item-actions';
1132
+ const steerBtn = document.createElement('span');
1133
+ steerBtn.className = 'material-symbols-outlined chat-queue-steer';
1134
+ steerBtn.textContent = 'bolt';
1135
+ createTooltip(steerBtn, 'Send now — stops the current response and runs this next');
1136
+ steerBtn.onclick = (e) => {
1137
+ e.stopPropagation();
1138
+ if (!this.queue.some((q) => q.element === chip))
1139
+ return;
1140
+ this.steerTarget = chip;
1141
+ if (this.generating)
1142
+ this.activeController?.abort();
1143
+ };
1144
+ actions.appendChild(steerBtn);
1145
+ const removeBtn = document.createElement('span');
1146
+ removeBtn.className = 'material-symbols-outlined chat-queue-remove';
1147
+ removeBtn.textContent = 'close';
1148
+ createTooltip(removeBtn, 'Remove from queue');
1149
+ removeBtn.onclick = (e) => {
1150
+ e.stopPropagation();
1151
+ const idx = this.queue.findIndex((q) => q.element === chip);
1152
+ if (idx >= 0) {
1153
+ if (this.steerTarget === chip)
1154
+ this.steerTarget = null;
1155
+ this.queue.splice(idx, 1);
1156
+ gsap.to(chip, {
1157
+ duration: 0.25, opacity: 0, height: 0, marginBottom: 0, ease: 'power2.in',
1158
+ onComplete: () => { chip.remove(); }
1159
+ });
1160
+ this.options.callbacks?.onQueueChange?.(this.queue.length);
1161
+ }
1162
+ };
1163
+ actions.appendChild(removeBtn);
1164
+ chip.appendChild(actions);
1165
+ this.refs.queueEl.appendChild(chip);
1166
+ gsap.from(chip, { duration: 0.3, opacity: 0, y: 8, ease: 'power2.out' });
1167
+ this.queue.push({ text, parts, files, element: chip });
1168
+ this.options.callbacks?.onQueueChange?.(this.queue.length);
1169
+ }
1170
+ runQueued(index) {
1171
+ const next = this.queue.splice(index, 1)[0];
1172
+ if (!next)
1173
+ return;
1174
+ this.options.callbacks?.onQueueChange?.(this.queue.length);
1175
+ next.element.remove();
1176
+ const queuedMessage = {
1177
+ role: 'user',
1178
+ text: next.text,
1179
+ createdAt: new Date(),
1180
+ parts: next.parts,
1181
+ files: next.files?.map((f) => ({ mime: f.contentType, name: f.name }))
1182
+ };
1183
+ const userEl = this.renderUserMessage(queuedMessage);
1184
+ this.refs.messagesEl.appendChild(userEl);
1185
+ gsap.from(userEl, { duration: 0.5, y: 100, opacity: 0, ease: 'power2.inOut' });
1186
+ this.history.push(queuedMessage);
1187
+ this.options.callbacks?.onMessage?.(queuedMessage);
1188
+ this.processGeneration(next.files);
1189
+ }
1190
+ drainQueue() {
1191
+ if (this.steerTarget) {
1192
+ const target = this.steerTarget;
1193
+ this.steerTarget = null;
1194
+ const idx = this.queue.findIndex((q) => q.element === target);
1195
+ if (idx >= 0) {
1196
+ this.runQueued(idx);
1197
+ return;
1198
+ }
1199
+ }
1200
+ if (this.queue.length > 0)
1201
+ this.runQueued(0);
1202
+ }
1203
+ async processGeneration(files) {
1204
+ if (!this.options.generate)
1205
+ return;
1206
+ if (this.destroyed)
1207
+ return;
1208
+ this.generating = true;
1209
+ this.generation++;
1210
+ this.stopRequested = false;
1211
+ const gen = this.generation;
1212
+ this.setComposerGenerating(true);
1213
+ const controller = new AbortController();
1214
+ this.activeController = controller;
1215
+ const modelEl = document.createElement('div');
1216
+ modelEl.className = 'chat-message-model';
1217
+ modelEl.dataset.model = this.currentModel;
1218
+ modelEl.appendChild(buildThinkingIndicator());
1219
+ stampModelAvatar(modelEl, this.currentModel, this.modelIcons);
1220
+ this.refs.messagesEl.appendChild(modelEl);
1221
+ gsap.from(modelEl, { duration: 0.5, y: 100, opacity: 0, ease: 'power2.inOut' });
1222
+ gsap.to(this.refs.messagesEl, { duration: 0.5, scrollTop: this.refs.messagesEl.scrollHeight, ease: 'power2.inOut' });
1223
+ const renderer = createStreamRenderer(modelEl, this.refs.messagesEl, this.options.renderToolCall);
1224
+ const isStale = () => this.destroyed || this.generation !== gen;
1225
+ const usedModel = this.currentModel;
1226
+ try {
1227
+ const stream = this.options.generate(usedModel, this.history, files ?? [], controller.signal, this.currentReasoning);
1228
+ for await (const event of stream) {
1229
+ if (isStale())
1230
+ break;
1231
+ if (event.event === 'text') {
1232
+ renderer.feedText(event.delta);
1233
+ }
1234
+ else if (event.event === 'tool_call') {
1235
+ renderer.feedToolCall(event.id, event.name, event.args);
1236
+ }
1237
+ else if (event.event === 'error') {
1238
+ renderer.feedError('Something went wrong. Please try again.');
1239
+ this.options.callbacks?.onError?.(event.message);
1240
+ }
1241
+ }
1242
+ }
1243
+ catch (err) {
1244
+ if (!controller.signal.aborted) {
1245
+ console.error(err);
1246
+ if (!renderer.getText()) {
1247
+ renderer.feedError('Something went wrong. Please try again.');
1248
+ }
1249
+ }
1250
+ }
1251
+ if (isStale())
1252
+ return;
1253
+ const aborted = controller.signal.aborted;
1254
+ const modelMessage = await renderer.finalize();
1255
+ modelMessage.model = usedModel;
1256
+ const hasContent = !!modelMessage.text || (modelMessage.toolCalls?.length ?? 0) > 0;
1257
+ if (aborted && !hasContent) {
1258
+ modelEl.remove();
1259
+ }
1260
+ else {
1261
+ if (modelMessage.text) {
1262
+ addCopyButton(modelEl, () => modelMessage.text, modelMessage.createdAt);
1263
+ }
1264
+ stampModelAvatar(modelEl, usedModel, this.modelIcons);
1265
+ this.history.push(modelMessage);
1266
+ this.options.callbacks?.onStreamComplete?.(modelMessage);
1267
+ }
1268
+ this.generating = false;
1269
+ this.activeController = null;
1270
+ this.setComposerGenerating(false);
1271
+ if (this.stopRequested) {
1272
+ this.stopRequested = false;
1273
+ return;
1274
+ }
1275
+ this.drainQueue();
1276
+ }
1277
+ // ── Room mode (multiplayer) ──
1278
+ roomMessageToChat(_room, _self, message) {
1279
+ const isUser = message.role === Fauna.Parrot.Generative.Role.USER;
1280
+ return {
1281
+ role: isUser ? 'user' : 'model',
1282
+ createdAt: message.created ? new Date(message.created) : new Date(),
1283
+ model: isUser ? undefined : message.model,
1284
+ author: isUser && message.author?.email
1285
+ ? { id: message.author.email, profile: message.author }
1286
+ : undefined,
1287
+ ...roomPartsToChatFields(message.parts),
1288
+ };
1289
+ }
1290
+ // Insert a rendered message element at the position matching its durable
1291
+ // `position` in the transcript, so members render in a consistent order
1292
+ // regardless of event-arrival timing (fixes interleaving on multi-member
1293
+ // sends / queue releases). Elements carry `data-position`; we insert before
1294
+ // the first existing element with a greater position, else append.
1295
+ insertByPosition(el, position) {
1296
+ el.dataset.position = String(position);
1297
+ const siblings = this.refs.messagesEl.querySelectorAll('[data-position]');
1298
+ let before = null;
1299
+ for (const sib of siblings) {
1300
+ const p = Number(sib.dataset.position);
1301
+ if (p > position) {
1302
+ before = sib;
1303
+ break;
1304
+ }
1305
+ }
1306
+ if (before)
1307
+ this.refs.messagesEl.insertBefore(el, before);
1308
+ else
1309
+ this.refs.messagesEl.appendChild(el);
1310
+ }
1311
+ paintedMessage(messageId) {
1312
+ if (!messageId)
1313
+ return null;
1314
+ const escaped = typeof CSS !== "undefined" && CSS.escape
1315
+ ? CSS.escape(messageId)
1316
+ : messageId.replace(/["\\]/g, "");
1317
+ return this.refs.messagesEl.querySelector(`[data-message-id="${escaped}"]`);
1318
+ }
1319
+ renderRoomMessage(chat, messageId, position) {
1320
+ if (this.destroyed)
1321
+ return;
1322
+ if (this.paintedMessage(messageId))
1323
+ return;
1324
+ const gen = this.generation;
1325
+ const stick = nearBottom(this.refs.messagesEl);
1326
+ const el = chat.role === 'user' ? this.renderUserMessage(chat) : undefined;
1327
+ if (el) {
1328
+ if (messageId)
1329
+ el.dataset.messageId = messageId;
1330
+ if (position !== undefined)
1331
+ this.insertByPosition(el, position);
1332
+ else
1333
+ this.refs.messagesEl.appendChild(el);
1334
+ if (position !== undefined)
1335
+ this.enableReactions(el, position);
1336
+ gsap.from(el, { duration: 0.4, y: 40, opacity: 0, ease: 'power2.out' });
1337
+ }
1338
+ else {
1339
+ buildMessageElement(chat, this.options.acceptedTypes, this.modelIcons, this.options.renderToolCall, this.options.mode, this.options.room?.self.profile.email, this.options.onTokenClick, this.fileCards).then((built) => {
1340
+ if (this.destroyed || this.generation !== gen)
1341
+ return;
1342
+ if (this.paintedMessage(messageId))
1343
+ return;
1344
+ if (messageId)
1345
+ built.dataset.messageId = messageId;
1346
+ if (chat.model)
1347
+ built.dataset.model = chat.model;
1348
+ if (position !== undefined)
1349
+ this.insertByPosition(built, position);
1350
+ else
1351
+ this.refs.messagesEl.appendChild(built);
1352
+ if (position !== undefined)
1353
+ this.enableReactions(built, position);
1354
+ gsap.from(built, { duration: 0.4, y: 40, opacity: 0, ease: 'power2.out' });
1355
+ autoScroll(this.refs.messagesEl, stick);
1356
+ });
1357
+ }
1358
+ this.history.push(chat);
1359
+ autoScroll(this.refs.messagesEl, stick);
1360
+ }
1361
+ // ── Reactions ──
1362
+ // A reaction arrives as its own room message; it never paints a bubble.
1363
+ // It folds into a row of emoji under the message at its target position.
1364
+ applyReaction(message, reaction) {
1365
+ const email = message.author?.email;
1366
+ if (!email)
1367
+ return;
1368
+ let byEmoji = this.reactions.get(reaction.position);
1369
+ if (!byEmoji) {
1370
+ byEmoji = new Map();
1371
+ this.reactions.set(reaction.position, byEmoji);
1372
+ }
1373
+ let byPerson = byEmoji.get(reaction.emoji);
1374
+ if (!byPerson) {
1375
+ byPerson = new Map();
1376
+ byEmoji.set(reaction.emoji, byPerson);
1377
+ }
1378
+ const latest = byPerson.get(email);
1379
+ if (latest && latest.at > message.position)
1380
+ return;
1381
+ const name = message.author?.displayName ?? email.split('@')[0];
1382
+ byPerson.set(email, { on: reaction.on, at: message.position, name });
1383
+ this.paintReactions(reaction.position);
1384
+ }
1385
+ /** Hover action to react, plus the folded row for any reactions already in. */
1386
+ enableReactions(el, position) {
1387
+ if (!this.options.room)
1388
+ return;
1389
+ const actions = getOrCreateActions(el);
1390
+ if (!actions.querySelector('.chat-react-button')) {
1391
+ const button = document.createElement('span');
1392
+ button.className = 'material-symbols-outlined chat-react-button';
1393
+ button.textContent = 'add_reaction';
1394
+ createTooltip(button, 'React');
1395
+ button.onclick = (e) => {
1396
+ e.stopPropagation();
1397
+ this.openReactionPicker(el, position);
1398
+ };
1399
+ actions.prepend(button);
1400
+ }
1401
+ this.paintReactions(position);
1402
+ }
1403
+ openReactionPicker(el, position) {
1404
+ el.querySelector('.chat-reaction-picker')?.remove();
1405
+ const picker = document.createElement('div');
1406
+ picker.className = 'chat-reaction-picker';
1407
+ for (const emoji of QUICK_REACTIONS) {
1408
+ const option = document.createElement('button');
1409
+ option.type = 'button';
1410
+ option.textContent = emoji;
1411
+ option.onclick = (e) => {
1412
+ e.stopPropagation();
1413
+ picker.remove();
1414
+ this.react(position, emoji, !this.reactedWith(position, emoji));
1415
+ };
1416
+ picker.appendChild(option);
1417
+ }
1418
+ el.insertBefore(picker, el.querySelector(':scope > .chat-message-actions'));
1419
+ gsap.from(picker, { duration: 0.2, scale: 0.85, opacity: 0, ease: 'back.out(2)' });
1420
+ const close = (e) => {
1421
+ if (picker.contains(e.target))
1422
+ return;
1423
+ picker.remove();
1424
+ document.removeEventListener('click', close);
1425
+ };
1426
+ setTimeout(() => document.addEventListener('click', close), 0);
1427
+ }
1428
+ reactedWith(position, emoji) {
1429
+ const self = this.options.room?.self.profile.email;
1430
+ return Boolean(self && this.reactions.get(position)?.get(emoji)?.get(self)?.on);
1431
+ }
1432
+ react(position, emoji, on) {
1433
+ void this.options.room?.room.react(position, emoji, on)
1434
+ .catch((err) => { this.options.callbacks?.onError?.(err.message); });
1435
+ }
1436
+ paintReactions(position) {
1437
+ const el = this.refs.messagesEl.querySelector(`[data-position="${position}"]`);
1438
+ if (!el)
1439
+ return;
1440
+ const self = this.options.room?.self.profile.email;
1441
+ const shown = [...(this.reactions.get(position) ?? new Map()).entries()]
1442
+ .map(([emoji, people]) => {
1443
+ const on = [...people.entries()].filter(([, state]) => state.on);
1444
+ return { emoji, names: on.map(([, state]) => state.name), mine: on.some(([email]) => email === self) };
1445
+ })
1446
+ .filter((entry) => entry.names.length > 0);
1447
+ let row = el.querySelector(':scope > .chat-reactions');
1448
+ if (shown.length === 0) {
1449
+ row?.remove();
1450
+ return;
1451
+ }
1452
+ if (!row) {
1453
+ row = document.createElement('div');
1454
+ row.className = 'chat-reactions';
1455
+ const actions = el.querySelector(':scope > .chat-message-actions');
1456
+ if (actions)
1457
+ el.insertBefore(row, actions);
1458
+ else
1459
+ el.appendChild(row);
1460
+ }
1461
+ const before = new Map([...row.children].map((chip) => [chip.dataset.emoji, chip.querySelector('.chat-reaction-count')?.textContent]));
1462
+ row.replaceChildren(...shown.map(({ emoji, names, mine }) => {
1463
+ const chip = document.createElement('button');
1464
+ chip.type = 'button';
1465
+ chip.className = mine ? 'chat-reaction mine' : 'chat-reaction';
1466
+ chip.dataset.emoji = emoji;
1467
+ chip.title = names.join(', ');
1468
+ const face = document.createElement('span');
1469
+ face.textContent = emoji;
1470
+ const count = document.createElement('span');
1471
+ count.className = 'chat-reaction-count';
1472
+ count.textContent = String(names.length);
1473
+ chip.append(face, count);
1474
+ chip.onclick = (e) => {
1475
+ e.stopPropagation();
1476
+ this.react(position, emoji, !mine);
1477
+ };
1478
+ if (before.get(emoji) !== count.textContent) {
1479
+ gsap.fromTo(chip, { scale: 0.7 }, { duration: 0.35, scale: 1, ease: 'back.out(3)' });
1480
+ }
1481
+ return chip;
1482
+ }));
1483
+ }
1484
+ /** Wrap client tools so optional evaluate(params, onProgress) callbacks stay
1485
+ * in this UI. The Parrot Tool type is still evaluate(params) only — Python /
1486
+ * Rust SDKs should not grow chip/token fields. */
1487
+ bindClientTools(tools) {
1488
+ if (!tools)
1489
+ return undefined;
1490
+ const list = Array.isArray(tools) ? tools : [tools];
1491
+ return list.map((tool) => ({
1492
+ function: tool.function,
1493
+ instruction: tool.instruction,
1494
+ evaluate: (params) => {
1495
+ const run = tool.evaluate;
1496
+ return run(params, (progress) => {
1497
+ this.toolProgress.set(tool.function.name, progress);
1498
+ this.roomStream?.renderer.feedToolProgress(tool.function.name, progress);
1499
+ }, this.roomToolAbort.signal);
1500
+ },
1501
+ }));
1502
+ }
1503
+ toolsWithProgress(tools) {
1504
+ return tools.map((t) => {
1505
+ const progress = this.toolProgress.get(t.name);
1506
+ return progress !== undefined ? { ...t, progress } : t;
1507
+ });
1508
+ }
1509
+ /** Where the repository button and the computer the model asks for are bound: the chat room's own, or an app's holder. */
1510
+ repositoryHolder() {
1511
+ const room = this.options.room?.room;
1512
+ const option = this.options.repositories;
1513
+ if (!room || !option || room.isHuman() || !room.chatId)
1514
+ return null;
1515
+ return option === true ? room : option;
1516
+ }
1517
+ /** A chat with no repository still offers use_desktop, until the person turns it down here. */
1518
+ offersComputer() {
1519
+ const holder = this.repositoryHolder();
1520
+ return !!holder && holder.repository === null && !this.declinedComputer;
1521
+ }
1522
+ /**
1523
+ * use_desktop without a repository: asks the person to make one. A no lasts only
1524
+ * as long as this engine. A holder that isn't the room passes its repository on to
1525
+ * the room (the app does), so the reply waits until the room has it.
1526
+ */
1527
+ computerTool(room, holder) {
1528
+ return {
1529
+ function: Fauna.Termite.OS.desktopTool(null),
1530
+ evaluate: async (args) => {
1531
+ if (holder.repository !== null)
1532
+ return JSON.stringify({ repository: holder.repository });
1533
+ const signal = this.roomToolAbort.signal;
1534
+ const repository = await askForComputer(holder, String(args?.name ?? ''), signal, this.options.computerAsker);
1535
+ if (repository) {
1536
+ if (holder !== room)
1537
+ await roomHas(room, repository.id, signal);
1538
+ return JSON.stringify({ repository: repository.id });
1539
+ }
1540
+ if (!signal.aborted)
1541
+ this.declinedComputer = true;
1542
+ return JSON.stringify({ declined: true });
1543
+ },
1544
+ };
1545
+ }
1546
+ setupRoomMode(room, self) {
1547
+ const clientTools = this.bindClientTools(this.options.room?.tools) ?? [];
1548
+ const holder = this.repositoryHolder();
1549
+ if (holder)
1550
+ clientTools.push(this.computerTool(room, holder));
1551
+ room.setClientTools(clientTools);
1552
+ const roster = document.createElement('div');
1553
+ roster.className = 'chat-room-roster';
1554
+ this.refs.root.insertBefore(roster, this.refs.messagesEl);
1555
+ const typing = document.createElement('div');
1556
+ typing.className = 'chat-room-typing';
1557
+ this.refs.inputEl.insertBefore(typing, this.refs.queueEl);
1558
+ let repositoryHolder = null;
1559
+ if (holder) {
1560
+ repositoryHolder = document.createElement('div');
1561
+ repositoryHolder.className = 'chat-repository-holder';
1562
+ this.refs.optionsEl.insertBefore(repositoryHolder, this.refs.optionsEl.firstChild);
1563
+ this.repositoryControl = mountRepositoryControl(repositoryHolder, holder, this.options.onOpenRepository, this.options.onRepositoryChange);
1564
+ this.repositoryControl.setReplying(this.roomThinking);
1565
+ }
1566
+ const seen = new Set();
1567
+ const refreshRoster = () => {
1568
+ roster.innerHTML = '';
1569
+ const others = room.getPresences().filter((p) => p.id !== self.id);
1570
+ // Solo chat: hide the roster entirely (it would just show your own
1571
+ // avatar). Only surface it once there's company to attribute.
1572
+ roster.style.display = others.length > 0 ? 'flex' : 'none';
1573
+ if (others.length > 0) {
1574
+ // Show only OTHER members — never your own avatar.
1575
+ for (const presence of others) {
1576
+ const chip = document.createElement('div');
1577
+ chip.className = 'chat-room-member';
1578
+ chip.appendChild(buildAvatar(presence.profile));
1579
+ roster.appendChild(chip);
1580
+ }
1581
+ }
1582
+ const typers = room.typingPresences(self);
1583
+ if (typers.length === 0) {
1584
+ typing.textContent = '';
1585
+ typing.classList.remove('chat-room-typing-active');
1586
+ }
1587
+ else {
1588
+ const names = typers.map((p) => p.profile?.displayName ?? p.profile?.email?.split('@')[0] ?? 'Someone');
1589
+ typing.textContent = names.length === 1
1590
+ ? `${names[0]} is typing…`
1591
+ : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]} are typing…`;
1592
+ typing.classList.add('chat-room-typing-active');
1593
+ }
1594
+ };
1595
+ for (const message of room.getMessages()) {
1596
+ seen.add(message.id);
1597
+ if (isSystemRoomMessage(message))
1598
+ continue;
1599
+ const reaction = reactionOf(message);
1600
+ if (reaction) {
1601
+ this.applyReaction(message, reaction);
1602
+ continue;
1603
+ }
1604
+ this.renderRoomMessage(this.roomMessageToChat(room, self, message), message.id, message.position);
1605
+ }
1606
+ requestAnimationFrame(() => {
1607
+ this.refs.messagesEl.scrollTop = this.refs.messagesEl.scrollHeight;
1608
+ });
1609
+ refreshRoster();
1610
+ this.syncRoomQueue(room);
1611
+ let liveEnded = false;
1612
+ room.setHooks({
1613
+ onMessage: (r, message) => {
1614
+ if (liveEnded || this.destroyed)
1615
+ return;
1616
+ if (seen.has(message.id) || this.paintedMessage(message.id))
1617
+ return;
1618
+ seen.add(message.id);
1619
+ if (isSystemRoomMessage(message))
1620
+ return;
1621
+ const reaction = reactionOf(message);
1622
+ if (reaction) {
1623
+ this.applyReaction(message, reaction);
1624
+ return;
1625
+ }
1626
+ const chat = this.roomMessageToChat(r, self, message);
1627
+ if (chat.files?.length)
1628
+ this.refs.panel?.added();
1629
+ // If this MODEL message finalizes the turn we've been streaming,
1630
+ // keep the already-rendered streamed element instead of building
1631
+ // a duplicate — just commit it to history.
1632
+ if (chat.role === 'model' && this.roomStream) {
1633
+ const streamedEl = this.roomStream.el;
1634
+ const streamedRenderer = this.roomStream.renderer;
1635
+ if (chat.text && chat.text.length > streamedRenderer.getText().length) {
1636
+ streamedRenderer.syncText(chat.text);
1637
+ }
1638
+ streamedRenderer.finalize?.();
1639
+ this.roomStream = null;
1640
+ this.toolProgress.clear();
1641
+ // The streamed element was rendered live, so it never went
1642
+ // through buildMessageElement — give it the same copy action
1643
+ // wrapper every other message gets.
1644
+ if (chat.text)
1645
+ addCopyButton(streamedEl, () => chat.text, chat.createdAt);
1646
+ streamedRenderer.syncToolCalls(chat.toolCalls ?? []);
1647
+ // Files the reply attached, from the landed row (the stream may have missed some).
1648
+ for (const file of chat.files ?? []) {
1649
+ if (file.part?.ref && streamedEl.querySelector(`[data-ref="${CSS.escape(file.part.ref)}"]`))
1650
+ continue;
1651
+ const card = buildFileElement(file, this.options.acceptedTypes, this.fileCards);
1652
+ const actions = streamedEl.querySelector(':scope > .chat-message-actions');
1653
+ if (actions)
1654
+ streamedEl.insertBefore(card, actions);
1655
+ else
1656
+ streamedEl.appendChild(card);
1657
+ }
1658
+ // Stamp its durable position so later inserts order correctly
1659
+ // relative to it (it was appended live before its row landed).
1660
+ streamedEl.dataset.position = String(message.position);
1661
+ if (message.id)
1662
+ streamedEl.dataset.messageId = message.id;
1663
+ if (chat.model)
1664
+ streamedEl.dataset.model = chat.model;
1665
+ stampModelAvatar(streamedEl, chat.model ?? this.currentModel, this.modelIcons);
1666
+ this.enableReactions(streamedEl, message.position);
1667
+ this.history.push(chat);
1668
+ autoScroll(this.refs.messagesEl);
1669
+ this.options.room?.onRoomMessage?.(message);
1670
+ return;
1671
+ }
1672
+ this.renderRoomMessage(chat, message.id, message.position);
1673
+ this.options.room?.onRoomMessage?.(message);
1674
+ },
1675
+ onQueue: (r) => {
1676
+ if (liveEnded || this.destroyed)
1677
+ return;
1678
+ this.syncRoomQueue(r);
1679
+ },
1680
+ onDelta: (_r, turn, delta, _text, aborted, tools, files) => {
1681
+ if (liveEnded || this.destroyed)
1682
+ return;
1683
+ if (aborted) {
1684
+ this.abortRoomTools();
1685
+ this.toolProgress.clear();
1686
+ // Successful clear after a committed MODEL row is not abort.
1687
+ // Only drop the live bubble when this turn never wrote text.
1688
+ if (this.roomStream && !this.roomStream.renderer.getText()) {
1689
+ this.roomStream.el.remove();
1690
+ this.roomStream = null;
1691
+ }
1692
+ return;
1693
+ }
1694
+ // Leftover stream after hydrate: the finished MODEL row is already
1695
+ // painted. Do not grow a second bubble for that turn.
1696
+ const last = [...room.getMessages()].reverse().find((m) => !isSystemRoomMessage(m));
1697
+ const lastIsPaintedModel = Boolean(last
1698
+ && last.role === Fauna.Parrot.Generative.Role.MODEL
1699
+ && (seen.has(last.id) || this.paintedMessage(last.id)));
1700
+ // Starting (or continuing) a streamed model turn: drop the
1701
+ // "thinking" dots and grow a model message element live.
1702
+ if (!this.roomStream || this.roomStream.turn !== turn) {
1703
+ if (this.roomStream && this.roomStream.turn !== turn) {
1704
+ this.roomStream.el.remove();
1705
+ this.roomStream = null;
1706
+ }
1707
+ if (lastIsPaintedModel)
1708
+ return;
1709
+ if (this.roomThinkingEl) {
1710
+ this.roomThinkingEl.remove();
1711
+ this.roomThinkingEl = null;
1712
+ }
1713
+ const el = document.createElement('div');
1714
+ el.className = 'chat-message-model';
1715
+ el.dataset.model = this.currentModel;
1716
+ stampModelAvatar(el, this.currentModel, this.modelIcons);
1717
+ this.refs.messagesEl.appendChild(el);
1718
+ gsap.from(el, { duration: 0.4, y: 40, opacity: 0, ease: 'power2.out' });
1719
+ this.roomStream = { turn, el, renderer: createStreamRenderer(el, this.refs.messagesEl, this.options.renderToolCall) };
1720
+ }
1721
+ this.roomStream.renderer.syncToolCalls(this.toolsWithProgress(tools));
1722
+ if (files?.length)
1723
+ this.paintStreamFiles(this.roomStream.el, files);
1724
+ if (delta)
1725
+ this.roomStream.renderer.feedText(delta);
1726
+ autoScroll(this.refs.messagesEl);
1727
+ },
1728
+ onOpenFile: (_r, attachment) => {
1729
+ if (liveEnded || this.destroyed)
1730
+ return;
1731
+ this.refs.panel?.point(attachment);
1732
+ },
1733
+ onThinking: (_r, thinking) => {
1734
+ if (liveEnded)
1735
+ return;
1736
+ this.setRoomThinking(thinking);
1737
+ this.options.room?.onThinking?.(thinking);
1738
+ },
1739
+ onRenamed: () => { },
1740
+ onClosed: () => {
1741
+ // Live room destroyed (host left / reap). Clear peer UI so we
1742
+ // don't sit on a ghost roster; reopen the panel to reconnect.
1743
+ liveEnded = true;
1744
+ roster.innerHTML = '';
1745
+ roster.style.display = 'none';
1746
+ typing.textContent = '';
1747
+ typing.classList.remove('chat-room-typing-active');
1748
+ if (!this.refs.messagesEl.querySelector('.chat-room-ended')) {
1749
+ const banner = document.createElement('div');
1750
+ banner.className = 'chat-room-ended';
1751
+ banner.textContent = 'Live session ended. Reopen the panel to reconnect.';
1752
+ this.refs.messagesEl.appendChild(banner);
1753
+ }
1754
+ this.setRoomThinking(false);
1755
+ this.roomQueue.clear();
1756
+ this.roomSteerAllEl?.remove();
1757
+ this.roomSteerAllEl = null;
1758
+ this.refs.queueEl.replaceChildren();
1759
+ },
1760
+ onPresence: () => {
1761
+ if (liveEnded)
1762
+ return;
1763
+ refreshRoster();
1764
+ },
1765
+ });
1766
+ room.replayReply();
1767
+ let typingTimer = null;
1768
+ this.roomTypingHandler = () => {
1769
+ self.setStatus('typing');
1770
+ if (typingTimer)
1771
+ clearTimeout(typingTimer);
1772
+ typingTimer = setTimeout(() => self.setStatus(''), 2500);
1773
+ };
1774
+ // Lazy backfill: when scrolled near the top and older pages remain, pull
1775
+ // one older page and PREPEND it, preserving the scroll offset so the view
1776
+ // doesn't jump. Guarded against overlapping loads.
1777
+ let loadingOlder = false;
1778
+ const onScroll = () => {
1779
+ if (loadingOlder || !room.hasMore())
1780
+ return;
1781
+ if (this.refs.messagesEl.scrollTop > 80)
1782
+ return;
1783
+ loadingOlder = true;
1784
+ const prevHeight = this.refs.messagesEl.scrollHeight;
1785
+ void room.loadOlder()
1786
+ .then((page) => {
1787
+ void page;
1788
+ if (liveEnded || this.destroyed)
1789
+ return;
1790
+ // loadOlder merged the older rows into room.getMessages();
1791
+ // render any not yet shown, in position order.
1792
+ for (const message of room.getMessages()) {
1793
+ if (seen.has(message.id) || this.paintedMessage(message.id))
1794
+ continue;
1795
+ seen.add(message.id);
1796
+ if (isSystemRoomMessage(message))
1797
+ continue;
1798
+ const reaction = reactionOf(message);
1799
+ if (reaction) {
1800
+ this.applyReaction(message, reaction);
1801
+ continue;
1802
+ }
1803
+ this.renderRoomMessage(this.roomMessageToChat(room, self, message), message.id, message.position);
1804
+ }
1805
+ // Preserve scroll position across the prepend.
1806
+ this.refs.messagesEl.scrollTop += this.refs.messagesEl.scrollHeight - prevHeight;
1807
+ this.options.room?.onOlderLoaded?.();
1808
+ })
1809
+ .catch((err) => this.options.callbacks?.onError?.(err.message))
1810
+ .finally(() => { loadingOlder = false; });
1811
+ };
1812
+ this.refs.messagesEl.addEventListener('scroll', onScroll);
1813
+ const rosterPoll = setInterval(refreshRoster, 1000);
1814
+ this.roomCleanup = () => {
1815
+ liveEnded = true;
1816
+ room.setHooks({
1817
+ onMessage: () => { },
1818
+ onQueue: () => { },
1819
+ onDelta: () => { },
1820
+ onThinking: () => { },
1821
+ onRenamed: () => { },
1822
+ onClosed: () => { },
1823
+ onPresence: () => { },
1824
+ onOpenFile: () => { },
1825
+ });
1826
+ this.repositoryControl?.unmount();
1827
+ this.repositoryControl = null;
1828
+ repositoryHolder?.remove();
1829
+ clearInterval(rosterPoll);
1830
+ if (typingTimer)
1831
+ clearTimeout(typingTimer);
1832
+ this.refs.messagesEl.removeEventListener('scroll', onScroll);
1833
+ this.roomTypingHandler = null;
1834
+ // StrictMode remounts the engine; orphan roster/typing nodes would
1835
+ // stack (same bug as the tokenized composer) and show duplicate peers.
1836
+ try {
1837
+ roster.remove();
1838
+ }
1839
+ catch { /* noop */ }
1840
+ try {
1841
+ typing.remove();
1842
+ }
1843
+ catch { /* noop */ }
1844
+ };
1845
+ }
1846
+ abortRoomTools() {
1847
+ if (!this.roomToolAbort.signal.aborted)
1848
+ this.roomToolAbort.abort();
1849
+ this.roomToolAbort = new AbortController();
1850
+ }
1851
+ stopRoom() {
1852
+ this.abortRoomTools();
1853
+ void this.options.room?.room.steer({ stop: true, model: this.currentModel, reasoning: this.currentReasoning })
1854
+ .catch((err) => {
1855
+ void sendInformaticToast(err.message || 'Stop failed', 'error');
1856
+ this.options.callbacks?.onError?.(err.message);
1857
+ });
1858
+ }
1859
+ setRoomThinking(thinking) {
1860
+ this.roomThinking = thinking;
1861
+ if (thinking && this.roomToolAbort.signal.aborted)
1862
+ this.roomToolAbort = new AbortController();
1863
+ if (!thinking)
1864
+ this.abortRoomTools();
1865
+ // Mirror the stop affordance: while the server is replying, the send
1866
+ // button becomes a stop control (clicking it cancels the room reply).
1867
+ this.setComposerGenerating(thinking);
1868
+ this.repositoryControl?.setReplying(thinking);
1869
+ // Note: the shared queue is NOT cleared here — queued rows are released
1870
+ // one-per-reply by the server and each release fans out a chat-queue-remove
1871
+ // (handled by syncRoomQueue), so chips disappear exactly as they drain.
1872
+ // While a streamed turn is actively rendering, the growing message is the
1873
+ // indicator — don't also show the dots.
1874
+ if (thinking && this.roomStream)
1875
+ return;
1876
+ if (thinking && !this.roomThinkingEl) {
1877
+ this.roomThinkingEl = document.createElement('div');
1878
+ this.roomThinkingEl.className = 'chat-message-model';
1879
+ this.roomThinkingEl.appendChild(buildThinkingIndicator());
1880
+ this.refs.messagesEl.appendChild(this.roomThinkingEl);
1881
+ gsap.from(this.roomThinkingEl, { duration: 0.4, y: 40, opacity: 0, ease: 'power2.out' });
1882
+ autoScroll(this.refs.messagesEl);
1883
+ }
1884
+ else if (!thinking && this.roomThinkingEl) {
1885
+ this.roomThinkingEl.remove();
1886
+ this.roomThinkingEl = null;
1887
+ }
1888
+ }
1889
+ // Reconcile the queue-chip UI against the room's shared queue. Driven by the
1890
+ // room's onQueue hook (and initial hydration): the server-side chat_queue is
1891
+ // the single source of truth, so we add chips for new rows, drop chips for
1892
+ // released/unqueued rows, and keep them in FIFO order. Each chip gets a bolt
1893
+ // (per-item steer: release just this row) and an x (unqueue: drop this row).
1894
+ syncRoomQueue(room) {
1895
+ const items = room.getQueue();
1896
+ const live = new Set(items.map((q) => q.id));
1897
+ // Drop chips whose queue row is gone (released into the transcript or
1898
+ // unqueued) — animate them out.
1899
+ for (const [id, chip] of [...this.roomQueue]) {
1900
+ if (!live.has(id)) {
1901
+ this.roomQueue.delete(id);
1902
+ gsap.to(chip, {
1903
+ duration: 0.25, opacity: 0, height: 0, marginBottom: 0, ease: 'power2.in',
1904
+ onComplete: () => { chip.remove(); },
1905
+ });
1906
+ }
1907
+ }
1908
+ // Add chips for new rows, in FIFO order (the queue is already sorted).
1909
+ for (const item of items) {
1910
+ if (this.roomQueue.has(item.id))
1911
+ continue;
1912
+ const chip = this.buildRoomQueueChip(item);
1913
+ this.roomQueue.set(item.id, chip);
1914
+ this.refs.queueEl.appendChild(chip);
1915
+ gsap.from(chip, { duration: 0.3, opacity: 0, y: 8, ease: 'power2.out' });
1916
+ }
1917
+ this.syncRoomSteerAll();
1918
+ this.options.callbacks?.onQueueChange?.(this.roomQueue.size);
1919
+ }
1920
+ // Build one queue chip for a shared-queue row. `bolt` releases just this row
1921
+ // into the transcript (per-item steer); `x` drops it (unqueue). Both are
1922
+ // room-global server operations — the resulting queue delta reconciles the UI.
1923
+ buildRoomQueueChip(item) {
1924
+ const chip = document.createElement('div');
1925
+ chip.className = 'chat-queue-item';
1926
+ const icon = document.createElement('span');
1927
+ icon.className = 'material-symbols-outlined chat-queue-item-icon';
1928
+ icon.textContent = 'schedule';
1929
+ chip.appendChild(icon);
1930
+ const text = item.parts.filter((p) => p.type === 'TEXT').map((p) => p.text).join('');
1931
+ const fileCount = item.parts.filter((p) => p.type === 'FILE').length;
1932
+ const label = document.createElement('div');
1933
+ label.className = 'chat-queue-item-label';
1934
+ const preview = toPlainText(text).trim();
1935
+ const fileNote = fileCount > 0 ? `${fileCount} file${fileCount > 1 ? 's' : ''}` : '';
1936
+ label.textContent = preview || fileNote || 'Attachment';
1937
+ if (preview && fileNote)
1938
+ label.title = `${preview} · ${fileNote}`;
1939
+ chip.appendChild(label);
1940
+ const actions = document.createElement('div');
1941
+ actions.className = 'chat-queue-item-actions';
1942
+ const steerBtn = document.createElement('span');
1943
+ steerBtn.className = 'material-symbols-outlined chat-queue-steer';
1944
+ steerBtn.textContent = 'bolt';
1945
+ createTooltip(steerBtn, 'Steer — stop the current reply and jump this message ahead');
1946
+ steerBtn.onclick = (e) => { e.stopPropagation(); this.steerRoom(item.id); };
1947
+ actions.appendChild(steerBtn);
1948
+ const removeBtn = document.createElement('span');
1949
+ removeBtn.className = 'material-symbols-outlined chat-queue-remove';
1950
+ removeBtn.textContent = 'close';
1951
+ createTooltip(removeBtn, 'Remove from queue');
1952
+ removeBtn.onclick = (e) => {
1953
+ e.stopPropagation();
1954
+ void this.options.room?.room.unqueue(item.id)
1955
+ .then(() => { void sendInformaticToast('Removed from queue', 'info', 2500); })
1956
+ .catch((err) => {
1957
+ void sendInformaticToast(err.message || 'Could not remove from queue', 'error');
1958
+ this.options.callbacks?.onError?.(err.message);
1959
+ });
1960
+ };
1961
+ actions.appendChild(removeBtn);
1962
+ chip.appendChild(actions);
1963
+ return chip;
1964
+ }
1965
+ // Steer the room. With no `queueId` (steer-all header path routes through
1966
+ // steerAllRoom) this stops the current reply and re-answers. With a `queueId`
1967
+ // it releases just that one queued row and re-answers. Either way the server
1968
+ // stops the current reply and restarts the normal debounce.
1969
+ steerRoom(queueId) {
1970
+ this.abortRoomTools();
1971
+ void this.options.room?.room.steer({ queueId, model: this.currentModel, reasoning: this.currentReasoning })
1972
+ .catch((err) => {
1973
+ void sendInformaticToast(err.message || 'Steer failed', 'error');
1974
+ this.options.callbacks?.onError?.(err.message);
1975
+ });
1976
+ }
1977
+ // Steer-all: flush the whole queue into the transcript and stop the current
1978
+ // reply (one reply then answers the whole batch after the debounce).
1979
+ steerAllRoom() {
1980
+ this.abortRoomTools();
1981
+ void this.options.room?.room.steer({ all: true, model: this.currentModel, reasoning: this.currentReasoning })
1982
+ .then(() => { void sendInformaticToast('Steered all queued messages', 'info', 2500); })
1983
+ .catch((err) => {
1984
+ void sendInformaticToast(err.message || 'Steer all failed', 'error');
1985
+ this.options.callbacks?.onError?.(err.message);
1986
+ });
1987
+ }
1988
+ // Show/refresh the "Steer all" header above the queue when >1 chip is waiting.
1989
+ syncRoomSteerAll() {
1990
+ if (this.roomQueue.size > 1 && !this.roomSteerAllEl) {
1991
+ const header = document.createElement('div');
1992
+ header.className = 'chat-queue-steer-all';
1993
+ const icon = document.createElement('span');
1994
+ icon.className = 'material-symbols-outlined';
1995
+ icon.textContent = 'bolt';
1996
+ header.appendChild(icon);
1997
+ const label = document.createElement('span');
1998
+ label.textContent = 'Steer all';
1999
+ header.appendChild(label);
2000
+ createTooltip(header, 'Stop the current reply and answer everything queued now');
2001
+ header.onclick = (e) => { e.stopPropagation(); this.steerAllRoom(); };
2002
+ this.refs.queueEl.insertBefore(header, this.refs.queueEl.firstChild);
2003
+ gsap.from(header, { duration: 0.3, opacity: 0, y: -6, ease: 'power2.out' });
2004
+ this.roomSteerAllEl = header;
2005
+ }
2006
+ else if (this.roomQueue.size <= 1 && this.roomSteerAllEl) {
2007
+ this.roomSteerAllEl.remove();
2008
+ this.roomSteerAllEl = null;
2009
+ }
2010
+ }
2011
+ // ── Public API (drives the ChatInstance handle) ──
2012
+ sendMessage(text, files) {
2013
+ if (files && files.length > 0) {
2014
+ void (async () => {
2015
+ const uploaded = [];
2016
+ for (const f of files) {
2017
+ const file = await this.uploadBytes(f.mime, f.name, f.data);
2018
+ if (!file)
2019
+ return;
2020
+ uploaded.push(file);
2021
+ }
2022
+ this.doSend(text, undefined, uploaded);
2023
+ })();
2024
+ return;
2025
+ }
2026
+ this.doSend(text);
2027
+ }
2028
+ appendMessage(role, text) {
2029
+ const message = {
2030
+ role,
2031
+ text,
2032
+ createdAt: new Date(),
2033
+ model: role === 'model' ? this.currentModel : undefined,
2034
+ };
2035
+ this.history.push(message);
2036
+ buildMessageElement(message, this.options.acceptedTypes, this.modelIcons, this.options.renderToolCall, this.options.mode, this.options.room?.self.profile.email, this.options.onTokenClick, this.fileCards).then((el) => {
2037
+ if (message.model)
2038
+ el.dataset.model = message.model;
2039
+ this.refs.messagesEl.appendChild(el);
2040
+ gsap.from(el, { duration: 0.5, y: 100, opacity: 0, ease: 'power2.inOut' });
2041
+ autoScroll(this.refs.messagesEl);
2042
+ });
2043
+ }
2044
+ removeQueuedMessage(index) {
2045
+ if (index < 0 || index >= this.queue.length)
2046
+ return;
2047
+ const removed = this.queue.splice(index, 1)[0];
2048
+ gsap.to(removed.element, {
2049
+ duration: 0.3, opacity: 0, scale: 0.9, ease: 'power2.in',
2050
+ onComplete: () => { removed.element.remove(); }
2051
+ });
2052
+ this.options.callbacks?.onQueueChange?.(this.queue.length);
2053
+ }
2054
+ getQueue() {
2055
+ return this.queue.map((q) => ({
2056
+ text: q.text,
2057
+ files: q.files?.map((f) => ({ mime: f.contentType, name: f.name })),
2058
+ }));
2059
+ }
2060
+ clearMessages() {
2061
+ this.generation++;
2062
+ this.generating = false;
2063
+ this.steerTarget = null;
2064
+ this.activeController?.abort();
2065
+ this.activeController = null;
2066
+ this.setComposerGenerating(false);
2067
+ this.history.length = 0;
2068
+ this.queue.length = 0;
2069
+ this.roomQueue.clear();
2070
+ this.roomSteerAllEl = null;
2071
+ while (this.refs.messagesEl.firstChild)
2072
+ this.refs.messagesEl.firstChild.remove();
2073
+ while (this.refs.queueEl.firstChild)
2074
+ this.refs.queueEl.firstChild.remove();
2075
+ this.options.callbacks?.onQueueChange?.(0);
2076
+ }
2077
+ getHistory() {
2078
+ return [...this.history];
2079
+ }
2080
+ getModel() {
2081
+ return this.currentModel;
2082
+ }
2083
+ setModel(model) {
2084
+ this.currentModel = model;
2085
+ this.modelDropdown?.setValue(model);
2086
+ }
2087
+ getReasoning() {
2088
+ return this.currentReasoning;
2089
+ }
2090
+ setReasoning(reasoning) {
2091
+ this.currentReasoning = reasoning;
2092
+ this.modelDropdown?.setReasoning(reasoning);
2093
+ }
2094
+ setMode(mode) {
2095
+ if (chatMode(this.options.mode) === mode)
2096
+ return;
2097
+ this.options.mode = mode;
2098
+ const selfEmail = this.options.room?.self.profile.email;
2099
+ for (const el of this.refs.messagesEl.querySelectorAll('[data-author]')) {
2100
+ const email = el.dataset.author;
2101
+ const incoming = mode === 'thread' && Boolean(email && selfEmail && email !== selfEmail);
2102
+ const profile = this.history.find((m) => m.author?.id === email)?.author?.profile;
2103
+ applyUserBubbleLayout(el, incoming, profile);
2104
+ }
2105
+ }
2106
+ setModels(models) {
2107
+ this.options.models = models;
2108
+ if (isModelLocked(models)) {
2109
+ this.modelDropdown?.destroy();
2110
+ this.modelDropdown = null;
2111
+ this.syncModelSelectVisibility();
2112
+ return;
2113
+ }
2114
+ this.syncModelSelectVisibility();
2115
+ if (!this.modelDropdown)
2116
+ this.wireModelSelect();
2117
+ else {
2118
+ this.modelDropdown.setValue(this.currentModel);
2119
+ this.modelDropdown.setReasoning(this.currentReasoning);
2120
+ }
2121
+ }
2122
+ insertToken(token) {
2123
+ this.tokenizedInput.insertToken(token);
2124
+ }
2125
+ getParts() {
2126
+ return this.tokenizedInput.getParts();
2127
+ }
2128
+ watchInput(handler) {
2129
+ this.inputWatchers.add(handler);
2130
+ return () => { this.inputWatchers.delete(handler); };
2131
+ }
2132
+ clearInput() {
2133
+ this.tokenizedInput.clear();
2134
+ }
2135
+ focusInput() {
2136
+ this.tokenizedInput.focus();
2137
+ }
2138
+ destroy() {
2139
+ this.destroyed = true;
2140
+ this.generation++;
2141
+ this.generating = false;
2142
+ this.steerTarget = null;
2143
+ this.activeController?.abort();
2144
+ this.activeController = null;
2145
+ this.roomCleanup?.();
2146
+ this.modelDropdown?.destroy();
2147
+ this.modelDropdown = null;
2148
+ // StrictMode remounts the engine; without clearing imperatively mounted
2149
+ // nodes, a second composer / roster stacks in the panel.
2150
+ this.inputWatchers.clear();
2151
+ try {
2152
+ this.tokenizedInput.getElement().remove();
2153
+ }
2154
+ catch { /* noop */ }
2155
+ this.refs.rowEl.replaceChildren();
2156
+ this.refs.messagesEl.replaceChildren();
2157
+ this.refs.queueEl.replaceChildren();
2158
+ this.history.length = 0;
2159
+ this.queue.length = 0;
2160
+ this.roomQueue.clear();
2161
+ this.roomSteerAllEl = null;
2162
+ }
2163
+ }
2164
+ // ---- React <Chat> component ----
2165
+ // Renders the chat shell (identical DOM structure/classes to the original
2166
+ // vanilla widget so ui.css and every animation apply unchanged) and wires the
2167
+ // imperative ChatEngine to the rendered nodes. Exposes the ChatInstance via
2168
+ // ref so both React consumers and the createChat bridge get the same handle.
2169
+ export const Chat = forwardRef(function Chat({ options = {} }, ref) {
2170
+ const rootRef = useRef(null);
2171
+ const mainRef = useRef(null);
2172
+ const messagesRef = useRef(null);
2173
+ // The side panel is set at mount, like every other option.
2174
+ const panelRoom = options.attachments && options.room && !options.room.room.isHuman() ? options.room.room : null;
2175
+ const bus = useMemo(() => (panelRoom ? createPanelBus() : null), [panelRoom]);
2176
+ const [panelOpen, setPanelOpen] = useState(false);
2177
+ const inputRef = useRef(null);
2178
+ const queueRef = useRef(null);
2179
+ const partsRef = useRef(null);
2180
+ const rowRef = useRef(null);
2181
+ const controlsRef = useRef(null);
2182
+ const optionsRef = useRef(null);
2183
+ const sendRef = useRef(null);
2184
+ const modelSelectRef = useRef(null);
2185
+ const attachRef = useRef(null);
2186
+ const engineRef = useRef(null);
2187
+ // Layout effect (not passive) so the engine is constructed during the commit
2188
+ // phase. Combined with the flushSync mount in `createChat`, this guarantees
2189
+ // the engine exists synchronously by the time createChat() returns, so a
2190
+ // caller may sendMessage() immediately (e.g. the landing send) without the
2191
+ // first message being dropped.
2192
+ useLayoutEffect(() => {
2193
+ if (!rootRef.current || !messagesRef.current || !inputRef.current || !queueRef.current ||
2194
+ !partsRef.current || !rowRef.current || !controlsRef.current || !optionsRef.current ||
2195
+ !sendRef.current)
2196
+ return;
2197
+ const engine = new ChatEngine({
2198
+ root: mainRef.current ?? rootRef.current,
2199
+ messagesEl: messagesRef.current,
2200
+ inputEl: inputRef.current,
2201
+ queueEl: queueRef.current,
2202
+ partsEl: partsRef.current,
2203
+ rowEl: rowRef.current,
2204
+ controlsEl: controlsRef.current,
2205
+ optionsEl: optionsRef.current,
2206
+ sendBtn: sendRef.current,
2207
+ modelSelect: modelSelectRef.current,
2208
+ attachBtn: attachRef.current,
2209
+ panel: bus,
2210
+ }, roomLayout(options));
2211
+ engineRef.current = engine;
2212
+ return () => {
2213
+ engine.destroy();
2214
+ engineRef.current = null;
2215
+ };
2216
+ // Options are read once at mount (matching the imperative widget's
2217
+ // one-time configuration); consumers rebuild the widget to change them.
2218
+ // eslint-disable-next-line react-hooks/exhaustive-deps
2219
+ }, []);
2220
+ useImperativeHandle(ref, () => ({
2221
+ sendMessage: (text, files) => engineRef.current?.sendMessage(text, files),
2222
+ appendMessage: (role, text) => engineRef.current?.appendMessage(role, text),
2223
+ removeQueuedMessage: (index) => engineRef.current?.removeQueuedMessage(index),
2224
+ getQueue: () => engineRef.current?.getQueue() ?? [],
2225
+ clearMessages: () => engineRef.current?.clearMessages(),
2226
+ getHistory: () => engineRef.current?.getHistory() ?? [],
2227
+ getModel: () => engineRef.current?.getModel() ?? '',
2228
+ setModel: (model) => engineRef.current?.setModel(model),
2229
+ getReasoning: () => engineRef.current?.getReasoning(),
2230
+ setReasoning: (reasoning) => engineRef.current?.setReasoning(reasoning),
2231
+ setMode: (mode) => engineRef.current?.setMode(mode),
2232
+ setModels: (models) => engineRef.current?.setModels(models),
2233
+ insertToken: (token) => engineRef.current?.insertToken(token),
2234
+ getParts: () => engineRef.current?.getParts() ?? [],
2235
+ watchInput: (handler) => engineRef.current?.watchInput(handler) ?? (() => { }),
2236
+ clearInput: () => engineRef.current?.clearInput(),
2237
+ focusInput: () => engineRef.current?.focusInput(),
2238
+ getElement: () => rootRef.current,
2239
+ destroy: () => engineRef.current?.destroy(),
2240
+ }), []);
2241
+ const messages = _jsx("div", { className: "chat-messages", ref: messagesRef });
2242
+ const input = (_jsxs("div", { className: "chat-input", ref: inputRef, children: [_jsx("div", { className: "chat-queue", ref: queueRef }), _jsx("div", { className: "chat-input-parts", ref: partsRef }), _jsx("div", { className: "chat-input-row", ref: rowRef }), _jsxs("div", { className: "chat-input-controls", ref: controlsRef, children: [_jsx("div", { className: "gen-select", ref: modelSelectRef, style: isModelLocked(roomLayout(options).models) ? { display: 'none' } : undefined }), _jsxs("div", { className: "chat-input-options", ref: optionsRef, children: [chatAuthorization(options) && (_jsx("span", { className: "material-symbols-outlined", ref: attachRef, children: "attach_file" })), _jsx("span", { className: "material-symbols-outlined", ref: sendRef, children: "arrow_forward" })] })] })] }));
2243
+ const withPanel = Boolean(panelRoom && bus);
2244
+ const actions = options.actions ?? [];
2245
+ const actionRow = actions.length > 0 || (withPanel && !panelOpen)
2246
+ ? (_jsxs("div", { className: "chat-actions", children: [actions.map((action) => (_jsx("button", { type: "button", className: "material-symbols-outlined chat-action", title: action.label, "aria-label": action.label, onClick: action.onClick, children: action.icon }, action.label))), withPanel && !panelOpen && (_jsx("button", { type: "button", className: "material-symbols-outlined chat-action", title: "Attachments", "aria-label": "Show attachments", onClick: () => setPanelOpen(true), children: "right_panel_open" }))] }))
2247
+ : null;
2248
+ if (!panelRoom || !bus) {
2249
+ return (_jsxs("div", { className: "chat-root", ref: rootRef, children: [actionRow, messages, input] }));
2250
+ }
2251
+ return (_jsxs("div", { className: "chat-root chat-root-panel", ref: rootRef, children: [_jsxs("div", { className: "chat-main", ref: mainRef, children: [actionRow, messages, input] }), _jsx(ChatPanel, { room: panelRoom, bus: bus, open: panelOpen, onOpenChange: setPanelOpen })] }));
2252
+ });
2253
+ // ---- createChat (imperative bridge, backwards compatible) ----
2254
+ // Mounts the React <Chat> into `container` and returns the same imperative
2255
+ // ChatInstance the vanilla widget exposed, so the existing vanilla apps keep
2256
+ // calling createChat(el, opts) unchanged.
2257
+ export function createChat(container, options = {}) {
2258
+ const root = createRoot(container);
2259
+ let instance = null;
2260
+ const ready = [];
2261
+ const handleRef = (inst) => {
2262
+ instance = inst;
2263
+ if (inst)
2264
+ ready.push(inst);
2265
+ };
2266
+ // React 19's `createRoot().render()` is asynchronous by default, so without
2267
+ // flushSync the engine wouldn't exist yet when this function returns and an
2268
+ // immediate sendMessage() (the landing send) would be silently dropped.
2269
+ // flushSync forces a synchronous commit + layout effects, so the engine
2270
+ // (mounted in a useLayoutEffect) is live before we return.
2271
+ flushSync(() => root.render(_jsx(Chat, { ref: handleRef, options: options })));
2272
+ const get = () => instance ?? ready[ready.length - 1] ?? null;
2273
+ return {
2274
+ sendMessage: (text, files) => get()?.sendMessage(text, files),
2275
+ appendMessage: (role, text) => get()?.appendMessage(role, text),
2276
+ removeQueuedMessage: (index) => get()?.removeQueuedMessage(index),
2277
+ getQueue: () => get()?.getQueue() ?? [],
2278
+ clearMessages: () => get()?.clearMessages(),
2279
+ getHistory: () => get()?.getHistory() ?? [],
2280
+ getModel: () => get()?.getModel() ?? '',
2281
+ setModel: (model) => get()?.setModel(model),
2282
+ getReasoning: () => get()?.getReasoning(),
2283
+ setReasoning: (reasoning) => get()?.setReasoning(reasoning),
2284
+ setMode: (mode) => get()?.setMode(mode),
2285
+ setModels: (models) => get()?.setModels(models),
2286
+ insertToken: (token) => get()?.insertToken(token),
2287
+ getParts: () => get()?.getParts() ?? [],
2288
+ watchInput: (handler) => get()?.watchInput(handler) ?? (() => { }),
2289
+ clearInput: () => get()?.clearInput(),
2290
+ focusInput: () => get()?.focusInput(),
2291
+ getElement: () => (get()?.getElement() ?? container),
2292
+ destroy: () => {
2293
+ get()?.destroy();
2294
+ root.unmount();
2295
+ },
2296
+ };
2297
+ }
2298
+ // ---- createLog ----
2299
+ // A read-only transcript surface: renders history and can stream a single
2300
+ // model reply, with no composer. Mirrors the original LogInstance API.
2301
+ export function createLog(container, options = {}) {
2302
+ const history = [];
2303
+ let destroyed = false;
2304
+ const root = document.createElement('div');
2305
+ root.className = 'chat-root';
2306
+ const messagesEl = document.createElement('div');
2307
+ messagesEl.className = 'chat-messages';
2308
+ root.appendChild(messagesEl);
2309
+ container.appendChild(root);
2310
+ if (options.initialHistory) {
2311
+ for (const message of options.initialHistory) {
2312
+ history.push(message);
2313
+ buildMessageElement(message).then((el) => {
2314
+ messagesEl.appendChild(el);
2315
+ });
2316
+ }
2317
+ }
2318
+ return {
2319
+ appendMessage(role, text) {
2320
+ const message = { role, text, createdAt: new Date() };
2321
+ history.push(message);
2322
+ options.callbacks?.onMessage?.(message);
2323
+ buildMessageElement(message).then((el) => {
2324
+ messagesEl.appendChild(el);
2325
+ gsap.from(el, { duration: 0.5, y: 100, opacity: 0, ease: 'power2.inOut' });
2326
+ autoScroll(messagesEl);
2327
+ });
2328
+ },
2329
+ async streamMessage(iterable) {
2330
+ if (destroyed)
2331
+ return;
2332
+ const modelEl = document.createElement('div');
2333
+ modelEl.className = 'chat-message-model';
2334
+ modelEl.appendChild(buildThinkingIndicator());
2335
+ messagesEl.appendChild(modelEl);
2336
+ gsap.from(modelEl, { duration: 0.5, y: 100, opacity: 0, ease: 'power2.inOut' });
2337
+ gsap.to(messagesEl, { duration: 0.5, scrollTop: messagesEl.scrollHeight, ease: 'power2.inOut' });
2338
+ const renderer = createStreamRenderer(modelEl, messagesEl);
2339
+ try {
2340
+ for await (const event of iterable) {
2341
+ if (destroyed)
2342
+ break;
2343
+ if (event.event === 'text')
2344
+ renderer.feedText(event.delta);
2345
+ else if (event.event === 'tool_call')
2346
+ renderer.feedToolCall(event.id, event.name, event.args);
2347
+ else if (event.event === 'error')
2348
+ renderer.feedError(event.message);
2349
+ }
2350
+ }
2351
+ catch (err) {
2352
+ console.error(err);
2353
+ if (!renderer.getText())
2354
+ renderer.feedError('Something went wrong.');
2355
+ }
2356
+ if (destroyed)
2357
+ return;
2358
+ const message = await renderer.finalize();
2359
+ history.push(message);
2360
+ options.callbacks?.onMessage?.(message);
2361
+ },
2362
+ clearMessages() {
2363
+ history.length = 0;
2364
+ while (messagesEl.firstChild)
2365
+ messagesEl.firstChild.remove();
2366
+ },
2367
+ getHistory() {
2368
+ return [...history];
2369
+ },
2370
+ getElement() {
2371
+ return root;
2372
+ },
2373
+ destroy() {
2374
+ destroyed = true;
2375
+ history.length = 0;
2376
+ root.remove();
2377
+ }
2378
+ };
2379
+ }