@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,1516 @@
1
+ // ============================================
2
+ // SATELLITES MODULE
3
+ // Multiple independent conversation tabs
4
+ // ============================================
5
+ //
6
+ // DEPENDENCY INTERFACE:
7
+ // This module requires the following dependencies, which can be provided
8
+ // via UplinkSatellites.init(config) or will fall back to window.* globals:
9
+ //
10
+ // - chat: { onConnection(cb), isConnected(), connected? } - Connection status
11
+ // - notifications: { show(message, type) } - User notifications
12
+ // - storage: { loadHistory() } - For migration from legacy storage
13
+ // - panels: { register(id, handlers), toggle(id) } - Panel management
14
+ // - core: { registerModule(name, initFn) } - Module lifecycle
15
+ // - messageApi: { addMessage(text, type, imageUrl, save) } - Chat messages
16
+ // - logger: { debug(...), warn(...), error(...) } - Logging
17
+ //
18
+ // EVENTS EMITTED:
19
+ // - 'uplink:satellite-switching' - Before switch (detail: { satelliteId })
20
+ // - 'uplink:satellite-switched' - After switch (detail: { satelliteId })
21
+ // - 'satellite:switched' (via event bus) - After switch (detail: { satelliteId })
22
+ //
23
+ // ============================================
24
+
25
+ import { emit as emitEvent, on as onEvent } from './event-bus.js';
26
+ import * as SatelliteUI from './satellite-ui.js';
27
+
28
+ const STORAGE_KEY = 'uplink-satellites';
29
+ const PRIMARY_SATELLITE = 'main';
30
+
31
+ // ============================================
32
+ // DEPENDENCY CONTAINER
33
+ // ============================================
34
+
35
+ // Dependencies with fallbacks to window.* for backwards compatibility
36
+ const deps = {
37
+ chat: null,
38
+ notifications: null,
39
+ storage: null,
40
+ panels: null,
41
+ core: null,
42
+ messageApi: null,
43
+ logger: null
44
+ };
45
+
46
+ /**
47
+ * Resolve a dependency - use injected or fall back to window.*
48
+ */
49
+ function getChat() {
50
+ return deps.chat || window.gatewayChat || window.UplinkChat || null;
51
+ }
52
+
53
+ function getNotifications() {
54
+ return deps.notifications || window.UplinkNotifications || null;
55
+ }
56
+
57
+ function getStorage() {
58
+ return deps.storage || window.UplinkStorage || null;
59
+ }
60
+
61
+ function getPanels() {
62
+ return deps.panels || window.UplinkPanels || null;
63
+ }
64
+
65
+ function getCore() {
66
+ return deps.core || window.UplinkCore || null;
67
+ }
68
+
69
+ function getLogger() {
70
+ return deps.logger || window.logger || console;
71
+ }
72
+
73
+ function getMessageApi() {
74
+ // For message API, prefer UplinkChat's addMessage
75
+ if (deps.messageApi) return deps.messageApi;
76
+ if (window.UplinkChat && window.UplinkChat.addMessage) {
77
+ return { addMessage: window.UplinkChat.addMessage };
78
+ }
79
+ if (window._originalAddMessage) {
80
+ return { addMessage: window._originalAddMessage };
81
+ }
82
+ if (window.addMessage) {
83
+ return { addMessage: window.addMessage };
84
+ }
85
+ return null;
86
+ }
87
+
88
+ // ============================================
89
+ // STATE
90
+ // ============================================
91
+
92
+ let satellites = {};
93
+ let currentSatellite = PRIMARY_SATELLITE;
94
+ let defaultSatellite = PRIMARY_SATELLITE; // Pinned to top of list
95
+ let panelVisible = false;
96
+
97
+ // UI Elements
98
+ let navigatorPanel = null;
99
+
100
+ // Timer tracking for cleanup
101
+ let connectionPollInterval = null;
102
+ let pendingDeleteTimers = new Map();
103
+
104
+ // History fetch debouncing (for visibility change)
105
+ let lastHistoryFetch = 0;
106
+ const HISTORY_FETCH_DEBOUNCE_MS = 10000; // 10 seconds
107
+
108
+ // Module init retry state
109
+ let initRetryCount = 0;
110
+ const MAX_INIT_RETRIES = 10;
111
+
112
+ // Track if message hook is installed
113
+ let messageHookInstalled = false;
114
+
115
+ // ============================================
116
+ // INITIALIZATION
117
+ // ============================================
118
+
119
+ /**
120
+ * Initialize the satellites module with optional dependency injection
121
+ * @param {Object} config - Optional configuration object
122
+ * @param {Object} config.chat - Chat module with onConnection, isConnected
123
+ * @param {Object} config.notifications - Notifications module with show()
124
+ * @param {Object} config.storage - Storage module with loadHistory()
125
+ * @param {Object} config.panels - Panel manager with register(), toggle()
126
+ * @param {Object} config.core - Core module with registerModule()
127
+ * @param {Object} config.messageApi - Message API with addMessage()
128
+ * @param {Object} config.logger - Logger with debug, warn, error
129
+ */
130
+ async function init(config = {}) {
131
+ // Inject dependencies if provided
132
+ if (config.chat) deps.chat = config.chat;
133
+ if (config.notifications) deps.notifications = config.notifications;
134
+ if (config.storage) deps.storage = config.storage;
135
+ if (config.panels) deps.panels = config.panels;
136
+ if (config.core) deps.core = config.core;
137
+ if (config.messageApi) deps.messageApi = config.messageApi;
138
+ if (config.logger) deps.logger = config.logger;
139
+
140
+ loadSatellites();
141
+ await syncRemoteSessionsWrapper();
142
+ createNavigatorPanel();
143
+ injectMessageHooks();
144
+
145
+ // Also listen to event bus for messages (in addition to hook)
146
+ onEvent('message:added', ({ text, type, imageUrl, save }) => {
147
+ if (save && type !== 'system') {
148
+ addMessageToSatellite({ text, type, imageUrl });
149
+ }
150
+ });
151
+
152
+ // Handle URL parameter for satellite embedding (e.g., ?satellite=redos-workshop)
153
+ const urlParams = new URLSearchParams(window.location.search);
154
+ const urlSatelliteId = urlParams.get('satellite');
155
+ if (urlSatelliteId && urlSatelliteId !== 'main') {
156
+ const sanitizedId = urlSatelliteId.replace(/[^a-zA-Z0-9_-]/g, '').substring(0, 32);
157
+ if (sanitizedId) {
158
+ // Create satellite if it doesn't exist
159
+ if (!satellites[sanitizedId]) {
160
+ // Use URL param 'name' if provided, otherwise humanize the ID
161
+ const urlSatelliteName = urlParams.get('name') || sanitizedId.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
162
+ satellites[sanitizedId] = {
163
+ name: urlSatelliteName,
164
+ createdAt: Date.now(),
165
+ embedded: true // Mark as externally embedded
166
+ };
167
+ saveSatellites();
168
+ getLogger().debug('Satellites: Created embedded satellite from URL:', sanitizedId, urlSatelliteName);
169
+ }
170
+ // Switch to the satellite
171
+ currentSatellite = sanitizedId;
172
+ getLogger().debug('Satellites: Switched to URL satellite:', sanitizedId);
173
+ }
174
+ }
175
+
176
+ // Migrate existing history if this is first run
177
+ await migrateExistingHistory();
178
+
179
+ // Check session status on init
180
+ updateSessionStatus();
181
+
182
+ // Setup connection listener for history loading with race condition protection
183
+ setupConnectionListener();
184
+
185
+ // Setup visibility change handler for history refresh on tab focus
186
+ setupVisibilityHandler();
187
+
188
+ getLogger().debug('Satellites: Initialized with', Object.keys(satellites).length, 'satellites');
189
+ }
190
+
191
+ // ============================================
192
+ // CONNECTION LISTENER (Race Condition Fix)
193
+ // ============================================
194
+
195
+ function setupConnectionListener() {
196
+ // Try UplinkConnection first (handles WebSocket state)
197
+ const connection = window.UplinkConnection;
198
+ const chat = getChat();
199
+
200
+ console.log('[Satellites] setupConnectionListener - connection:', !!connection, 'chat:', !!chat);
201
+
202
+ // Prefer UplinkConnection for connection events
203
+ if (connection && connection.onConnection) {
204
+ console.log('[Satellites] Using UplinkConnection for connection events');
205
+ connection.onConnection((event) => {
206
+ if (event === 'connected') {
207
+ console.log('[Satellites] UplinkConnection: connected event, loading history');
208
+ loadHistoryOnConnect();
209
+ }
210
+ });
211
+
212
+ // Check if already connected
213
+ if (connection.isConnected && connection.isConnected()) {
214
+ console.log('[Satellites] UplinkConnection: already connected, loading history');
215
+ loadHistoryOnConnect();
216
+ return;
217
+ }
218
+ } else if (chat && chat.onConnection) {
219
+ // Fallback to chat module
220
+ chat.onConnection(() => {
221
+ console.log('[Satellites] Connection callback fired, loading history');
222
+ loadHistoryOnConnect();
223
+ });
224
+
225
+ if (chat.isConnected && chat.isConnected()) {
226
+ console.log('[Satellites] Already connected on init, loading history');
227
+ loadHistoryOnConnect();
228
+ return;
229
+ }
230
+ } else {
231
+ console.log('[Satellites] No connection module found, will use polling fallback');
232
+ }
233
+
234
+ // Polling fallback: Check every 100ms for connection readiness
235
+ let pollCount = 0;
236
+ const maxPolls = 50; // 5 seconds max
237
+
238
+ // Clear any existing poll interval before starting new one
239
+ if (connectionPollInterval) {
240
+ clearInterval(connectionPollInterval);
241
+ connectionPollInterval = null;
242
+ }
243
+
244
+ connectionPollInterval = setInterval(() => {
245
+ pollCount++;
246
+
247
+ const conn = window.UplinkConnection;
248
+ const chatInstance = getChat();
249
+
250
+ if (pollCount === 1 || pollCount % 10 === 0) {
251
+ const connStatus = conn ? (conn.isConnected ? conn.isConnected() : 'no isConnected') : 'no conn';
252
+ console.log('[Satellites] Polling attempt', pollCount, '- connection:', connStatus);
253
+ }
254
+
255
+ // Check UplinkConnection first
256
+ if (conn && conn.isConnected && conn.isConnected()) {
257
+ console.log('[Satellites] Connection detected via UplinkConnection polling');
258
+ clearInterval(connectionPollInterval);
259
+ connectionPollInterval = null;
260
+ loadHistoryOnConnect();
261
+ return;
262
+ }
263
+
264
+ // Fallback: check chat instance
265
+ if (chatInstance) {
266
+ const isConnected = chatInstance.isConnected ? chatInstance.isConnected() :
267
+ chatInstance.connected || chatInstance.readyState === 1;
268
+
269
+ if (isConnected) {
270
+ console.log('[Satellites] Connection detected via chat polling');
271
+ clearInterval(connectionPollInterval);
272
+ connectionPollInterval = null;
273
+ loadHistoryOnConnect();
274
+ return;
275
+ }
276
+ }
277
+
278
+ // Stop polling after max attempts
279
+ if (pollCount >= maxPolls) {
280
+ clearInterval(connectionPollInterval);
281
+ connectionPollInterval = null;
282
+ console.log('[Satellites] Connection polling timed out after', maxPolls, 'attempts');
283
+ // Last resort: try loading history anyway (HTTP fetch might still work)
284
+ console.log('[Satellites] Attempting history load despite no connection detected');
285
+ loadHistoryOnConnect();
286
+ }
287
+ }, 100);
288
+ }
289
+
290
+ async function loadHistoryOnConnect() {
291
+ console.log('[Satellites] loadHistoryOnConnect called');
292
+
293
+ // Fetch history from Gateway and merge with local messages
294
+ await fetchAndMergeGatewayHistory();
295
+
296
+ // Reload chat display for the current satellite
297
+ reloadChatDisplay();
298
+
299
+ // Also refresh session status
300
+ updateSessionStatus();
301
+
302
+ // Track fetch time for debouncing
303
+ lastHistoryFetch = Date.now();
304
+
305
+ console.log('[Satellites] loadHistoryOnConnect complete');
306
+ }
307
+
308
+ /**
309
+ * Handle visibility change - fetch history when tab becomes visible
310
+ */
311
+ function setupVisibilityHandler() {
312
+ document.addEventListener('visibilitychange', async () => {
313
+ if (document.visibilityState !== 'visible') return;
314
+
315
+ // Don't fire API calls if not connected yet (avoid fetch errors during reconnect)
316
+ if (!window.UplinkConnection?.isConnected?.()) {
317
+ getLogger().debug('Satellites: Skipping visibility fetch (not connected)');
318
+ return;
319
+ }
320
+
321
+ // Debounce: don't fetch if we just fetched recently
322
+ const now = Date.now();
323
+ if (now - lastHistoryFetch < HISTORY_FETCH_DEBOUNCE_MS) {
324
+ getLogger().debug('Satellites: Skipping visibility fetch (debounced)');
325
+ return;
326
+ }
327
+
328
+ getLogger().debug('Satellites: Tab visible, fetching history...');
329
+ lastHistoryFetch = now;
330
+
331
+ // Sync remote sessions (pick up satellites created on other devices)
332
+ const prevCount = Object.keys(satellites).length;
333
+ await syncRemoteSessionsWrapper();
334
+ if (Object.keys(satellites).length > prevCount) {
335
+ // New satellites appeared — refresh the navigator panel
336
+ createNavigatorPanel();
337
+ }
338
+
339
+ await fetchAndMergeGatewayHistory();
340
+ reloadChatDisplay();
341
+ });
342
+ }
343
+
344
+ /**
345
+ * Fetch history from Gateway and merge with local satellite messages
346
+ */
347
+ async function fetchAndMergeGatewayHistory() {
348
+ if (!window.UplinkSatelliteSync) {
349
+ getLogger().warn('Satellites: UplinkSatelliteSync not loaded yet');
350
+ return;
351
+ }
352
+
353
+ const satellite = satellites[currentSatellite];
354
+ if (!satellite) return;
355
+
356
+ await window.UplinkSatelliteSync.fetchAndMergeGatewayHistory(currentSatellite, satellite);
357
+ }
358
+
359
+ // ============================================
360
+ // DATA MANAGEMENT
361
+ // ============================================
362
+
363
+ function loadSatellites() {
364
+ try {
365
+ const stored = localStorage.getItem(STORAGE_KEY);
366
+ if (stored) {
367
+ const data = JSON.parse(stored);
368
+ satellites = data.satellites || {};
369
+ currentSatellite = data.currentSatellite || PRIMARY_SATELLITE;
370
+ defaultSatellite = data.defaultSatellite || PRIMARY_SATELLITE;
371
+ }
372
+
373
+ // Ensure primary satellite exists
374
+ if (!satellites[PRIMARY_SATELLITE]) {
375
+ satellites[PRIMARY_SATELLITE] = createSatelliteData(PRIMARY_SATELLITE, 'Primary', null);
376
+ }
377
+
378
+ // Ensure default satellite still exists, fallback to primary
379
+ if (!satellites[defaultSatellite]) {
380
+ defaultSatellite = PRIMARY_SATELLITE;
381
+ }
382
+ } catch (e) {
383
+ getLogger().error('Satellites: Failed to load', e);
384
+ satellites = { [PRIMARY_SATELLITE]: createSatelliteData(PRIMARY_SATELLITE, 'Primary', null) };
385
+ currentSatellite = PRIMARY_SATELLITE;
386
+ defaultSatellite = PRIMARY_SATELLITE;
387
+ }
388
+ }
389
+
390
+ function saveSatellites() {
391
+ try {
392
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({
393
+ satellites,
394
+ currentSatellite,
395
+ defaultSatellite
396
+ }));
397
+ } catch (e) {
398
+ getLogger().error('Satellites: Failed to save', e);
399
+ }
400
+ }
401
+
402
+ /**
403
+ * Wrapper for sync module - maintains local state
404
+ */
405
+ async function syncRemoteSessionsWrapper() {
406
+ if (!window.UplinkSatelliteSync) {
407
+ getLogger().warn('Satellites: UplinkSatelliteSync not loaded yet');
408
+ return;
409
+ }
410
+
411
+ const result = await window.UplinkSatelliteSync.syncRemoteSessions(
412
+ satellites,
413
+ PRIMARY_SATELLITE,
414
+ currentSatellite
415
+ );
416
+
417
+ // Update local state from sync result
418
+ satellites = result.satellites;
419
+ currentSatellite = result.currentSatellite;
420
+
421
+ if (result.added > 0 || result.removed > 0) {
422
+ saveSatellites();
423
+ }
424
+ }
425
+
426
+ function createSatelliteData(id, name, agentId) {
427
+ return {
428
+ id,
429
+ name,
430
+ agentId: agentId || 'main',
431
+ createdAt: Date.now(),
432
+ messages: []
433
+ };
434
+ }
435
+
436
+ function generateSatelliteId() {
437
+ return 'sat-' + Date.now().toString(36) + Math.random().toString(36).substr(2, 4);
438
+ }
439
+
440
+ function getAgentEmoji(agentId) {
441
+ const agents = window.UplinkAgents?.getAgents?.() || [];
442
+ const agent = agents.find(a => a.id === agentId);
443
+ return agent?.identity?.emoji || '🤖';
444
+ }
445
+
446
+ // ============================================
447
+ // SATELLITE OPERATIONS
448
+ // ============================================
449
+
450
+ function promptForSatelliteName() {
451
+ // Create inline prompt in the navigator panel
452
+ const listEl = document.getElementById('satelliteList');
453
+ if (!listEl) return;
454
+
455
+ // Check if prompt already exists
456
+ if (listEl.querySelector('.satellite-name-prompt')) return;
457
+
458
+ // Build agent options from the agents module
459
+ const agentsList = window.UplinkAgents?.getAgents?.() || [];
460
+ const hasMultipleAgents = agentsList.length > 1;
461
+ const agentOptions = agentsList.map(a => {
462
+ const label = a.identity?.emoji ? `${a.identity.emoji} ${a.identity?.name || a.name || a.id}` : (a.identity?.name || a.name || a.id);
463
+ return `<option value="${a.id}" ${a.default ? 'selected' : ''}>${label}</option>`;
464
+ }).join('');
465
+
466
+ const promptHtml = `
467
+ <div class="satellite-name-prompt">
468
+ <input type="text" class="satellite-name-input" placeholder="Satellite name..." maxlength="32" autofocus>
469
+ ${hasMultipleAgents ? `
470
+ <div class="satellite-agent-field">
471
+ <label class="satellite-agent-label">Agent</label>
472
+ <select class="satellite-agent-select" id="satelliteAgentSelect">
473
+ ${agentOptions}
474
+ </select>
475
+ </div>
476
+ ` : ''}
477
+ <div class="satellite-prompt-buttons">
478
+ <button class="satellite-prompt-cancel">Cancel</button>
479
+ <button class="satellite-prompt-create">Create</button>
480
+ </div>
481
+ </div>
482
+ `;
483
+
484
+ listEl.insertAdjacentHTML('afterbegin', promptHtml);
485
+
486
+ const promptEl = listEl.querySelector('.satellite-name-prompt');
487
+ const inputEl = promptEl.querySelector('.satellite-name-input');
488
+ const cancelBtn = promptEl.querySelector('.satellite-prompt-cancel');
489
+ const createBtn = promptEl.querySelector('.satellite-prompt-create');
490
+ const agentSelect = promptEl.querySelector('#satelliteAgentSelect');
491
+
492
+ inputEl.focus();
493
+
494
+ const cleanup = () => promptEl.remove();
495
+
496
+ const doCreate = () => {
497
+ const name = inputEl.value.trim();
498
+ const agentId = agentSelect?.value || 'main';
499
+ cleanup();
500
+ launchSatellite(name || null, null, agentId);
501
+ };
502
+
503
+ cancelBtn.addEventListener('click', cleanup);
504
+ createBtn.addEventListener('click', doCreate);
505
+ inputEl.addEventListener('keydown', (e) => {
506
+ if (e.key === 'Enter') doCreate();
507
+ if (e.key === 'Escape') cleanup();
508
+ });
509
+ }
510
+
511
+ function launchSatellite(name, initialMessages = null, agentId = null) {
512
+ const satId = generateSatelliteId();
513
+ const satellite = createSatelliteData(
514
+ satId,
515
+ name || `Satellite ${Object.keys(satellites).length}`,
516
+ agentId
517
+ );
518
+
519
+ // If initial messages provided (fork), populate the satellite
520
+ if (initialMessages && Array.isArray(initialMessages)) {
521
+ satellite.messages = initialMessages.map(msg => ({
522
+ text: msg.text,
523
+ type: msg.type,
524
+ timestamp: Date.now()
525
+ }));
526
+ }
527
+
528
+ satellites[satId] = satellite;
529
+ saveSatellites();
530
+
531
+ // Switch to new satellite
532
+ connectToSatellite(satId);
533
+
534
+ // Show notification
535
+ const forkNote = initialMessages ? ` (forked with ${initialMessages.length} messages)` : '';
536
+ showNotification(`Launched "${satellite.name}" 🛰️${forkNote}`);
537
+
538
+ return satId;
539
+ }
540
+
541
+ function connectToSatellite(satId) {
542
+ console.log('[Satellites] connectToSatellite called with:', satId);
543
+ if (!satellites[satId]) return false;
544
+
545
+ // Show loading state and disable interaction
546
+ showSwitchingIndicator(true);
547
+
548
+ currentSatellite = satId;
549
+ saveSatellites();
550
+
551
+ // Emit event BEFORE reload (so listeners can clear state like reply context)
552
+ window.dispatchEvent(new CustomEvent('uplink:satellite-switching', {
553
+ detail: { satelliteId: satId }
554
+ }));
555
+
556
+ // Clear sync deduplication state for new satellite
557
+ if (window.UplinkConnection?.clearSyncDedup) {
558
+ window.UplinkConnection.clearSyncDedup();
559
+ }
560
+
561
+ // Fetch history from gateway then reload display
562
+ fetchAndMergeGatewayHistory().then(() => {
563
+ reloadChatDisplay();
564
+ });
565
+ updateNavigator();
566
+
567
+ // Update session status indicator
568
+ updateSessionStatus(satId);
569
+
570
+ // Show toast notification on satellite switch
571
+ showNotification(`Switched to "${satellites[satId].name}" 🛰️`);
572
+
573
+ // Emit event after switch complete
574
+ window.dispatchEvent(new CustomEvent('uplink:satellite-switched', {
575
+ detail: { satelliteId: satId }
576
+ }));
577
+
578
+ // Also emit via event bus for cross-module communication
579
+ emitEvent('satellite:switched', { satelliteId: satId });
580
+
581
+ // Hide loading state after a brief delay to ensure UI updates complete
582
+ setTimeout(() => {
583
+ showSwitchingIndicator(false);
584
+ }, 150);
585
+
586
+ return true;
587
+ }
588
+
589
+ /**
590
+ * Show/hide loading indicator during satellite switch
591
+ */
592
+ function showSwitchingIndicator(show) {
593
+ const messagesEl = document.getElementById('messages');
594
+ const inputArea = document.querySelector('.input-area, .chat-input');
595
+ const overlay = document.getElementById('satelliteSwitchOverlay');
596
+
597
+ if (show) {
598
+ // Disable input during switch
599
+ if (inputArea) {
600
+ inputArea.classList.add('switching-satellite');
601
+ inputArea.style.pointerEvents = 'none';
602
+ inputArea.style.opacity = '0.5';
603
+ }
604
+
605
+ // Add loading overlay to messages area
606
+ if (messagesEl && !overlay) {
607
+ const loadingOverlay = document.createElement('div');
608
+ loadingOverlay.id = 'satelliteSwitchOverlay';
609
+ loadingOverlay.className = 'satellite-switch-overlay';
610
+ loadingOverlay.innerHTML = `
611
+ <div class="satellite-switch-spinner">
612
+ <span>🛰️</span>
613
+ <span>Switching...</span>
614
+ </div>
615
+ `;
616
+ loadingOverlay.style.cssText = `
617
+ position: absolute;
618
+ top: 0;
619
+ left: 0;
620
+ right: 0;
621
+ bottom: 0;
622
+ background: var(--bg-primary, rgba(0,0,0,0.7));
623
+ display: flex;
624
+ align-items: center;
625
+ justify-content: center;
626
+ z-index: 100;
627
+ animation: fadeIn 0.15s ease;
628
+ `;
629
+ messagesEl.style.position = 'relative';
630
+ messagesEl.appendChild(loadingOverlay);
631
+ }
632
+ } else {
633
+ // Re-enable input
634
+ if (inputArea) {
635
+ inputArea.classList.remove('switching-satellite');
636
+ inputArea.style.pointerEvents = '';
637
+ inputArea.style.opacity = '';
638
+ }
639
+
640
+ // Remove overlay
641
+ const existingOverlay = document.getElementById('satelliteSwitchOverlay');
642
+ if (existingOverlay) {
643
+ existingOverlay.remove();
644
+ }
645
+ }
646
+ }
647
+
648
+ /**
649
+ * Fetch and display session status from OpenClaw
650
+ */
651
+ async function updateSessionStatus(satId = currentSatellite) {
652
+ const indicator = document.getElementById('sessionIndicator');
653
+ const keyDisplay = document.getElementById('sessionKeyDisplay');
654
+
655
+ if (!indicator || !keyDisplay) return;
656
+
657
+ if (!window.UplinkSatelliteSync) {
658
+ getLogger().warn('Satellites: UplinkSatelliteSync not loaded yet');
659
+ return;
660
+ }
661
+
662
+ // Show loading state
663
+ indicator.className = 'session-indicator loading';
664
+ indicator.title = 'Checking session...';
665
+ keyDisplay.textContent = 'checking...';
666
+
667
+ const satAgentId = satellites[satId]?.agentId || 'main';
668
+ const data = await window.UplinkSatelliteSync.fetchSessionStatus(satId, satAgentId);
669
+
670
+ if (!data) {
671
+ indicator.className = 'session-indicator error';
672
+ indicator.title = 'Failed to check session';
673
+ keyDisplay.textContent = 'error';
674
+ return;
675
+ }
676
+
677
+ if (data.gatewayConnected) {
678
+ indicator.className = 'session-indicator connected';
679
+ indicator.title = 'Connected to OpenClaw';
680
+ } else {
681
+ indicator.className = 'session-indicator disconnected';
682
+ indicator.title = 'Gateway offline';
683
+ }
684
+
685
+ // Show abbreviated session key
686
+ const shortKey = data.sessionKey.replace('agent:main:', '');
687
+ keyDisplay.textContent = shortKey;
688
+ keyDisplay.title = `Session: ${data.sessionKey}\nClick to copy`;
689
+
690
+ // Click to copy
691
+ keyDisplay.onclick = () => {
692
+ navigator.clipboard.writeText(data.sessionKey);
693
+ showNotification('Session key copied');
694
+ };
695
+ }
696
+
697
+ function deleteSatellite(satId, skipConfirm = false) {
698
+ console.log('[Satellites] deleteSatellite START, panelVisible:', panelVisible);
699
+
700
+ // Primary satellite cannot be deleted (button is disabled in UI as visual feedback)
701
+ if (satId === PRIMARY_SATELLITE) {
702
+ return false;
703
+ }
704
+
705
+ const satellite = satellites[satId];
706
+ const isCurrentSatellite = satId === currentSatellite;
707
+
708
+ // Check for pending delete state (click delete twice to confirm)
709
+ if (!skipConfirm) {
710
+ if (!satellite._pendingDelete) {
711
+ // First click - set pending state and show visual feedback
712
+ satellite._pendingDelete = Date.now();
713
+
714
+ // Extra warning if retiring the current satellite
715
+ if (isCurrentSatellite) {
716
+ showNotification('⚠️ You\'re in this satellite! Click again to retire it.', 'warning');
717
+ } else {
718
+ showNotification('Are you sure you want to retire this satellite?', 'info');
719
+ }
720
+ console.log('[Satellites] After showNotification, panelVisible:', panelVisible);
721
+ updateNavigator(); // Re-render to show pending state
722
+ console.log('[Satellites] After updateNavigator, panelVisible:', panelVisible);
723
+
724
+ // Clear any existing timer for this satellite
725
+ if (pendingDeleteTimers.has(satId)) {
726
+ clearTimeout(pendingDeleteTimers.get(satId));
727
+ }
728
+
729
+ // Auto-clear pending state after 5 seconds
730
+ const timerId = setTimeout(() => {
731
+ if (satellite._pendingDelete) {
732
+ delete satellite._pendingDelete;
733
+ updateNavigator();
734
+ }
735
+ pendingDeleteTimers.delete(satId);
736
+ }, 5000);
737
+ pendingDeleteTimers.set(satId, timerId);
738
+ return false;
739
+ }
740
+
741
+ // Second click on current satellite - show final warning with message count
742
+ if (isCurrentSatellite && !satellite._confirmedCurrentDelete) {
743
+ const msgCount = getMessageCountInSatellite(satId);
744
+ satellite._confirmedCurrentDelete = true;
745
+ showNotification(`⚠️ This will delete ${msgCount} messages. Click once more to confirm.`, 'warning');
746
+
747
+ // Reset the confirmation after 5 seconds
748
+ setTimeout(() => {
749
+ if (satellite._confirmedCurrentDelete) {
750
+ delete satellite._confirmedCurrentDelete;
751
+ delete satellite._pendingDelete;
752
+ updateNavigator();
753
+ }
754
+ }, 5000);
755
+ return false;
756
+ }
757
+ }
758
+
759
+ // Call the retire API to also delete the OpenClaw session
760
+ retireSatelliteSession(satId);
761
+
762
+ // If we're on this satellite, switch to primary
763
+ if (currentSatellite === satId) {
764
+ currentSatellite = PRIMARY_SATELLITE;
765
+ }
766
+
767
+ delete satellites[satId];
768
+ saveSatellites();
769
+ reloadChatDisplay();
770
+ updateNavigator();
771
+
772
+ // Ensure panel stays visible after delete (fix for splitview state desync)
773
+ if (navigatorPanel) {
774
+ navigatorPanel.style.display = 'flex';
775
+ navigatorPanel.classList.add('visible');
776
+ }
777
+ // Also ensure splitview side panel stays open if on desktop
778
+ const sidePanel = document.getElementById('sidePanel');
779
+ if (sidePanel) {
780
+ sidePanel.classList.add('visible');
781
+ document.body.classList.add('panel-open');
782
+ }
783
+
784
+ showNotification('Satellite decommissioned');
785
+ return true;
786
+ }
787
+
788
+ /**
789
+ * Call the backend to retire a satellite's OpenClaw session
790
+ * This deletes both the session entry and transcript from OpenClaw
791
+ */
792
+ async function retireSatelliteSession(satId) {
793
+ if (!window.UplinkSatelliteSync) {
794
+ getLogger().warn('Satellites: UplinkSatelliteSync not loaded yet');
795
+ return;
796
+ }
797
+
798
+ const result = await window.UplinkSatelliteSync.retireSatelliteSession(
799
+ satId,
800
+ satellites[satId]?.agentId || 'main'
801
+ );
802
+
803
+ // Show additional feedback if OpenClaw session was deleted
804
+ if (result && (result.sessionDeleted || result.transcriptDeleted)) {
805
+ showNotification('Session data cleared from OpenClaw', 'info');
806
+ }
807
+ }
808
+
809
+ function setAsPrimary(satId) {
810
+ if (!satellites[satId]) return;
811
+
812
+ // Just set this satellite as the default (pinned to top)
813
+ defaultSatellite = satId;
814
+ saveSatellites();
815
+
816
+ // Switch to it
817
+ connectToSatellite(satId);
818
+ updateNavigator();
819
+
820
+ showNotification(`"${satellites[satId].name}" is now default`);
821
+ }
822
+
823
+ function renameSatellite(satId) {
824
+ const satellite = satellites[satId];
825
+ if (!satellite) return;
826
+
827
+ // Try native prompt first
828
+ let newName = prompt('Rename satellite:', satellite.name);
829
+
830
+ // If prompt returns null (cancelled or not working), use inline edit
831
+ if (newName === null) {
832
+ // Find the satellite item and make name editable
833
+ const item = navigatorPanel?.querySelector(`[data-satellite-id="${satId}"]`);
834
+ const nameEl = item?.querySelector('.satellite-item-name');
835
+ if (nameEl) {
836
+ const input = document.createElement('input');
837
+ input.type = 'text';
838
+ input.value = satellite.name;
839
+ input.className = 'satellite-rename-input';
840
+ input.style.cssText = 'width: 100px; padding: 2px 4px; font-size: inherit; border: 1px solid var(--accent); border-radius: 4px; background: var(--bg-secondary); color: inherit;';
841
+
842
+ const originalText = nameEl.textContent;
843
+ nameEl.textContent = '';
844
+ nameEl.appendChild(input);
845
+ input.focus();
846
+ input.select();
847
+
848
+ const finishEdit = (save) => {
849
+ const val = input.value.trim();
850
+ if (save && val) {
851
+ satellite.name = val;
852
+ saveSatellites();
853
+ }
854
+ nameEl.textContent = save && val ? val : originalText;
855
+ updateNavigator();
856
+ };
857
+
858
+ input.addEventListener('blur', () => finishEdit(true));
859
+ input.addEventListener('keydown', (e) => {
860
+ if (e.key === 'Enter') { e.preventDefault(); finishEdit(true); }
861
+ if (e.key === 'Escape') { e.preventDefault(); finishEdit(false); }
862
+ });
863
+ }
864
+ return;
865
+ }
866
+
867
+ if (newName.trim() === '') return;
868
+
869
+ satellite.name = newName.trim();
870
+ saveSatellites();
871
+ updateNavigator();
872
+ }
873
+
874
+ // ============================================
875
+ // MESSAGE HISTORY
876
+ // ============================================
877
+
878
+ function getMessageHistory(satId = currentSatellite) {
879
+ const satellite = satellites[satId];
880
+ if (!satellite) return [];
881
+ return satellite.messages || [];
882
+ }
883
+
884
+ function addMessageToSatellite(msg) {
885
+ console.log('[Satellites] addMessageToSatellite called, currentSatellite:', currentSatellite, 'msg type:', msg.type);
886
+
887
+ // Main satellite uses Gateway as source of truth for text messages
888
+ // But we DO save image messages locally since Gateway doesn't track them
889
+ if (currentSatellite === 'main') {
890
+ if (!msg.imageUrl) {
891
+ console.log('[Satellites] Skipping local save for main satellite (Gateway is source of truth)');
892
+ return;
893
+ }
894
+ console.log('[Satellites] Saving image message locally for main satellite');
895
+ }
896
+
897
+ if (!satellites[currentSatellite]) {
898
+ console.warn('[Satellites] currentSatellite not found in satellites object!');
899
+ return;
900
+ }
901
+
902
+ // Don't store data URLs — they're too large for localStorage
903
+ const safeMsg = { ...msg };
904
+ if (safeMsg.imageUrl && safeMsg.imageUrl.startsWith('data:')) {
905
+ safeMsg.imageUrl = '__pending_upload__';
906
+ }
907
+
908
+ satellites[currentSatellite].messages.push({
909
+ ...safeMsg,
910
+ timestamp: safeMsg.timestamp || Date.now()
911
+ });
912
+
913
+ console.log('[Satellites] Message saved. Total messages for', currentSatellite, ':', satellites[currentSatellite].messages.length);
914
+ saveSatellites();
915
+ }
916
+
917
+ function getMessageCountInSatellite(satId) {
918
+ return (satellites[satId]?.messages || []).length;
919
+ }
920
+
921
+ // ============================================
922
+ // UI: NAVIGATOR PANEL
923
+ // ============================================
924
+
925
+ // Active tab: 'satellites' or 'agents'
926
+ let activeTab = 'satellites';
927
+
928
+ function switchTab(tab) {
929
+ // Premium gate: block agents tab for free users
930
+ if (tab === 'agents' && window.UplinkPremium && !window.UplinkPremium.isActive()) {
931
+ window.UplinkPremium.showUpgradeModal('Agent management');
932
+ return;
933
+ }
934
+
935
+ activeTab = tab;
936
+ const satTabs = navigatorPanel.querySelectorAll('[data-nav-tab="satellites"]');
937
+ const agentsTabs = navigatorPanel.querySelectorAll('[data-nav-tab="agents"]');
938
+ const satContent = navigatorPanel.querySelector('.satellite-tab-content');
939
+ const agentsContent = navigatorPanel.querySelector('.agents-tab-content');
940
+
941
+ if (tab === 'satellites') {
942
+ satTabs.forEach(t => t.classList.add('active'));
943
+ agentsTabs.forEach(t => t.classList.remove('active'));
944
+ if (satContent) satContent.style.display = '';
945
+ if (agentsContent) agentsContent.style.display = 'none';
946
+ } else {
947
+ satTabs.forEach(t => t.classList.remove('active'));
948
+ agentsTabs.forEach(t => t.classList.add('active'));
949
+ if (satContent) satContent.style.display = 'none';
950
+ if (agentsContent) agentsContent.style.display = '';
951
+ // Render agents into the content area
952
+ if (window.UplinkAgents && agentsContent) {
953
+ window.UplinkAgents.render(agentsContent);
954
+ }
955
+ }
956
+ }
957
+
958
+ function createNavigatorPanel() {
959
+ navigatorPanel = document.createElement('div');
960
+ navigatorPanel.className = 'satellite-navigator';
961
+ navigatorPanel.innerHTML = `
962
+ <div class="satellite-nav-header">
963
+ <div class="satellite-nav-tabs" role="tablist">
964
+ <button class="satellite-nav-tab active" data-nav-tab="satellites" role="tab" aria-selected="true">Sessions</button>
965
+ <button class="satellite-nav-tab" data-nav-tab="agents" role="tab" aria-selected="false">Agents</button>
966
+ </div>
967
+ <button class="satellite-nav-close" aria-label="Close panel"><svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="1" y1="1" x2="13" y2="13"/><line x1="13" y1="1" x2="1" y2="13"/></svg></button>
968
+ </div>
969
+ <div class="satellite-nav-tabs-inline" role="tablist" style="display: none;">
970
+ <button class="satellite-nav-tab active" data-nav-tab="satellites" role="tab" aria-selected="true">Sessions</button>
971
+ <button class="satellite-nav-tab" data-nav-tab="agents" role="tab" aria-selected="false">Agents</button>
972
+ </div>
973
+ <div class="satellite-tab-content">
974
+ <div class="satellite-session-status" id="sessionStatus">
975
+ <span class="session-indicator" id="sessionIndicator">●</span>
976
+ <span class="session-key" id="sessionKeyDisplay" title="Click to copy"></span>
977
+ </div>
978
+ <div class="satellite-list" id="satelliteList" role="listbox" aria-label="Satellite list"></div>
979
+ <div class="satellite-nav-actions">
980
+ <button class="satellite-nav-btn" id="launchSatelliteBtn">+ Launch New</button>
981
+ </div>
982
+ </div>
983
+ <div class="agents-tab-content" style="display: none;"></div>
984
+ `;
985
+
986
+ navigatorPanel.style.display = 'none';
987
+ document.body.appendChild(navigatorPanel);
988
+
989
+ // Tab switching
990
+ navigatorPanel.querySelectorAll('.satellite-nav-tab').forEach(tab => {
991
+ tab.addEventListener('click', () => switchTab(tab.dataset.navTab));
992
+ });
993
+
994
+ // Event listeners
995
+ navigatorPanel.querySelector('.satellite-nav-close').addEventListener('click', function() {
996
+ // If split view is active (desktop), close via split view
997
+ if (window.UplinkSplitView && window.UplinkSplitView.isDesktop() && window.UplinkSplitView.getCurrentPanel() === 'satellites') {
998
+ window.UplinkSplitView.closePanel();
999
+ return;
1000
+ }
1001
+ var panels = getPanels();
1002
+ if (panels) {
1003
+ panels.close('satellites');
1004
+ } else {
1005
+ hideNavigator();
1006
+ }
1007
+ });
1008
+ navigatorPanel.querySelector('#launchSatelliteBtn').addEventListener('click', () => {
1009
+ promptForSatelliteName();
1010
+ });
1011
+
1012
+ // Bind to existing toggle button in header (or create one as fallback)
1013
+ let toggleBtn = document.getElementById('satellitesBtn');
1014
+ if (!toggleBtn) {
1015
+ const header = document.querySelector('.header-right, .header-actions');
1016
+ if (header) {
1017
+ toggleBtn = document.createElement('button');
1018
+ toggleBtn.id = 'satellitesBtn';
1019
+ toggleBtn.className = 'header-btn satellite-toggle';
1020
+ toggleBtn.innerHTML = '<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><circle cx="12" cy="12" r="8" stroke-dasharray="2 4"/><path d="M12 2v2m0 16v2M2 12h2m16 0h2"/></svg>';
1021
+ toggleBtn.title = 'Satellites';
1022
+ toggleBtn.setAttribute('aria-label', 'Satellites');
1023
+ header.insertBefore(toggleBtn, header.firstChild);
1024
+ }
1025
+ }
1026
+ // Bind click handler (splitview intercepts on desktop, this handles mobile)
1027
+ if (toggleBtn) {
1028
+ toggleBtn.addEventListener('click', toggleNavigator);
1029
+ }
1030
+
1031
+ // Register with panel manager for mutual exclusivity
1032
+ registerWithPanelManager();
1033
+
1034
+ updateNavigator();
1035
+ }
1036
+
1037
+ function updateNavigator() {
1038
+ const listEl = document.getElementById('satelliteList');
1039
+ const currentNameEl = document.getElementById('currentSatelliteName');
1040
+
1041
+ if (!listEl) return;
1042
+
1043
+ // Update current satellite name
1044
+ if (currentNameEl && satellites[currentSatellite]) {
1045
+ currentNameEl.textContent = satellites[currentSatellite].name;
1046
+ }
1047
+
1048
+ // Build list view
1049
+ const listHtml = buildSatelliteList();
1050
+ listEl.innerHTML = listHtml;
1051
+
1052
+ // Add event listeners to satellite items
1053
+ const items = listEl.querySelectorAll('.satellite-item');
1054
+ items.forEach(item => {
1055
+ const satId = item.dataset.satelliteId;
1056
+
1057
+ // Click anywhere on the item to connect (except action buttons)
1058
+ item.addEventListener('click', (e) => {
1059
+ if (!e.target.closest('.satellite-item-actions')) {
1060
+ connectToSatellite(satId);
1061
+ }
1062
+ });
1063
+
1064
+ // Keyboard navigation
1065
+ item.addEventListener('keydown', (e) => {
1066
+ const allItems = Array.from(items);
1067
+ const currentIndex = allItems.indexOf(item);
1068
+
1069
+ switch (e.key) {
1070
+ case 'ArrowDown':
1071
+ e.preventDefault();
1072
+ if (currentIndex < allItems.length - 1) {
1073
+ allItems[currentIndex + 1].focus();
1074
+ }
1075
+ break;
1076
+ case 'ArrowUp':
1077
+ e.preventDefault();
1078
+ if (currentIndex > 0) {
1079
+ allItems[currentIndex - 1].focus();
1080
+ }
1081
+ break;
1082
+ case 'Enter':
1083
+ case ' ':
1084
+ e.preventDefault();
1085
+ connectToSatellite(satId);
1086
+ break;
1087
+ case 'Delete':
1088
+ case 'Backspace':
1089
+ e.preventDefault();
1090
+ if (satId !== PRIMARY_SATELLITE) {
1091
+ deleteSatellite(satId);
1092
+ }
1093
+ break;
1094
+ case 'F2':
1095
+ e.preventDefault();
1096
+ renameSatellite(satId);
1097
+ break;
1098
+ case 'Home':
1099
+ e.preventDefault();
1100
+ allItems[0]?.focus();
1101
+ break;
1102
+ case 'End':
1103
+ e.preventDefault();
1104
+ allItems[allItems.length - 1]?.focus();
1105
+ break;
1106
+ }
1107
+ });
1108
+
1109
+ item.querySelector('.satellite-item-rename')?.addEventListener('click', (e) => {
1110
+ e.stopPropagation();
1111
+ renameSatellite(satId);
1112
+ });
1113
+
1114
+ item.querySelector('.satellite-item-delete')?.addEventListener('click', (e) => {
1115
+ console.log('[Satellites] Delete button clicked for:', satId);
1116
+ e.stopPropagation();
1117
+ e.preventDefault();
1118
+ const satellite = satellites[satId];
1119
+ const skipConfirm = satellite?._pendingDelete && (Date.now() - satellite._pendingDelete < 3000);
1120
+ console.log('[Satellites] skipConfirm:', skipConfirm, 'pendingDelete:', satellite?._pendingDelete);
1121
+ if (skipConfirm && satellite) delete satellite._pendingDelete;
1122
+ deleteSatellite(satId, skipConfirm);
1123
+ console.log('[Satellites] After deleteSatellite, panelVisible:', panelVisible);
1124
+ });
1125
+ });
1126
+ }
1127
+
1128
+ function buildSatelliteList() {
1129
+ // Sort satellites: primary first, then by most recently active
1130
+ const sortedIds = Object.keys(satellites).sort((a, b) => {
1131
+ if (a === PRIMARY_SATELLITE) return -1;
1132
+ if (b === PRIMARY_SATELLITE) return 1;
1133
+ return (satellites[b].createdAt || 0) - (satellites[a].createdAt || 0);
1134
+ });
1135
+
1136
+ return sortedIds.map(id => {
1137
+ const satellite = satellites[id];
1138
+ const isCurrent = id === currentSatellite;
1139
+ const isPrimary = id === PRIMARY_SATELLITE;
1140
+ const isDefault = id === defaultSatellite;
1141
+ const msgCount = getMessageCountInSatellite(id);
1142
+ const isPendingDelete = !!satellite._pendingDelete;
1143
+
1144
+ const classes = ['panel-item', 'satellite-item'];
1145
+ if (isCurrent) classes.push('active', 'current');
1146
+ if (isPendingDelete) classes.push('pending-delete');
1147
+
1148
+ // Icon: globe for primary, satellite for everything else
1149
+ const icon = isPrimary ? '🌍' : '🛰️';
1150
+
1151
+ return `
1152
+ <div class="${classes.join(' ')}"
1153
+ data-satellite-id="${id}"
1154
+ role="option"
1155
+ aria-selected="${isCurrent}"
1156
+ tabindex="${isCurrent ? '0' : '-1'}">
1157
+ <span class="panel-item-icon satellite-item-icon" aria-hidden="true">${icon}</span>
1158
+ <span class="panel-item-name satellite-item-name">${escapeHtml(satellite.name)}</span>
1159
+ <div class="satellite-item-actions">
1160
+ <button class="satellite-item-btn satellite-item-rename" title="Rename" aria-label="Rename ${escapeHtml(satellite.name)}">✏️</button>
1161
+ ${!isPrimary ? `
1162
+ <button class="satellite-item-btn satellite-item-delete" title="${isPendingDelete ? 'Confirm retire' : 'Retire'}" aria-label="${isPendingDelete ? 'Confirm retire' : 'Retire'} ${escapeHtml(satellite.name)}">${isPendingDelete ? '⚠️' : '🗑️'}</button>
1163
+ ` : ''}
1164
+ </div>
1165
+ </div>
1166
+ `;
1167
+ }).join('');
1168
+ }
1169
+
1170
+ function showNavigator() {
1171
+ if (navigatorPanel) {
1172
+ navigatorPanel.style.display = 'flex';
1173
+ navigatorPanel.classList.add('visible');
1174
+ panelVisible = true;
1175
+ updateNavigator();
1176
+ }
1177
+ }
1178
+
1179
+ function hideNavigator() {
1180
+ console.log('[Satellites] hideNavigator called');
1181
+ console.trace('[Satellites] hideNavigator stack trace');
1182
+ if (navigatorPanel) {
1183
+ navigatorPanel.classList.remove('visible');
1184
+ // Delay hiding display to allow transition to complete
1185
+ setTimeout(() => {
1186
+ if (!panelVisible) {
1187
+ navigatorPanel.style.display = 'none';
1188
+ }
1189
+ }, 300);
1190
+ panelVisible = false;
1191
+ }
1192
+ }
1193
+
1194
+ function toggleNavigator() {
1195
+ console.log('[Satellites] toggleNavigator called');
1196
+ console.trace('[Satellites] toggleNavigator stack trace');
1197
+ const panels = getPanels();
1198
+ if (panels) {
1199
+ panelVisible = panels.toggle('satellites');
1200
+ console.log('[Satellites] panels.toggle returned:', panelVisible);
1201
+ if (panelVisible) updateNavigator();
1202
+ } else {
1203
+ if (panelVisible) {
1204
+ hideNavigator();
1205
+ } else {
1206
+ showNavigator();
1207
+ }
1208
+ }
1209
+ }
1210
+
1211
+ function registerWithPanelManager() {
1212
+ const panels = getPanels();
1213
+ if (panels && navigatorPanel) {
1214
+ panels.register('satellites', {
1215
+ element: navigatorPanel,
1216
+ isOpen: () => panelVisible,
1217
+ open: showNavigator,
1218
+ close: hideNavigator
1219
+ });
1220
+ }
1221
+ }
1222
+
1223
+ // ============================================
1224
+ // CHAT INTEGRATION
1225
+ // ============================================
1226
+
1227
+ function reloadChatDisplay() {
1228
+ console.log('[Satellites] reloadChatDisplay called');
1229
+ const messagesEl = document.getElementById('messages');
1230
+ const emptyStateEl = document.querySelector('.empty-state');
1231
+
1232
+ if (!messagesEl) {
1233
+ console.warn('[Satellites] messagesEl not found, retrying...');
1234
+ setTimeout(reloadChatDisplay, 100);
1235
+ return;
1236
+ }
1237
+
1238
+ // DON'T wipe if streaming is in progress - the streaming div would be destroyed
1239
+ const streamingDiv = messagesEl.querySelector('.message.streaming');
1240
+ const isProcessing = window.UplinkCore?.chatState === 'processing';
1241
+ if (streamingDiv || isProcessing) {
1242
+ return; // Don't wipe DOM during streaming
1243
+ }
1244
+
1245
+ // Clear current messages
1246
+ messagesEl.innerHTML = '';
1247
+
1248
+ // For main satellite, messages come from Gateway (set by fetchAndMergeGatewayHistory)
1249
+ // For other satellites, use local storage
1250
+ const messages = currentSatellite === 'main'
1251
+ ? (satellites[currentSatellite]?.messages || []) // Gateway messages merged here
1252
+ : getMessageHistory();
1253
+
1254
+ console.log('[Satellites] Reloading display with', messages.length, 'messages for', currentSatellite);
1255
+
1256
+ if (messages.length === 0) {
1257
+ if (emptyStateEl) emptyStateEl.style.display = 'flex';
1258
+ } else {
1259
+ if (emptyStateEl) emptyStateEl.style.display = 'none';
1260
+
1261
+ // Try to use the message API first
1262
+ const msgApi = getMessageApi();
1263
+ console.log('[Satellites] Message API:', msgApi ? 'available' : 'NOT available');
1264
+
1265
+ messages.forEach(msg => {
1266
+ if (msgApi && msgApi.addMessage) {
1267
+ msgApi.addMessage(msg.text, msg.type, msg.imageUrl, false, msg.timestamp || null);
1268
+ } else {
1269
+ // Fallback: render directly to DOM if message API unavailable
1270
+ renderMessageDirectly(messagesEl, msg);
1271
+ }
1272
+ });
1273
+
1274
+ // Scroll to bottom after loading
1275
+ messagesEl.scrollTop = messagesEl.scrollHeight;
1276
+ }
1277
+ }
1278
+
1279
+ /**
1280
+ * Fallback renderer when message API is unavailable
1281
+ */
1282
+ function renderMessageDirectly(container, msg) {
1283
+ const div = document.createElement('div');
1284
+ div.className = `message ${msg.type}`;
1285
+
1286
+ if (msg.imageUrl) {
1287
+ const img = document.createElement('img');
1288
+ img.src = msg.imageUrl;
1289
+ img.alt = msg.type === 'user' ? 'Image shared by you' : 'Image from assistant';
1290
+ div.appendChild(img);
1291
+ }
1292
+
1293
+ if (msg.text) {
1294
+ const textSpan = document.createElement('span');
1295
+ textSpan.className = 'message-text';
1296
+ // Basic formatting - the full formatMessage is in chat.js
1297
+ textSpan.innerHTML = msg.text
1298
+ .replace(/&/g, '&amp;')
1299
+ .replace(/</g, '&lt;')
1300
+ .replace(/>/g, '&gt;')
1301
+ .replace(/\n/g, '<br>');
1302
+ div.appendChild(textSpan);
1303
+ }
1304
+
1305
+ container.appendChild(div);
1306
+ }
1307
+
1308
+ function injectMessageHooks(retryCount = 0) {
1309
+ // Prevent multiple registration
1310
+ if (messageHookInstalled) return;
1311
+
1312
+ console.log('[Satellites] injectMessageHooks attempt', retryCount, '- UplinkChat:', !!window.UplinkChat, 'onMessage:', !!(window.UplinkChat && window.UplinkChat.onMessage));
1313
+
1314
+ // If we have an injected messageApi, use that
1315
+ if (deps.messageApi) {
1316
+ messageHookInstalled = true;
1317
+ console.log('[Satellites] Using injected message API');
1318
+ return;
1319
+ }
1320
+
1321
+ // Use the proper UplinkChat.onMessage hook API
1322
+ if (window.UplinkChat && window.UplinkChat.onMessage) {
1323
+ // Register with the chat module's hook system
1324
+ window.UplinkChat.onMessage(({ text, type, imageUrl, save }) => {
1325
+ // Save to current satellite if this is a new message (not replay)
1326
+ if (save && type !== 'system') {
1327
+ addMessageToSatellite({ text, type, imageUrl });
1328
+ }
1329
+ });
1330
+
1331
+ // Store reference to addMessage for reloadChatDisplay
1332
+ window._originalAddMessage = window.UplinkChat.addMessage || window.addMessage;
1333
+
1334
+ messageHookInstalled = true;
1335
+ console.log('[Satellites] Message hook registered via UplinkChat.onMessage');
1336
+ } else if (retryCount < 20) {
1337
+ // Retry with exponential backoff
1338
+ const delay = Math.min(100 * Math.pow(1.5, retryCount), 1000);
1339
+ setTimeout(() => injectMessageHooks(retryCount + 1), delay);
1340
+ } else {
1341
+ console.warn('[Satellites] Could not register message hook after 20 retries - falling back to event listener');
1342
+ // Fallback: listen for chat module ready event
1343
+ window.addEventListener('uplink:ready', () => {
1344
+ console.log('[Satellites] uplink:ready event received, retrying hook installation');
1345
+ if (window.UplinkChat && !messageHookInstalled) {
1346
+ injectMessageHooks(0);
1347
+ }
1348
+ }, { once: true });
1349
+ }
1350
+ }
1351
+
1352
+ // ============================================
1353
+ // NOTIFICATIONS
1354
+ // ============================================
1355
+
1356
+ function showNotification(message, type = 'success') {
1357
+ // Try to use injected or global notification system
1358
+ const notifications = getNotifications();
1359
+ if (notifications?.show) {
1360
+ notifications.show(message, type);
1361
+ return;
1362
+ }
1363
+
1364
+ // Fallback: create simple toast
1365
+ const toast = document.createElement('div');
1366
+ toast.className = `satellite-toast satellite-toast-${type}`;
1367
+ toast.textContent = message;
1368
+ // M-35: Announce satellite notifications to screen readers
1369
+ toast.setAttribute('role', 'alert');
1370
+ toast.setAttribute('aria-live', 'polite');
1371
+ toast.style.cssText = `
1372
+ position: fixed;
1373
+ bottom: 80px;
1374
+ left: 50%;
1375
+ transform: translateX(-50%);
1376
+ background: var(--bg-secondary, #333);
1377
+ color: var(--text-primary, #fff);
1378
+ padding: 12px 24px;
1379
+ border-radius: 8px;
1380
+ z-index: 10000;
1381
+ animation: fadeInUp 0.3s ease;
1382
+ `;
1383
+ document.body.appendChild(toast);
1384
+
1385
+ setTimeout(() => {
1386
+ toast.style.animation = 'fadeOutDown 0.3s ease';
1387
+ setTimeout(() => toast.remove(), 300);
1388
+ }, 2000);
1389
+ }
1390
+
1391
+ // ============================================
1392
+ // UTILITIES
1393
+ // ============================================
1394
+
1395
+ function escapeHtml(text) {
1396
+ const div = document.createElement('div');
1397
+ div.textContent = text;
1398
+ return div.innerHTML;
1399
+ }
1400
+
1401
+ // ============================================
1402
+ // MIGRATION
1403
+ // ============================================
1404
+
1405
+ async function migrateExistingHistory() {
1406
+ // Only migrate if primary satellite is empty and there's existing history
1407
+ if (satellites[PRIMARY_SATELLITE].messages.length > 0) return;
1408
+
1409
+ // Check for old branches data
1410
+ try {
1411
+ const oldBranches = localStorage.getItem('uplink-branches');
1412
+ if (oldBranches) {
1413
+ const data = JSON.parse(oldBranches);
1414
+ if (data.branches?.main?.messages?.length > 0) {
1415
+ satellites[PRIMARY_SATELLITE].messages = data.branches.main.messages;
1416
+ saveSatellites();
1417
+ getLogger().debug('Satellites: Migrated', satellites[PRIMARY_SATELLITE].messages.length, 'messages from branches');
1418
+ return;
1419
+ }
1420
+ }
1421
+ } catch (e) {
1422
+ getLogger().debug('Satellites: No branch data to migrate');
1423
+ }
1424
+
1425
+ // Try to migrate from storage module
1426
+ try {
1427
+ const storage = getStorage();
1428
+ const existingHistory = await storage?.loadHistory();
1429
+ if (existingHistory && existingHistory.length > 0) {
1430
+ satellites[PRIMARY_SATELLITE].messages = existingHistory;
1431
+ saveSatellites();
1432
+ getLogger().debug('Satellites: Migrated', existingHistory.length, 'messages to primary satellite');
1433
+ }
1434
+ } catch (e) {
1435
+ getLogger().error('Satellites: Migration failed', e);
1436
+ }
1437
+ }
1438
+
1439
+ // ============================================
1440
+ // PUBLIC API
1441
+ // ============================================
1442
+
1443
+ export const UplinkSatellites = {
1444
+ // Initialization with dependency injection
1445
+ init,
1446
+
1447
+ // Satellite operations
1448
+ launchSatellite,
1449
+ connectToSatellite,
1450
+ deleteSatellite,
1451
+ renameSatellite,
1452
+
1453
+ // Getters
1454
+ getCurrentSatellite: () => currentSatellite,
1455
+ getCurrentId: () => currentSatellite,
1456
+ getCurrentAgentId: () => (satellites[currentSatellite]?.agentId || 'main'),
1457
+ getSatellites: () => ({ ...satellites }),
1458
+ getMessageHistory,
1459
+
1460
+ // UI
1461
+ showNavigator,
1462
+ hideNavigator,
1463
+ toggleNavigator,
1464
+
1465
+ // History refresh (fetches from Gateway for main satellite)
1466
+ refreshHistory: loadHistoryOnConnect,
1467
+
1468
+ // Update image URL in stored messages (data URL → server URL)
1469
+ updateLastImageUrl: function(serverUrl) {
1470
+ const sat = satellites[currentSatellite];
1471
+ if (!sat || !sat.messages) return;
1472
+
1473
+ for (let i = sat.messages.length - 1; i >= 0; i--) {
1474
+ const url = sat.messages[i].imageUrl;
1475
+ if (url && (url === '__pending_upload__' || url.startsWith('data:'))) {
1476
+ sat.messages[i].imageUrl = serverUrl;
1477
+ saveSatellites();
1478
+ break;
1479
+ }
1480
+ }
1481
+ }
1482
+ };
1483
+
1484
+ import { UplinkCore } from './core.js';
1485
+
1486
+ // Backward compat: assign to window
1487
+ window.UplinkSatellites = UplinkSatellites;
1488
+
1489
+ // Helper for init retry with exponential backoff
1490
+ function scheduleInitRetry() {
1491
+ if (initRetryCount >= MAX_INIT_RETRIES) {
1492
+ getLogger().warn('Satellites: Max init retries reached, giving up');
1493
+ return;
1494
+ }
1495
+ initRetryCount++;
1496
+ const delay = Math.min(100 * Math.pow(2, initRetryCount), 5000);
1497
+ setTimeout(() => init(), delay);
1498
+ }
1499
+
1500
+ // Cleanup function for timers
1501
+ function cleanup() {
1502
+ // Clear connection poll interval
1503
+ if (connectionPollInterval) {
1504
+ clearInterval(connectionPollInterval);
1505
+ connectionPollInterval = null;
1506
+ }
1507
+ // Clear all pending delete timers
1508
+ pendingDeleteTimers.forEach((timerId) => clearTimeout(timerId));
1509
+ pendingDeleteTimers.clear();
1510
+ }
1511
+
1512
+ // Cleanup on page unload
1513
+ window.addEventListener('beforeunload', cleanup);
1514
+
1515
+ // Register with core for coordinated initialization
1516
+ UplinkCore.registerModule('satellites', init);