@mooncompany/uplink-chat 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of @mooncompany/uplink-chat might be problematic. Click here for more details.

Files changed (158) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +185 -0
  3. package/bin/uplink.js +279 -0
  4. package/middleware/error-handler.js +69 -0
  5. package/package.json +93 -0
  6. package/public/css/agents.36b98c0f.css +1469 -0
  7. package/public/css/agents.css +1469 -0
  8. package/public/css/app.a6a7f8f5.css +2731 -0
  9. package/public/css/app.css +2731 -0
  10. package/public/css/artifacts.css +444 -0
  11. package/public/css/commands.css +55 -0
  12. package/public/css/connection.css +131 -0
  13. package/public/css/dashboard.css +233 -0
  14. package/public/css/developer.css +328 -0
  15. package/public/css/files.css +123 -0
  16. package/public/css/markdown.css +156 -0
  17. package/public/css/message-actions.css +278 -0
  18. package/public/css/mobile.css +614 -0
  19. package/public/css/panels-unified.css +483 -0
  20. package/public/css/premium.css +415 -0
  21. package/public/css/realtime.css +189 -0
  22. package/public/css/satellites.css +401 -0
  23. package/public/css/shortcuts.css +185 -0
  24. package/public/css/split-view.4def0262.css +673 -0
  25. package/public/css/split-view.css +673 -0
  26. package/public/css/theme-generator.css +391 -0
  27. package/public/css/themes.css +387 -0
  28. package/public/css/timestamps.css +54 -0
  29. package/public/css/variables.css +78 -0
  30. package/public/dist/bundle.b55050c4.js +15757 -0
  31. package/public/favicon.svg +24 -0
  32. package/public/img/agents/ada.png +0 -0
  33. package/public/img/agents/clarice.png +0 -0
  34. package/public/img/agents/dennis-nedry.png +0 -0
  35. package/public/img/agents/elliot-alderson.png +0 -0
  36. package/public/img/agents/main.png +0 -0
  37. package/public/img/agents/scotty.png +0 -0
  38. package/public/img/agents/top-flight-security.png +0 -0
  39. package/public/index.html +1083 -0
  40. package/public/js/agents-data.js +234 -0
  41. package/public/js/agents-ui.js +72 -0
  42. package/public/js/agents.js +1525 -0
  43. package/public/js/app.js +79 -0
  44. package/public/js/appearance-settings.js +111 -0
  45. package/public/js/artifacts.js +432 -0
  46. package/public/js/audio-queue.js +168 -0
  47. package/public/js/bootstrap.js +54 -0
  48. package/public/js/chat.js +1211 -0
  49. package/public/js/commands.js +581 -0
  50. package/public/js/connection-api.js +121 -0
  51. package/public/js/connection.js +1231 -0
  52. package/public/js/context-tracker.js +271 -0
  53. package/public/js/core.js +172 -0
  54. package/public/js/dashboard.js +452 -0
  55. package/public/js/developer.js +432 -0
  56. package/public/js/encryption.js +124 -0
  57. package/public/js/errors.js +122 -0
  58. package/public/js/event-bus.js +77 -0
  59. package/public/js/fetch-utils.js +171 -0
  60. package/public/js/file-handler.js +229 -0
  61. package/public/js/files.js +352 -0
  62. package/public/js/gateway-chat.js +538 -0
  63. package/public/js/logger.js +112 -0
  64. package/public/js/markdown.js +190 -0
  65. package/public/js/message-actions.js +431 -0
  66. package/public/js/message-renderer.js +288 -0
  67. package/public/js/missed-messages.js +235 -0
  68. package/public/js/mobile-debug.js +95 -0
  69. package/public/js/notifications.js +367 -0
  70. package/public/js/offline-queue.js +178 -0
  71. package/public/js/onboarding.js +543 -0
  72. package/public/js/panels.js +156 -0
  73. package/public/js/premium.js +412 -0
  74. package/public/js/realtime-voice.js +844 -0
  75. package/public/js/satellite-sync.js +256 -0
  76. package/public/js/satellite-ui.js +175 -0
  77. package/public/js/satellites.js +1516 -0
  78. package/public/js/settings.js +1087 -0
  79. package/public/js/shortcuts.js +381 -0
  80. package/public/js/split-chat.js +1234 -0
  81. package/public/js/split-resize.js +211 -0
  82. package/public/js/splitview.js +340 -0
  83. package/public/js/storage.js +408 -0
  84. package/public/js/streaming-handler.js +324 -0
  85. package/public/js/stt-settings.js +316 -0
  86. package/public/js/theme-generator.js +661 -0
  87. package/public/js/themes.js +164 -0
  88. package/public/js/timestamps.js +198 -0
  89. package/public/js/tts-settings.js +575 -0
  90. package/public/js/ui.js +267 -0
  91. package/public/js/update-notifier.js +143 -0
  92. package/public/js/utils/constants.js +165 -0
  93. package/public/js/utils/sanitize.js +93 -0
  94. package/public/js/utils/sse-parser.js +195 -0
  95. package/public/js/voice.js +883 -0
  96. package/public/manifest.json +58 -0
  97. package/public/moon_texture.jpg +0 -0
  98. package/public/sw.js +221 -0
  99. package/public/three.min.js +6 -0
  100. package/server/channel.js +529 -0
  101. package/server/chat.js +270 -0
  102. package/server/config-store.js +362 -0
  103. package/server/config.js +159 -0
  104. package/server/context.js +131 -0
  105. package/server/gateway-commands.js +211 -0
  106. package/server/gateway-proxy.js +318 -0
  107. package/server/index.js +22 -0
  108. package/server/logger.js +89 -0
  109. package/server/middleware/auth.js +188 -0
  110. package/server/middleware.js +218 -0
  111. package/server/openclaw-discover.js +308 -0
  112. package/server/premium/index.js +156 -0
  113. package/server/premium/license.js +140 -0
  114. package/server/realtime/bridge.js +837 -0
  115. package/server/realtime/index.js +349 -0
  116. package/server/realtime/tts-stream.js +446 -0
  117. package/server/routes/agents.js +564 -0
  118. package/server/routes/artifacts.js +174 -0
  119. package/server/routes/chat.js +311 -0
  120. package/server/routes/config-settings.js +345 -0
  121. package/server/routes/config.js +603 -0
  122. package/server/routes/files.js +307 -0
  123. package/server/routes/index.js +18 -0
  124. package/server/routes/media.js +451 -0
  125. package/server/routes/missed-messages.js +107 -0
  126. package/server/routes/premium.js +75 -0
  127. package/server/routes/push.js +156 -0
  128. package/server/routes/satellite.js +406 -0
  129. package/server/routes/status.js +251 -0
  130. package/server/routes/stt.js +35 -0
  131. package/server/routes/voice.js +260 -0
  132. package/server/routes/webhooks.js +203 -0
  133. package/server/routes.js +206 -0
  134. package/server/runtime-config.js +336 -0
  135. package/server/share.js +305 -0
  136. package/server/stt/faster-whisper.js +72 -0
  137. package/server/stt/groq.js +51 -0
  138. package/server/stt/index.js +196 -0
  139. package/server/stt/openai.js +49 -0
  140. package/server/sync.js +244 -0
  141. package/server/tailscale-https.js +175 -0
  142. package/server/tts.js +646 -0
  143. package/server/update-checker.js +172 -0
  144. package/server/utils/filename.js +129 -0
  145. package/server/utils.js +147 -0
  146. package/server/watchdog.js +318 -0
  147. package/server/websocket/broadcast.js +359 -0
  148. package/server/websocket/connections.js +339 -0
  149. package/server/websocket/index.js +215 -0
  150. package/server/websocket/routing.js +277 -0
  151. package/server/websocket/sync.js +102 -0
  152. package/server.js +404 -0
  153. package/utils/detect-tool-usage.js +93 -0
  154. package/utils/errors.js +158 -0
  155. package/utils/html-escape.js +84 -0
  156. package/utils/id-sanitize.js +94 -0
  157. package/utils/response.js +130 -0
  158. package/utils/with-retry.js +105 -0
@@ -0,0 +1,1234 @@
1
+ // ============================================
2
+ // SPLIT CHAT MODULE
3
+ // Secondary chat component for dual-pane mode
4
+ // Independent chat state sharing the same WebSocket
5
+ // ============================================
6
+
7
+ import { UplinkCore } from './core.js';
8
+ import { UplinkLogger } from './logger.js';
9
+
10
+ const logger = UplinkLogger;
11
+
12
+ // ============================================
13
+ // STATE
14
+ // ============================================
15
+
16
+ // Active session state
17
+ let activeSession = null; // { sessionKey, label, agentName }
18
+
19
+ // DOM references (set in init)
20
+ let paneEl = null;
21
+ let iconEl = null;
22
+ let nameEl = null;
23
+ let messagesEl = null;
24
+ let inputEl = null;
25
+ let sendBtnEl = null;
26
+ let switchBtnEl = null;
27
+ let closeBtnEl = null;
28
+ let contextBadgeEl = null;
29
+ let contextBarFillEl = null;
30
+ let contextTextEl = null;
31
+
32
+ // Streaming handler instance (created lazily via shared UplinkStreamingHandler)
33
+ let streamHandler = null;
34
+
35
+ // History loading timestamp (used to prevent sync event duplication after load)
36
+ let historyLoadedAt = 0;
37
+
38
+ // Track recently sent messages to prevent duplication from sync events
39
+ let lastSentMessageText = null;
40
+ let lastSentMessageTime = 0;
41
+
42
+ // Auto-scroll state
43
+ const SCROLL_THRESHOLD_PX = 100;
44
+ let isNearBottom = true;
45
+
46
+ // Picker state
47
+ let pickerVisible = false;
48
+ let pickerEl = null;
49
+
50
+ // WebSocket listener unsubscribe function
51
+ let wsUnsubscribe = null;
52
+
53
+ // Typing indicator element
54
+ let typingEl = null;
55
+
56
+ // Abort controller for stopping generation
57
+ let currentAbortController = null;
58
+
59
+ // Module init retry state
60
+ let initRetryCount = 0;
61
+ const MAX_INIT_RETRIES = 10;
62
+
63
+ // ============================================
64
+ // SHARED MODULE HELPERS
65
+ // ============================================
66
+
67
+ /**
68
+ * Get or create the streaming handler instance for split chat.
69
+ * Lazily initialized because messagesEl may not be available at module load time.
70
+ */
71
+ function getStreamHandler() {
72
+ if (!streamHandler && messagesEl && window.UplinkStreamingHandler) {
73
+ streamHandler = window.UplinkStreamingHandler.create({
74
+ container: messagesEl,
75
+ formatMessage: renderMarkdown,
76
+ agentId: activeSession?.agentId || null,
77
+ onStreamStart: () => {
78
+ setInputEnabled(false);
79
+ },
80
+ onStreamEnd: () => setInputEnabled(true),
81
+ getIsNearBottom: () => isNearBottom,
82
+ showAvatar: true
83
+ });
84
+ }
85
+ return streamHandler;
86
+ }
87
+
88
+ /**
89
+ * Render markdown text to HTML (delegates to shared module).
90
+ */
91
+ function renderMarkdown(text) {
92
+ return window.UplinkMessageRenderer?.renderMarkdown(text) || text || '';
93
+ }
94
+
95
+ /**
96
+ * Get agent emoji (delegates to shared module).
97
+ */
98
+ function getAgentEmoji(agentId) {
99
+ return window.UplinkMessageRenderer?.getAgentEmoji(agentId) || '🤖';
100
+ }
101
+
102
+ /**
103
+ * Parse a session key into satelliteId and agentId.
104
+ * Format: agent:<agentId>:main or agent:<agentId>:uplink:satellite:<satelliteId>
105
+ * @param {string} sessionKey
106
+ * @returns {{ satelliteId: string, agentId: string }}
107
+ */
108
+ function parseSessionKey(sessionKey) {
109
+ let satelliteId = 'main';
110
+ let agentId = 'main';
111
+ const parts = (sessionKey || '').split(':');
112
+ if (parts[0] === 'agent' && parts.length >= 3) {
113
+ agentId = parts[1];
114
+ if (parts.length >= 5 && parts[2] === 'uplink' && parts[3] === 'satellite') {
115
+ satelliteId = parts[4];
116
+ } else {
117
+ satelliteId = parts[2]; // 'main' or other
118
+ }
119
+ }
120
+ return { satelliteId, agentId };
121
+ }
122
+
123
+ /**
124
+ * Escape HTML for safe insertion (delegates to shared module).
125
+ */
126
+ function escapeHtml(text) {
127
+ if (window.UplinkMessageRenderer?.escapeHtml) {
128
+ return window.UplinkMessageRenderer.escapeHtml(text);
129
+ }
130
+ const div = document.createElement('div');
131
+ div.textContent = text || '';
132
+ return div.innerHTML;
133
+ }
134
+
135
+ /**
136
+ * Escape a string for use in HTML attributes (delegates to shared module).
137
+ */
138
+ function escapeAttr(text) {
139
+ if (window.UplinkMessageRenderer?.escapeAttr) {
140
+ return window.UplinkMessageRenderer.escapeAttr(text);
141
+ }
142
+ return (text || '')
143
+ .replace(/&/g, '&amp;')
144
+ .replace(/"/g, '&quot;')
145
+ .replace(/'/g, '&#39;')
146
+ .replace(/</g, '&lt;')
147
+ .replace(/>/g, '&gt;');
148
+ }
149
+
150
+ // ============================================
151
+ // INITIALIZATION
152
+ // ============================================
153
+
154
+ /**
155
+ * Initialize the split chat module.
156
+ * Called once on page load. Sets up DOM references and event listeners.
157
+ */
158
+ function init() {
159
+ paneEl = document.getElementById('splitSecondary');
160
+ iconEl = document.getElementById('splitSecondaryIcon');
161
+ nameEl = document.getElementById('splitSecondaryName');
162
+ messagesEl = document.getElementById('splitSecondaryMessages');
163
+ inputEl = document.getElementById('splitSecondaryInput');
164
+ sendBtnEl = document.getElementById('splitSecondarySend');
165
+ switchBtnEl = document.getElementById('splitSecondarySwitch');
166
+ closeBtnEl = document.getElementById('splitSecondaryClose');
167
+ contextBadgeEl = document.getElementById('splitContextBadge');
168
+ contextBarFillEl = document.getElementById('splitContextBarFill');
169
+ contextTextEl = document.getElementById('splitContextText');
170
+
171
+ if (!paneEl || !messagesEl || !inputEl || !switchBtnEl || !sendBtnEl) {
172
+ if (initRetryCount >= MAX_INIT_RETRIES) {
173
+ logger.warn('SplitChat: Required elements not found after max retries');
174
+ return;
175
+ }
176
+ initRetryCount++;
177
+ const delay = Math.min(100 * Math.pow(2, initRetryCount), 5000);
178
+ setTimeout(init, delay);
179
+ return;
180
+ }
181
+
182
+ setupEventListeners();
183
+ subscribeToWebSocket();
184
+ logger.debug('SplitChat: Initialized');
185
+ }
186
+
187
+ /**
188
+ * Set up DOM event listeners for input, buttons, and scrolling.
189
+ */
190
+ function setupEventListeners() {
191
+ // Enter to send (Shift+Enter for newline)
192
+ if (inputEl) {
193
+ inputEl.addEventListener('keydown', (e) => {
194
+ if (e.key === 'Enter' && !e.shiftKey) {
195
+ e.preventDefault();
196
+ handleSend();
197
+ }
198
+ });
199
+
200
+ // Auto-resize textarea as user types
201
+ inputEl.addEventListener('input', () => {
202
+ const prevHeight = inputEl.offsetHeight;
203
+ inputEl.style.height = 'auto';
204
+ inputEl.style.height = Math.min(inputEl.scrollHeight, 150) + 'px';
205
+ const delta = inputEl.offsetHeight - prevHeight;
206
+ if (delta > 0 && messagesEl && isNearBottom) {
207
+ messagesEl.scrollTop += delta;
208
+ }
209
+ });
210
+ }
211
+
212
+ // Send button
213
+ if (sendBtnEl) {
214
+ sendBtnEl.addEventListener('click', handleSend);
215
+ }
216
+
217
+ // Switch session button
218
+ if (switchBtnEl) {
219
+ switchBtnEl.addEventListener('click', (e) => {
220
+ e.preventDefault();
221
+ e.stopPropagation();
222
+ showPicker();
223
+ });
224
+ }
225
+
226
+ // Close button
227
+ if (closeBtnEl) {
228
+ closeBtnEl.addEventListener('click', closeSession);
229
+ }
230
+
231
+ // Scroll tracking
232
+ if (messagesEl) {
233
+ messagesEl.addEventListener('scroll', () => {
234
+ const distFromBottom = messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight;
235
+ isNearBottom = distFromBottom <= SCROLL_THRESHOLD_PX;
236
+ }, { passive: true });
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Subscribe to the shared WebSocket connection for streaming events.
242
+ * Filters events to only handle the secondary session's sessionKey.
243
+ */
244
+ function subscribeToWebSocket() {
245
+ if (!window.UplinkConnection) {
246
+ // Retry if connection module not ready yet
247
+ setTimeout(subscribeToWebSocket, 500);
248
+ return;
249
+ }
250
+
251
+ wsUnsubscribe = window.UplinkConnection.onConnection((event, data) => {
252
+ if (event !== 'message') return;
253
+ if (!activeSession) return;
254
+
255
+ let parsed;
256
+ try {
257
+ parsed = typeof data === 'string' ? JSON.parse(data) : data;
258
+ } catch {
259
+ return;
260
+ }
261
+
262
+ // Unwrap OpenClaw event envelope
263
+ if (parsed.type === 'event' && parsed.event && parsed.payload) {
264
+ handleWsEvent(parsed.event, parsed.payload);
265
+ return;
266
+ }
267
+
268
+ // Handle sync events routed directly (connection.js sends raw data, so
269
+ // this path fires when another module re-emits with unwrapped type)
270
+ if (parsed.type && parsed.type.startsWith('sync_')) {
271
+ handleSyncEvent(parsed.type, parsed);
272
+ }
273
+ });
274
+ }
275
+
276
+ // ============================================
277
+ // WEBSOCKET EVENT HANDLERS
278
+ // ============================================
279
+
280
+ /**
281
+ * Handle unwrapped OpenClaw event envelope messages.
282
+ */
283
+ function handleWsEvent(eventName, payload) {
284
+ if (!activeSession) return;
285
+
286
+ // Check if this event is for our session
287
+ const eventSatelliteId = payload.satelliteId;
288
+ if (eventSatelliteId && !isOurSession(eventSatelliteId)) return;
289
+
290
+ switch (eventName) {
291
+ case 'sync.thinking':
292
+ handleStreamThinking(payload);
293
+ break;
294
+ case 'sync.delta':
295
+ handleStreamDelta(payload);
296
+ break;
297
+ case 'sync.tool':
298
+ handleStreamTool(payload);
299
+ break;
300
+ case 'sync.complete':
301
+ handleStreamComplete(payload);
302
+ break;
303
+ case 'sync':
304
+ handleSyncMessage(payload);
305
+ break;
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Handle sync events with type prefix (e.g., sync_delta).
311
+ */
312
+ function handleSyncEvent(type, data) {
313
+ if (!activeSession) return;
314
+
315
+ const eventSatelliteId = data.satelliteId;
316
+ if (eventSatelliteId && !isOurSession(eventSatelliteId)) return;
317
+
318
+ switch (type) {
319
+ case 'sync_thinking':
320
+ handleStreamThinking(data);
321
+ break;
322
+ case 'sync_delta':
323
+ handleStreamDelta(data);
324
+ break;
325
+ case 'sync_tool':
326
+ handleStreamTool(data);
327
+ break;
328
+ case 'sync_complete':
329
+ handleStreamComplete(data);
330
+ break;
331
+ case 'sync_message':
332
+ handleSyncMessage(data);
333
+ break;
334
+ }
335
+ }
336
+
337
+ /**
338
+ * Check if a satelliteId belongs to our active session.
339
+ */
340
+ function isOurSession(satelliteId) {
341
+ if (!activeSession) return false;
342
+
343
+ // Match by sessionKey components
344
+ // Session keys look like: agent:main:main or agent:main:uplink:satellite:sat-xxxxx
345
+ const sk = activeSession.sessionKey;
346
+ if (!sk) return false;
347
+
348
+ // Extract satellite ID from session key
349
+ // Format: agent:<agentId>:uplink:satellite:<satelliteId>
350
+ // Or: agent:<agentId>:main (for primary)
351
+ if (sk.includes(':uplink:satellite:')) {
352
+ const parts = sk.split(':uplink:satellite:');
353
+ return parts[1] === satelliteId;
354
+ }
355
+ // Primary session
356
+ if (sk.endsWith(':main')) {
357
+ return satelliteId === 'main';
358
+ }
359
+
360
+ return false;
361
+ }
362
+
363
+ /**
364
+ * Handle thinking status - show streaming bubble with thinking indicator.
365
+ */
366
+ function handleStreamThinking(data) {
367
+ logger.debug('SplitChat: Thinking event received');
368
+ hideTyping();
369
+ const handler = getStreamHandler();
370
+ if (!handler) return;
371
+ if (!handler.getStreamingDiv()) {
372
+ handler.createStreamingMessage();
373
+ }
374
+
375
+ // Ensure .streaming class is on the div
376
+ const streamingDiv = handler.getStreamingDiv();
377
+ if (streamingDiv && !streamingDiv.classList.contains('streaming')) {
378
+ streamingDiv.classList.add('streaming');
379
+ }
380
+
381
+ handler.updateStreamingContent('🧠 Thinking...');
382
+ }
383
+
384
+ /**
385
+ * Handle streaming delta - accumulate content and render progressively.
386
+ */
387
+ function handleStreamDelta(data) {
388
+ const { content } = data;
389
+ if (!content) return;
390
+
391
+ logger.debug('SplitChat: Delta event received');
392
+ hideTyping();
393
+ const handler = getStreamHandler();
394
+ if (!handler) return;
395
+ if (!handler.getStreamingDiv()) {
396
+ handler.createStreamingMessage();
397
+ }
398
+
399
+ // Ensure .streaming class is on the div
400
+ const streamingDiv = handler.getStreamingDiv();
401
+ if (streamingDiv && !streamingDiv.classList.contains('streaming')) {
402
+ streamingDiv.classList.add('streaming');
403
+ }
404
+
405
+ handler.setStreamContent(handler.getStreamContent() + content);
406
+ handler.updateStreamingContent(handler.getStreamContent());
407
+ }
408
+
409
+ /**
410
+ * Handle tool usage during streaming.
411
+ */
412
+ function handleStreamTool(data) {
413
+ const { tool } = data;
414
+ if (!tool) return;
415
+
416
+ hideTyping();
417
+ const handler = getStreamHandler();
418
+ if (!handler) return;
419
+ if (!handler.getStreamingDiv()) {
420
+ handler.createStreamingMessage();
421
+ }
422
+ handler.updateStreamingContent(`🔧 Using ${tool}...`);
423
+ }
424
+
425
+ /**
426
+ * Handle stream completion - finalize message.
427
+ */
428
+ function handleStreamComplete(_data) {
429
+ const handler = getStreamHandler();
430
+ if (handler) handler.finalizeStreamingMessage();
431
+ }
432
+
433
+ /**
434
+ * Handle a final sync message (complete message, not streaming).
435
+ */
436
+ function handleSyncMessage(data) {
437
+ const { messageId, role, content } = data;
438
+ if (!content) return;
439
+
440
+ // Dedup by messageId via shared streaming handler
441
+ const handler = getStreamHandler();
442
+ if (handler && messageId && handler.isDuplicate(messageId)) {
443
+ logger.debug('SplitChat: Duplicate messageId, skipping:', messageId);
444
+ return;
445
+ }
446
+
447
+ // Skip sync messages that arrived shortly after history load to prevent duplication
448
+ if (historyLoadedAt && Date.now() - historyLoadedAt < 5000) {
449
+ return;
450
+ }
451
+
452
+ // Skip user messages that we just sent (prevent duplication from sync echo)
453
+ if (role === 'user' && content === lastSentMessageText && Date.now() - lastSentMessageTime < 5000) {
454
+ logger.debug('SplitChat: Skipping sync echo of own message');
455
+ return;
456
+ }
457
+
458
+ logger.debug('SplitChat: Processing sync message', role);
459
+
460
+ // If streaming is active, the streaming handlers own the assistant message lifecycle.
461
+ // Sync messages during active streaming are the "final" version - use them to finalize
462
+ // instead of creating a duplicate div.
463
+ if (role === 'assistant' && handler) {
464
+ const streamingDiv = handler.getStreamingDiv();
465
+ const isStreaming = handler.getIsStreaming?.() || !!streamingDiv;
466
+
467
+ if (isStreaming && streamingDiv) {
468
+ // Stream is active - finalize with the sync content (full message)
469
+ handler.setStreamContent(content);
470
+ handler.finalizeStreamingMessage();
471
+ return;
472
+ }
473
+
474
+ // If stream just completed (within 2s), this sync message is a duplicate of what
475
+ // was already finalized by handleStreamComplete. Skip it.
476
+ if (handler._lastFinalizedAt && Date.now() - handler._lastFinalizedAt < 2000) {
477
+ logger.debug('SplitChat: Skipping sync message — stream just finalized');
478
+ return;
479
+ }
480
+ }
481
+
482
+ // Append as a complete message (cross-device sync, another user sent to this satellite)
483
+ if (role === 'assistant' || role === 'user') {
484
+ appendMessage(content, role);
485
+ }
486
+ }
487
+
488
+ // ============================================
489
+ // SESSION MANAGEMENT
490
+ // ============================================
491
+
492
+ /**
493
+ * Connect to a specific gateway session.
494
+ * Loads history and subscribes to streaming events for that session.
495
+ * @param {string} sessionKey - The gateway session key
496
+ * @param {string} label - Human-readable session label
497
+ * @param {string} agentName - The agent name for display
498
+ * @param {string} agentId - The agent ID for avatar lookup
499
+ */
500
+ async function openSession(sessionKey, label, agentName, agentId = 'main') {
501
+ // Close any existing session first
502
+ if (activeSession) {
503
+ resetState();
504
+ }
505
+
506
+ activeSession = { sessionKey, label, agentName, agentId };
507
+
508
+ // Reset streaming handler so it picks up the new agentId
509
+ streamHandler = null;
510
+
511
+ // Update UI - name
512
+ if (nameEl) {
513
+ nameEl.textContent = agentName || label || 'Secondary';
514
+ }
515
+
516
+ // Update UI - avatar
517
+ if (iconEl) {
518
+ const avatarPath = `/img/agents/${agentId}.png`;
519
+ iconEl.src = avatarPath;
520
+ iconEl.alt = `${agentName || 'Agent'} avatar`;
521
+ // Fallback to default if agent avatar doesn't exist
522
+ iconEl.onerror = () => {
523
+ iconEl.onerror = null; // Prevent infinite loop
524
+ iconEl.src = '/img/agents/default.png';
525
+ };
526
+ }
527
+
528
+ // Clear messages container
529
+ if (messagesEl) {
530
+ messagesEl.innerHTML = '';
531
+ }
532
+
533
+ // Enable input
534
+ setInputEnabled(true);
535
+
536
+ // Hide picker if visible
537
+ hidePicker();
538
+
539
+ // Load history
540
+ await loadHistory(sessionKey);
541
+
542
+ // Fetch context data for this session
543
+ fetchContextData();
544
+
545
+ logger.debug('SplitChat: Opened session', sessionKey);
546
+ }
547
+
548
+ /**
549
+ * Disconnect and reset the secondary chat.
550
+ */
551
+ function closeSession() {
552
+ const handler = getStreamHandler();
553
+ if (handler?.getStreamingDiv()) {
554
+ handler.finalizeStreamingMessage();
555
+ }
556
+
557
+ resetState();
558
+
559
+ // Reset context
560
+ contextUsed = 0;
561
+ displayedTokens = 0;
562
+ updateContextDisplay();
563
+
564
+ // Update UI to show picker / empty state
565
+ if (nameEl) {
566
+ nameEl.textContent = '';
567
+ }
568
+ if (messagesEl) {
569
+ messagesEl.innerHTML = '';
570
+ }
571
+
572
+ setInputEnabled(false);
573
+
574
+ logger.debug('SplitChat: Session closed');
575
+ }
576
+
577
+ /**
578
+ * Reset internal state without touching DOM beyond what's needed.
579
+ */
580
+ function resetState() {
581
+ activeSession = null;
582
+ historyLoadedAt = 0;
583
+ hideTyping();
584
+
585
+ const handler = getStreamHandler();
586
+ if (handler) handler.reset();
587
+
588
+ if (currentAbortController) {
589
+ currentAbortController.abort();
590
+ currentAbortController = null;
591
+ }
592
+ }
593
+
594
+ /**
595
+ * Get the active session info.
596
+ * @returns {{ sessionKey: string, label: string, agentName: string } | null}
597
+ */
598
+ function getActiveSession() {
599
+ return activeSession ? { ...activeSession } : null;
600
+ }
601
+
602
+ /**
603
+ * Check if the secondary chat has an active session.
604
+ * @returns {boolean}
605
+ */
606
+ function isActive() {
607
+ return activeSession !== null;
608
+ }
609
+
610
+ // ============================================
611
+ // HISTORY
612
+ // ============================================
613
+
614
+ /**
615
+ * Load chat history from the gateway for the given session key.
616
+ * @param {string} sessionKey - The session key to fetch history for
617
+ */
618
+ async function loadHistory(sessionKey) {
619
+ try {
620
+ const { satelliteId, agentId } = parseSessionKey(sessionKey);
621
+
622
+ const url = `/api/gateway/history?satelliteId=${encodeURIComponent(satelliteId)}&agentId=${encodeURIComponent(agentId)}&limit=50`;
623
+ const response = await fetch(url);
624
+
625
+ if (!response.ok) {
626
+ logger.warn('SplitChat: History fetch failed:', response.status);
627
+ return;
628
+ }
629
+
630
+ const data = await response.json();
631
+ if (!data.ok || !data.messages || data.messages.length === 0) {
632
+ logger.debug('SplitChat: No history messages');
633
+ return;
634
+ }
635
+
636
+ // Render messages
637
+ data.messages.forEach(msg => {
638
+ const role = msg.type === 'user' ? 'user' : 'assistant';
639
+ const timestamp = msg.createdAt ? new Date(msg.createdAt).getTime() : Date.now();
640
+ appendMessage(msg.text || msg.content || '', role, timestamp);
641
+ });
642
+
643
+ // Mark history as loaded to prevent sync event duplication
644
+ historyLoadedAt = Date.now();
645
+
646
+ // Scroll to bottom after loading history
647
+ if (messagesEl) {
648
+ messagesEl.scrollTop = messagesEl.scrollHeight;
649
+ }
650
+
651
+ logger.debug('SplitChat: Loaded', data.messages.length, 'history messages');
652
+ } catch (err) {
653
+ logger.error('SplitChat: Failed to load history', err);
654
+ }
655
+ }
656
+
657
+ // ============================================
658
+ // MESSAGE RENDERING
659
+ // ============================================
660
+
661
+ /**
662
+ * Append a complete message to the secondary messages container.
663
+ * Delegates to shared UplinkMessageRenderer.
664
+ * @param {string} content - Message text (markdown)
665
+ * @param {string} role - 'user' or 'assistant'
666
+ * @param {number} timestamp - Optional timestamp for the message
667
+ */
668
+ function appendMessage(content, role, timestamp = null) {
669
+ if (!messagesEl || !content) return;
670
+
671
+ const renderer = window.UplinkMessageRenderer;
672
+ if (renderer?.addMessageToContainer) {
673
+ return renderer.addMessageToContainer({
674
+ container: messagesEl,
675
+ text: content,
676
+ type: role,
677
+ showAvatar: role === 'assistant',
678
+ agentId: activeSession?.agentId || null,
679
+ timestamp: timestamp || Date.now(),
680
+ scroll: { isNearBottom }
681
+ });
682
+ }
683
+
684
+ // Fallback if renderer not loaded yet
685
+ const div = document.createElement('div');
686
+ div.className = `message ${role}`;
687
+ div.dataset.time = timestamp || Date.now();
688
+
689
+ const textSpan = document.createElement('span');
690
+ textSpan.className = 'message-text';
691
+ textSpan.innerHTML = renderMarkdown(content);
692
+ div.appendChild(textSpan);
693
+
694
+ messagesEl.appendChild(div);
695
+ if (isNearBottom) {
696
+ messagesEl.scrollTop = messagesEl.scrollHeight;
697
+ }
698
+ }
699
+
700
+ // ============================================
701
+ // STREAMING MESSAGE MANAGEMENT
702
+ // Delegated to shared UplinkStreamingHandler instance.
703
+ // See getStreamHandler() in SHARED MODULE HELPERS section.
704
+ // ============================================
705
+
706
+ // ============================================
707
+ // SENDING MESSAGES
708
+ // ============================================
709
+
710
+ /**
711
+ * Handle send button click or Enter key.
712
+ */
713
+ async function handleSend() {
714
+ if (!activeSession) return;
715
+ const handler = getStreamHandler();
716
+ if (handler?.getIsStreaming()) return;
717
+
718
+ const text = inputEl?.value?.trim();
719
+ if (!text) return;
720
+
721
+ // Clear input
722
+ inputEl.value = '';
723
+ inputEl.style.height = 'auto';
724
+
725
+ // Show user message
726
+ appendMessage(text, 'user');
727
+
728
+ // Track this message to prevent duplication from sync events
729
+ lastSentMessageText = text;
730
+ lastSentMessageTime = Date.now();
731
+
732
+ // Show typing indicator
733
+ showTyping();
734
+ setInputEnabled(false);
735
+
736
+ // Create abort controller
737
+ currentAbortController = new AbortController();
738
+
739
+ try {
740
+ const { satelliteId, agentId } = parseSessionKey(activeSession.sessionKey);
741
+
742
+ const response = await fetch('/api/chat', {
743
+ method: 'POST',
744
+ headers: { 'Content-Type': 'application/json' },
745
+ body: JSON.stringify({
746
+ message: text,
747
+ stream: true,
748
+ satelliteId,
749
+ satelliteName: activeSession.label || satelliteId,
750
+ agentId
751
+ }),
752
+ signal: currentAbortController.signal
753
+ });
754
+
755
+ if (!response.ok) {
756
+ throw new Error(`HTTP ${response.status}`);
757
+ }
758
+
759
+ // Process SSE stream
760
+ // The WebSocket sync events will handle rendering via our WS listener.
761
+ // We still read the SSE stream to handle cases where WS sync isn't available
762
+ // and to detect completion.
763
+ await processSSEResponse(response);
764
+
765
+ } catch (err) {
766
+ if (err.name === 'AbortError') {
767
+ appendSystemMessage('Generation stopped');
768
+ } else {
769
+ logger.error('SplitChat: Send failed', err);
770
+ appendSystemMessage('Failed to send message');
771
+ }
772
+ } finally {
773
+ hideTyping();
774
+ currentAbortController = null;
775
+
776
+ // If streaming didn't start, ensure input is re-enabled
777
+ if (!handler?.getIsStreaming()) {
778
+ setInputEnabled(true);
779
+ }
780
+
781
+ // Refresh context after message completes
782
+ refreshContext();
783
+ }
784
+ }
785
+
786
+ /**
787
+ * Process SSE response from /api/chat.
788
+ * WebSocket sync events handle the real-time streaming display.
789
+ * The SSE stream is read as a fallback and for completion detection.
790
+ * Delegates to shared UplinkStreamingHandler for chunk processing.
791
+ * @param {Response} response - Fetch response
792
+ */
793
+ async function processSSEResponse(response) {
794
+ const reader = response.body.getReader();
795
+ const decoder = new TextDecoder();
796
+ let buffer = '';
797
+ let fullResponse = '';
798
+
799
+ // Split-chat's WebSocket subscription (subscribeToWebSocket) handles all
800
+ // streaming display via handleStreamThinking/handleStreamDelta/handleStreamComplete.
801
+ // The SSE stream is consumed ONLY to drain the response and track errors.
802
+ // We never create streaming divs here — that would race with the WS handlers.
803
+
804
+ while (true) {
805
+ const { done, value } = await reader.read();
806
+ if (done) break;
807
+
808
+ buffer += decoder.decode(value, { stream: true });
809
+ const lines = buffer.split('\n');
810
+ buffer = lines.pop() || '';
811
+
812
+ for (const line of lines) {
813
+ if (!line.startsWith('data: ')) continue;
814
+
815
+ const data = line.slice(6);
816
+ if (data === '[DONE]' || data.startsWith(':')) continue;
817
+
818
+ try {
819
+ const parsed = JSON.parse(data);
820
+
821
+ // Track content for completion detection
822
+ if (parsed.content) {
823
+ fullResponse += parsed.content;
824
+ }
825
+
826
+ // Surface errors
827
+ if (parsed.error) {
828
+ appendSystemMessage(parsed.error || parsed.message || 'An error occurred');
829
+ setInputEnabled(true);
830
+ }
831
+
832
+ await new Promise(r => setTimeout(r, 0));
833
+ } catch (e) {
834
+ // Skip unparseable chunks
835
+ }
836
+ }
837
+ }
838
+
839
+ return { fullResponse };
840
+ }
841
+
842
+ // ============================================
843
+ // SESSION PICKER
844
+ // ============================================
845
+
846
+ /**
847
+ * Display a session/agent picker overlay inside the secondary pane.
848
+ * Pulls sessions from satellites or falls back to fetching from gateway.
849
+ */
850
+ async function showPicker() {
851
+ logger.debug('SplitChat: showPicker() called');
852
+
853
+ // Check actual DOM presence instead of just the flag
854
+ // (pickerEl might have been removed without calling hidePicker)
855
+ if (pickerEl && pickerEl.parentNode) {
856
+ logger.debug('SplitChat: Picker already visible, hiding');
857
+ hidePicker();
858
+ return;
859
+ }
860
+
861
+ // Reset flag if out of sync
862
+ if (pickerVisible && !pickerEl) {
863
+ pickerVisible = false;
864
+ }
865
+
866
+ // Reset flag if out of sync
867
+ if (pickerVisible && !pickerEl) {
868
+ console.warn('[SplitChat] pickerVisible out of sync, resetting');
869
+ pickerVisible = false;
870
+ }
871
+
872
+ const container = paneEl || document.getElementById('splitSecondary');
873
+ if (!container) {
874
+ logger.warn('SplitChat: No container found for picker');
875
+ return;
876
+ }
877
+
878
+ // Build picker data
879
+ let sessions = [];
880
+
881
+ // Try to get from satellites module
882
+ const sats = window.UplinkSatellites?.getSatellites?.();
883
+ if (sats && Object.keys(sats).length > 0) {
884
+ for (const [id, sat] of Object.entries(sats)) {
885
+ const agentId = sat.agentId || 'main';
886
+ const sessionKey = id === 'main'
887
+ ? `agent:${agentId}:main`
888
+ : `agent:${agentId}:uplink:satellite:${id}`;
889
+
890
+ sessions.push({
891
+ sessionKey,
892
+ label: sat.name || id,
893
+ agentName: sat.name || id,
894
+ agentId,
895
+ emoji: getAgentEmoji(agentId),
896
+ satelliteId: id
897
+ });
898
+ }
899
+ }
900
+
901
+ // Fallback: fetch from API
902
+ if (sessions.length === 0) {
903
+ try {
904
+ const res = await fetch('/api/gateway/sessions');
905
+ if (res.ok) {
906
+ const data = await res.json();
907
+ if (data.sessions) {
908
+ sessions = data.sessions.map(s => ({
909
+ sessionKey: s.sessionKey || s.key,
910
+ label: s.label || s.name || s.sessionKey,
911
+ agentName: s.agentName || s.label || 'Agent',
912
+ emoji: '🤖',
913
+ satelliteId: s.satelliteId || 'main'
914
+ }));
915
+ }
916
+ }
917
+ } catch (err) {
918
+ logger.warn('SplitChat: Failed to fetch sessions', err);
919
+ }
920
+ }
921
+
922
+ // Filter out the primary chat's current session to avoid conflicts
923
+ const primarySatId = window.UplinkSatellites?.getCurrentSatellite?.() || 'main';
924
+
925
+ // Build picker UI
926
+ pickerEl = document.createElement('div');
927
+ pickerEl.className = 'split-chat-picker';
928
+ pickerEl.innerHTML = `
929
+ <div class="split-chat-picker-header">
930
+ <span class="split-chat-picker-title">Select Session</span>
931
+ <button class="split-chat-picker-close" aria-label="Close picker">&times;</button>
932
+ </div>
933
+ <div class="split-chat-picker-list">
934
+ ${sessions.length === 0 ? '<div class="split-chat-picker-empty">No sessions available</div>' : ''}
935
+ ${sessions.map(s => {
936
+ const isCurrent = s.satelliteId === primarySatId;
937
+ const isActive = activeSession?.sessionKey === s.sessionKey;
938
+ const avatarSrc = `/img/agents/${s.agentId || 'main'}.png`;
939
+ return `
940
+ <button class="split-chat-picker-item${isCurrent ? ' primary' : ''}${isActive ? ' active' : ''}"
941
+ data-session-key="${escapeAttr(s.sessionKey)}"
942
+ data-label="${escapeAttr(s.label)}"
943
+ data-agent-name="${escapeAttr(s.agentName)}"
944
+ data-agent-id="${escapeAttr(s.agentId || 'main')}">
945
+ <img class="split-chat-picker-avatar" src="${avatarSrc}" alt="${escapeAttr(s.agentName || s.label)}" onerror="this.src='/img/agents/default.png'">
946
+ <span class="split-chat-picker-name">${escapeHtml(s.agentName || s.label)}</span>
947
+ ${isCurrent ? '<span class="split-chat-picker-badge">Primary</span>' : ''}
948
+ ${isActive ? '<span class="split-chat-picker-badge active">Active</span>' : ''}
949
+ </button>
950
+ `;
951
+ }).join('')}
952
+ </div>
953
+ `;
954
+
955
+ // Ensure container has relative positioning for overlay
956
+ container.style.position = 'relative';
957
+ container.appendChild(pickerEl);
958
+ pickerVisible = true;
959
+
960
+ // Event listeners
961
+ pickerEl.querySelector('.split-chat-picker-close').addEventListener('click', hidePicker);
962
+
963
+ pickerEl.querySelectorAll('.split-chat-picker-item').forEach(item => {
964
+ item.addEventListener('click', () => {
965
+ const sessionKey = item.dataset.sessionKey;
966
+ const label = item.dataset.label;
967
+ const agentName = item.dataset.agentName;
968
+ const agentId = item.dataset.agentId;
969
+ openSession(sessionKey, label, agentName, agentId);
970
+ });
971
+ });
972
+ }
973
+
974
+ /**
975
+ * Hide the session picker overlay.
976
+ */
977
+ function hidePicker() {
978
+ if (pickerEl && pickerEl.parentNode) {
979
+ pickerEl.remove();
980
+ }
981
+ pickerEl = null;
982
+ pickerVisible = false;
983
+ }
984
+
985
+ // ============================================
986
+ // TYPING INDICATOR
987
+ // ============================================
988
+
989
+ /**
990
+ * Show the typing indicator in the messages container.
991
+ */
992
+ function showTyping() {
993
+ if (typingEl) return;
994
+
995
+ typingEl = document.createElement('div');
996
+ typingEl.className = 'typing';
997
+ typingEl.setAttribute('role', 'status');
998
+ typingEl.setAttribute('aria-label', 'Assistant is typing');
999
+ typingEl.innerHTML = '<span></span><span></span><span></span>';
1000
+
1001
+ if (messagesEl) {
1002
+ messagesEl.appendChild(typingEl);
1003
+ if (isNearBottom) {
1004
+ messagesEl.scrollTop = messagesEl.scrollHeight;
1005
+ }
1006
+ }
1007
+ }
1008
+
1009
+ /**
1010
+ * Hide the typing indicator.
1011
+ */
1012
+ function hideTyping() {
1013
+ if (typingEl) {
1014
+ if (typingEl.parentNode) typingEl.remove();
1015
+ typingEl = null;
1016
+ }
1017
+ // Also remove any orphaned typing elements in our container
1018
+ if (messagesEl) {
1019
+ messagesEl.querySelectorAll('.typing').forEach(el => el.remove());
1020
+ }
1021
+ }
1022
+
1023
+ // ============================================
1024
+ // SYSTEM MESSAGES
1025
+ // ============================================
1026
+
1027
+ /**
1028
+ * Append a system message (error, info) that auto-dismisses.
1029
+ * @param {string} text - Message text
1030
+ */
1031
+ function appendSystemMessage(text) {
1032
+ if (!messagesEl) return;
1033
+
1034
+ const div = document.createElement('div');
1035
+ div.className = 'message system';
1036
+ div.setAttribute('role', 'alert');
1037
+ div.setAttribute('aria-live', 'polite');
1038
+
1039
+ const textSpan = document.createElement('span');
1040
+ textSpan.className = 'message-text';
1041
+ textSpan.textContent = text;
1042
+ div.appendChild(textSpan);
1043
+
1044
+ messagesEl.appendChild(div);
1045
+
1046
+ if (isNearBottom) {
1047
+ messagesEl.scrollTop = messagesEl.scrollHeight;
1048
+ }
1049
+
1050
+ // Auto-dismiss after 10 seconds
1051
+ setTimeout(() => {
1052
+ if (div.parentNode) {
1053
+ div.style.transition = 'opacity 0.3s';
1054
+ div.style.opacity = '0';
1055
+ setTimeout(() => div.remove(), 300);
1056
+ }
1057
+ }, 10000);
1058
+ }
1059
+
1060
+ // ============================================
1061
+ // CONTEXT TRACKING
1062
+ // ============================================
1063
+
1064
+ const DEFAULT_CONTEXT_WINDOW = 200000;
1065
+ let contextUsed = 0;
1066
+ let contextMax = DEFAULT_CONTEXT_WINDOW;
1067
+ let displayedTokens = 0;
1068
+ let contextAnimationFrame = null;
1069
+
1070
+ /**
1071
+ * Format token count to human-readable string
1072
+ */
1073
+ function formatTokens(tokens) {
1074
+ if (tokens >= 1000000) {
1075
+ return (tokens / 1000000).toFixed(1).replace(/\.0$/, '') + 'M';
1076
+ }
1077
+ if (tokens >= 1000) {
1078
+ return (tokens / 1000).toFixed(tokens >= 10000 ? 0 : 1).replace(/\.0$/, '') + 'k';
1079
+ }
1080
+ return String(tokens);
1081
+ }
1082
+
1083
+ /**
1084
+ * Get color based on usage percentage
1085
+ */
1086
+ function getUsageColor(pct) {
1087
+ if (pct >= 90) return 'var(--error)';
1088
+ if (pct >= 75) return 'var(--warning)';
1089
+ return 'var(--success)';
1090
+ }
1091
+
1092
+ /**
1093
+ * Animate token count smoothly
1094
+ */
1095
+ function animateContextTokenCount(target, max) {
1096
+ if (contextAnimationFrame) cancelAnimationFrame(contextAnimationFrame);
1097
+
1098
+ const start = displayedTokens;
1099
+ const diff = target - start;
1100
+ if (diff === 0) return;
1101
+
1102
+ const duration = 600;
1103
+ const startTime = performance.now();
1104
+
1105
+ function tick(now) {
1106
+ const elapsed = now - startTime;
1107
+ const progress = Math.min(elapsed / duration, 1);
1108
+ const eased = 1 - Math.pow(1 - progress, 3);
1109
+
1110
+ displayedTokens = Math.round(start + diff * eased);
1111
+ const isMobile = window.innerWidth <= 768;
1112
+ const pct = Math.min(100, Math.round((displayedTokens / max) * 100));
1113
+
1114
+ if (contextTextEl) {
1115
+ contextTextEl.textContent = isMobile ? `${pct}%` : `${formatTokens(displayedTokens)}/${formatTokens(max)}`;
1116
+ }
1117
+
1118
+ if (progress < 1) {
1119
+ contextAnimationFrame = requestAnimationFrame(tick);
1120
+ } else {
1121
+ displayedTokens = target;
1122
+ contextAnimationFrame = null;
1123
+ }
1124
+ }
1125
+
1126
+ contextAnimationFrame = requestAnimationFrame(tick);
1127
+ }
1128
+
1129
+ /**
1130
+ * Update context badge display
1131
+ */
1132
+ function updateContextDisplay() {
1133
+ if (!contextBadgeEl) return;
1134
+
1135
+ if (contextUsed <= 0) {
1136
+ contextBadgeEl.style.display = 'none';
1137
+ return;
1138
+ }
1139
+
1140
+ contextBadgeEl.style.display = 'flex';
1141
+
1142
+ const pct = Math.min(100, Math.round((contextUsed / contextMax) * 100));
1143
+ const color = getUsageColor(pct);
1144
+
1145
+ if (contextBarFillEl) {
1146
+ contextBarFillEl.style.width = pct + '%';
1147
+ contextBarFillEl.style.background = color;
1148
+ }
1149
+
1150
+ animateContextTokenCount(contextUsed, contextMax);
1151
+
1152
+ contextBadgeEl.title = `Context: ${contextUsed.toLocaleString()} / ${contextMax.toLocaleString()} tokens (${pct}%)`;
1153
+ contextBadgeEl.setAttribute('aria-label', `Context window usage: ${pct}% - ${formatTokens(contextUsed)} of ${formatTokens(contextMax)} tokens used`);
1154
+ }
1155
+
1156
+ /**
1157
+ * Fetch context data for the active split session
1158
+ */
1159
+ async function fetchContextData() {
1160
+ if (!activeSession) {
1161
+ contextUsed = 0;
1162
+ updateContextDisplay();
1163
+ return;
1164
+ }
1165
+
1166
+ try {
1167
+ const { satelliteId, agentId } = parseSessionKey(activeSession.sessionKey);
1168
+ const response = await fetch(`/api/session/context?satelliteId=${encodeURIComponent(satelliteId)}&agentId=${encodeURIComponent(agentId)}`);
1169
+
1170
+ if (!response.ok) return;
1171
+
1172
+ const data = await response.json();
1173
+ if (!data.ok && data.error) {
1174
+ logger.warn('SplitChat: Context fetch error', data.error);
1175
+ return;
1176
+ }
1177
+
1178
+ contextUsed = data.totalTokens || 0;
1179
+ contextMax = data.contextTokens || DEFAULT_CONTEXT_WINDOW;
1180
+ updateContextDisplay();
1181
+ } catch (e) {
1182
+ logger.warn('SplitChat: Context fetch failed', e.message);
1183
+ }
1184
+ }
1185
+
1186
+ /**
1187
+ * Refresh context after message completes
1188
+ */
1189
+ function refreshContext() {
1190
+ setTimeout(fetchContextData, 2000);
1191
+ setTimeout(fetchContextData, 8000);
1192
+ }
1193
+
1194
+ // ============================================
1195
+ // UI HELPERS
1196
+ // ============================================
1197
+
1198
+ /**
1199
+ * Enable or disable the input area.
1200
+ * @param {boolean} enabled
1201
+ */
1202
+ function setInputEnabled(enabled) {
1203
+ if (inputEl) {
1204
+ inputEl.disabled = !enabled;
1205
+ }
1206
+ if (sendBtnEl) {
1207
+ sendBtnEl.disabled = !enabled;
1208
+ }
1209
+ }
1210
+
1211
+ // getAgentEmoji, escapeHtml, escapeAttr are defined in SHARED MODULE HELPERS
1212
+ // section above, delegating to UplinkMessageRenderer.
1213
+
1214
+ // ============================================
1215
+ // PUBLIC API
1216
+ // ============================================
1217
+
1218
+ export const UplinkSplitChat = {
1219
+ init,
1220
+ openSession,
1221
+ closeSession,
1222
+ getActiveSession,
1223
+ isActive,
1224
+ showPicker,
1225
+ hidePicker
1226
+ };
1227
+
1228
+ // Backward compat: assign to window
1229
+ window.UplinkSplitChat = UplinkSplitChat;
1230
+
1231
+ // Register with UplinkCore if available
1232
+ UplinkCore.registerModule('splitChat', init);
1233
+
1234
+ logger.debug('SplitChat: Module loaded');