@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,190 @@
1
+ // ============================================
2
+ // MARKDOWN MODULE
3
+ // Enhanced markdown rendering for messages
4
+ // ============================================
5
+
6
+ // Validate URL to prevent XSS via javascript: protocol
7
+ function isValidHttpUrl(str) {
8
+ try {
9
+ const url = new URL(str);
10
+ return url.protocol === 'http:' || url.protocol === 'https:';
11
+ } catch {
12
+ return false;
13
+ }
14
+ }
15
+
16
+ // Markdown rendering function
17
+ export function render(text) {
18
+ if (!text) return '';
19
+
20
+ // Escape HTML first
21
+ let html = text
22
+ .replace(/&/g, '&')
23
+ .replace(/</g, '&lt;')
24
+ .replace(/>/g, '&gt;');
25
+
26
+ // Code blocks (```language\ncode\n```)
27
+ html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, (match, lang, code) => {
28
+ const langClass = lang ? ` class="language-${lang}"` : '';
29
+ return `<pre><code${langClass}>${code.trim()}</code></pre>`;
30
+ });
31
+
32
+ // Inline code (`code`)
33
+ html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
34
+
35
+ // Tables (GitHub-flavored markdown) - MUST be before line breaks
36
+ html = html.replace(/\|([^\n]+)\|\n\|[-:\|\s]+\|\n((?:\|[^\n]+\|\n?)+)/g, (match, headerRow, bodyRows) => {
37
+ const headers = headerRow.split('|').map(h => h.trim()).filter(h => h);
38
+ const rows = bodyRows.trim().split('\n').map(row => {
39
+ const cells = row.split('|').map(c => c.trim()).filter(c => c);
40
+ return `<tr>${cells.map(c => `<td>${c}</td>`).join('')}</tr>`;
41
+ }).join('');
42
+ return `<table class="md-table"><thead><tr>${headers.map(h => `<th>${h}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table>`;
43
+ });
44
+
45
+ // Headers (# ## ###)
46
+ html = html.replace(/^### (.+)$/gm, '<h4>$1</h4>');
47
+ html = html.replace(/^## (.+)$/gm, '<h3>$1</h3>');
48
+ html = html.replace(/^# (.+)$/gm, '<h2>$1</h2>');
49
+
50
+ // Bold (**text** or __text__)
51
+ html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
52
+ html = html.replace(/__([^_]+)__/g, '<strong>$1</strong>');
53
+
54
+ // Italic (*text* or _text_)
55
+ html = html.replace(/(^|[^*])\*([^*]+)\*(?!\*)/gm, '$1<em>$2</em>');
56
+ html = html.replace(/(^|[^_])_([^_]+)_(?!_)/gm, '$1<em>$2</em>');
57
+
58
+ // Strikethrough (~~text~~)
59
+ html = html.replace(/~~([^~]+)~~/g, '<del>$1</del>');
60
+
61
+ // Blockquotes (> text)
62
+ html = html.replace(/^&gt; (.+)$/gm, '<blockquote>$1</blockquote>');
63
+ // Merge consecutive blockquotes
64
+ html = html.replace(/<\/blockquote>\n<blockquote>/g, '\n');
65
+
66
+ // Horizontal rules (--- or ***)
67
+ html = html.replace(/^(-{3,}|\*{3,})$/gm, '<hr>');
68
+
69
+ // Unordered lists (- item or * item)
70
+ html = html.replace(/^[\-\*] (.+)$/gm, '<li>$1</li>');
71
+ html = html.replace(/(<li>.*<\/li>\n?)+/g, '<ul>$&</ul>');
72
+
73
+ // Ordered lists (1. item)
74
+ html = html.replace(/^\d+\. (.+)$/gm, '<li>$1</li>');
75
+ // Wrap consecutive li's in ol (avoiding ul)
76
+ html = html.replace(/(<li>.*<\/li>\n?)+/g, (match, _p1, offset) => {
77
+ const before = html.substring(Math.max(0, offset - 5), offset);
78
+ if (before.includes('<ul>') || match.includes('<ul>')) return match;
79
+ return '<ol>' + match + '</ol>';
80
+ });
81
+
82
+ // Images ![alt](url) - process before links to avoid conflict
83
+ html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, url) => {
84
+ const trimmedUrl = url.trim();
85
+
86
+ // Rewrite local file paths to agent media proxy
87
+ // Matches Windows paths (C:\...), Unix paths (/home/...), and relative paths
88
+ if (!/^(https?:|\/api\/)/i.test(trimmedUrl)) {
89
+ // This is a local path - it should have been converted to a proxy URL by the server
90
+ // If it wasn't, skip rendering to avoid broken images
91
+ console.warn('[Markdown] Local file path in markdown image, skipping:', trimmedUrl);
92
+ return `[Image: ${alt || 'local file'}]`;
93
+ }
94
+
95
+ return `<img src="${trimmedUrl}" alt="${alt || ''}" loading="lazy">`;
96
+ });
97
+
98
+ // Links [text](url)
99
+ html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, url) => {
100
+ const trimmedUrl = url.trim();
101
+ if (/^(https?:|mailto:|\/|#)/i.test(trimmedUrl)) {
102
+ return `<a href="${trimmedUrl}" target="_blank" rel="noopener noreferrer">${text}</a>`;
103
+ }
104
+ return text;
105
+ });
106
+
107
+ // Auto-link URLs
108
+ html = html.replace(/(^|[\s>])(https?:\/\/[^\s<]+)/g,
109
+ (match, prefix, url) => {
110
+ if (prefix === '"' || prefix === "'") return match;
111
+ return isValidHttpUrl(url)
112
+ ? `${prefix}<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`
113
+ : match;
114
+ });
115
+
116
+ // Artifact deep links — detect artifacts/filename references and make them clickable
117
+ // Uses data-artifact attribute + event delegation (no inline onclick — works on mobile/PWA)
118
+ html = html.replace(/<code>artifacts\/([a-zA-Z0-9_\-]+\.[a-zA-Z]+)<\/code>/g,
119
+ '<a href="javascript:void(0)" class="artifact-link" data-artifact="$1">📄 $1</a>');
120
+ html = html.replace(/(?<!<code>|data-artifact=")artifacts\/([a-zA-Z0-9_\-]+\.[a-zA-Z]+)/g,
121
+ '<a href="javascript:void(0)" class="artifact-link" data-artifact="$1">📄 $1</a>');
122
+
123
+ // Line breaks
124
+ html = html.replace(/\n/g, '<br>');
125
+
126
+ // Clean up extra breaks around block elements
127
+ html = html.replace(/<br>(<\/?(?:pre|ul|ol|li|blockquote|h[2-4]|hr|table))/g, '$1');
128
+ html = html.replace(/(<\/(?:pre|ul|ol|li|blockquote|h[2-4])>)<br>/g, '$1');
129
+
130
+ return html;
131
+ }
132
+
133
+ // Apply syntax highlighting to code blocks (basic)
134
+ export function highlightCode(container) {
135
+ container.querySelectorAll('pre code').forEach(block => {
136
+ // Skip already-highlighted blocks
137
+ if (block.dataset.highlighted) return;
138
+
139
+ const lang = block.className.replace('language-', '');
140
+ if (!lang) return;
141
+
142
+ const keywords = {
143
+ js: ['const', 'let', 'var', 'function', 'return', 'if', 'else', 'for', 'while', 'class', 'import', 'export', 'from', 'async', 'await', 'try', 'catch', 'throw', 'new', 'this', 'true', 'false', 'null', 'undefined'],
144
+ python: ['def', 'class', 'if', 'elif', 'else', 'for', 'while', 'return', 'import', 'from', 'as', 'try', 'except', 'raise', 'with', 'True', 'False', 'None', 'and', 'or', 'not', 'in', 'is', 'lambda', 'self'],
145
+ css: ['@media', '@import', '@keyframes', '!important'],
146
+ html: []
147
+ };
148
+
149
+ const langKeywords = keywords[lang] || keywords.js;
150
+ if (langKeywords.length === 0) return;
151
+
152
+ let code = block.innerHTML;
153
+
154
+ // Highlight strings
155
+ code = code.replace(/(["'`])(?:(?!\1)[^\\]|\\.)*\1/g, '<span class="md-string">$&</span>');
156
+
157
+ // Highlight comments
158
+ code = code.replace(/(\/\/.*$|\/\*[\s\S]*?\*\/|#.*$)/gm, '<span class="md-comment">$&</span>');
159
+
160
+ // Highlight keywords (word boundary)
161
+ const keywordRegex = new RegExp(`\\b(${langKeywords.join('|')})\\b`, 'g');
162
+ code = code.replace(keywordRegex, '<span class="md-keyword">$1</span>');
163
+
164
+ // Highlight numbers
165
+ code = code.replace(/\b(\d+\.?\d*)\b/g, '<span class="md-number">$1</span>');
166
+
167
+ block.innerHTML = code;
168
+ block.dataset.highlighted = 'true';
169
+ });
170
+ }
171
+
172
+ // Delegated click handler for artifact deep links (works on mobile/PWA, no inline onclick needed)
173
+ document.addEventListener('click', (e) => {
174
+ const link = e.target.closest('.artifact-link[data-artifact]');
175
+ if (!link) return;
176
+ e.preventDefault();
177
+ const filename = link.dataset.artifact;
178
+ if (filename && window.UplinkArtifacts?.openArtifactByName) {
179
+ window.UplinkArtifacts.openArtifactByName(filename);
180
+ }
181
+ });
182
+
183
+ // Export API
184
+ export const UplinkMarkdown = {
185
+ render,
186
+ highlightCode
187
+ };
188
+
189
+ // Backward compat: assign to window
190
+ window.UplinkMarkdown = UplinkMarkdown;
@@ -0,0 +1,431 @@
1
+ // ============================================
2
+ // MESSAGE ACTIONS MODULE
3
+ // Copy, Reply, Fork to Satellite
4
+ // ============================================
5
+
6
+ import { UplinkCore } from './core.js';
7
+
8
+ // ========== CONSTANTS (L-07: Extract magic numbers) ==========
9
+ const COPY_FEEDBACK_DURATION_MS = 1500; // How long to show "copied" state
10
+ const TEXT_TRUNCATE_REPLY_LENGTH = 150; // Reply context max chars
11
+ const TEXT_TRUNCATE_FORK_LENGTH = 500; // Fork dialog max chars
12
+ const SATELLITE_NAME_MAX_LENGTH = 32; // Max length for satellite name
13
+
14
+ // State
15
+ let replyingTo = null; // { text, type, element }
16
+
17
+ // Icons
18
+ const ICONS = {
19
+ copy: '<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>',
20
+ reply: '<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M9 17l-5-5 5-5"/><path d="M4 12h11a4 4 0 014 4v4"/></svg>',
21
+ fork: '<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><circle cx="18" cy="6" r="3"/><path d="M6 9v6c0 1.1.9 2 2 2h5"/><path d="M18 9v0"/></svg>',
22
+ edit: '<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>'
23
+ };
24
+
25
+ /**
26
+ * Create action buttons for a message
27
+ * @param {HTMLElement} msgElement - The message div
28
+ * @param {string} type - 'user' or 'assistant'
29
+ * @param {string} text - Message text content
30
+ */
31
+ function createActions(msgElement, type, text) {
32
+ const actions = document.createElement('div');
33
+ actions.className = 'message-actions';
34
+
35
+ // Copy button (all messages)
36
+ const copyBtn = createButton('copy', 'Copy', () => copyMessage(text, copyBtn));
37
+ actions.appendChild(copyBtn);
38
+
39
+ // Reply button (all messages)
40
+ const replyBtn = createButton('reply', 'Reply', () => startReply(text, type, msgElement));
41
+ actions.appendChild(replyBtn);
42
+
43
+ // Fork button (all messages)
44
+ const forkBtn = createButton('fork', 'Fork to satellite', () => showForkDialog(msgElement, text));
45
+ actions.appendChild(forkBtn);
46
+
47
+ msgElement.appendChild(actions);
48
+ msgElement.style.position = 'relative'; // For absolute positioning of actions
49
+ }
50
+
51
+ function createButton(icon, title, onClick) {
52
+ const btn = document.createElement('button');
53
+ btn.className = 'message-action-btn';
54
+ btn.innerHTML = ICONS[icon];
55
+ btn.title = title;
56
+ btn.setAttribute('aria-label', title);
57
+ btn.setAttribute('type', 'button');
58
+ btn.onclick = (e) => {
59
+ e.stopPropagation();
60
+ onClick();
61
+ };
62
+ // Support Enter and Space for keyboard activation
63
+ btn.onkeydown = (e) => {
64
+ if (e.key === 'Enter' || e.key === ' ') {
65
+ e.preventDefault();
66
+ e.stopPropagation();
67
+ onClick();
68
+ }
69
+ };
70
+ return btn;
71
+ }
72
+
73
+ // ============================================
74
+ // COPY
75
+ // ============================================
76
+ async function copyMessage(text, btn) {
77
+ try {
78
+ await navigator.clipboard.writeText(text);
79
+ btn.classList.add('copied');
80
+ setTimeout(() => btn.classList.remove('copied'), COPY_FEEDBACK_DURATION_MS);
81
+ } catch (err) {
82
+ if (window.UplinkLogger?.error) {
83
+ window.UplinkLogger.error('Failed to copy:', err);
84
+ }
85
+ // Fallback
86
+ const textarea = document.createElement('textarea');
87
+ textarea.value = text;
88
+ document.body.appendChild(textarea);
89
+ textarea.select();
90
+ document.execCommand('copy');
91
+ document.body.removeChild(textarea);
92
+ btn.classList.add('copied');
93
+ setTimeout(() => btn.classList.remove('copied'), COPY_FEEDBACK_DURATION_MS);
94
+ }
95
+ }
96
+
97
+ // ============================================
98
+ // REPLY
99
+ // ============================================
100
+ function startReply(text, type, element) {
101
+ // Clear any existing reply
102
+ clearReply();
103
+
104
+ replyingTo = { text, type, element };
105
+
106
+ // Highlight the message being replied to
107
+ element.classList.add('replying-to');
108
+
109
+ // Show reply context in input area
110
+ showReplyContext(text, type);
111
+
112
+ // Focus the input
113
+ const input = document.getElementById('textInput');
114
+ if (input) input.focus();
115
+ }
116
+
117
+ function showReplyContext(text, type) {
118
+ const inputArea = document.querySelector('.input-area');
119
+ if (!inputArea) return;
120
+
121
+ // Remove existing context
122
+ const existing = inputArea.querySelector('.reply-context');
123
+ if (existing) existing.remove();
124
+
125
+ // Create context element
126
+ const context = document.createElement('div');
127
+ context.className = 'reply-context';
128
+
129
+ const truncated = text.length > TEXT_TRUNCATE_REPLY_LENGTH ? text.substring(0, TEXT_TRUNCATE_REPLY_LENGTH) + '...' : text;
130
+ const sender = type === 'user' ? 'You' : (window.UplinkCore?.config?.agentName || 'Assistant');
131
+
132
+ context.innerHTML = `
133
+ <div class="reply-context-text">
134
+ <strong>${escapeHtml(sender)}:</strong> ${escapeHtml(truncated)}
135
+ </div>
136
+ <button class="reply-context-close" title="Cancel reply">×</button>
137
+ `;
138
+
139
+ context.querySelector('.reply-context-close').onclick = clearReply;
140
+
141
+ // Insert before the text input row
142
+ const textRow = inputArea.querySelector('.text-input-row');
143
+ if (textRow) {
144
+ inputArea.insertBefore(context, textRow);
145
+ }
146
+ }
147
+
148
+ function clearReply() {
149
+ if (replyingTo?.element) {
150
+ replyingTo.element.classList.remove('replying-to');
151
+ }
152
+ replyingTo = null;
153
+
154
+ // Remove context display
155
+ const context = document.querySelector('.reply-context');
156
+ if (context) context.remove();
157
+ }
158
+
159
+ /**
160
+ * Get reply context for sending with message
161
+ * Returns null if not replying, or { text, type } if replying
162
+ */
163
+ function getReplyContext() {
164
+ if (!replyingTo) return null;
165
+ return { text: replyingTo.text, type: replyingTo.type };
166
+ }
167
+
168
+ /**
169
+ * Format message with reply context for sending
170
+ * @param {string} newMessage - The new message to send
171
+ * @returns {string} - Message formatted with reply context
172
+ */
173
+ function formatMessageWithReply(newMessage) {
174
+ const ctx = getReplyContext();
175
+ if (!ctx) return newMessage;
176
+
177
+ // Clear the reply state
178
+ clearReply();
179
+
180
+ // Format: include the quoted message as context
181
+ const sender = ctx.type === 'user' ? 'User' : 'Assistant';
182
+ const truncated = ctx.text.length > TEXT_TRUNCATE_FORK_LENGTH ? ctx.text.substring(0, TEXT_TRUNCATE_FORK_LENGTH) + '...' : ctx.text;
183
+
184
+ return `[Replying to ${sender}: "${truncated}"]\n\n${newMessage}`;
185
+ }
186
+
187
+ // ============================================
188
+ // FORK TO SATELLITE
189
+ // ============================================
190
+ function showForkDialog(msgElement, text) {
191
+ // Store the trigger element to return focus when closed
192
+ const triggerElement = document.activeElement;
193
+
194
+ // Remove existing dialog
195
+ const existing = document.querySelector('.fork-dialog-backdrop');
196
+ if (existing) existing.remove();
197
+
198
+ const truncated = text.length > TEXT_TRUNCATE_FORK_LENGTH ? text.substring(0, TEXT_TRUNCATE_FORK_LENGTH) + '...' : text;
199
+
200
+ // Create backdrop
201
+ const backdrop = document.createElement('div');
202
+ backdrop.className = 'fork-dialog-backdrop';
203
+ backdrop.setAttribute('role', 'presentation');
204
+
205
+ // Function to close dialog and restore focus
206
+ const closeDialog = () => {
207
+ backdrop.remove();
208
+ // Return focus to trigger element
209
+ if (triggerElement && triggerElement.focus) {
210
+ triggerElement.focus();
211
+ }
212
+ };
213
+
214
+ backdrop.onclick = closeDialog;
215
+
216
+ // Create dialog
217
+ const dialog = document.createElement('div');
218
+ dialog.className = 'fork-dialog';
219
+ dialog.setAttribute('role', 'dialog');
220
+ dialog.setAttribute('aria-modal', 'true');
221
+ dialog.setAttribute('aria-labelledby', 'fork-dialog-title');
222
+ dialog.onclick = (e) => e.stopPropagation();
223
+
224
+ dialog.innerHTML = `
225
+ <h3 id="fork-dialog-title">🛰️ Fork to New Satellite</h3>
226
+ <div class="fork-dialog-preview" aria-label="Message preview">${escapeHtml(truncated)}</div>
227
+ <label for="fork-dialog-input" class="sr-only">Satellite name</label>
228
+ <input id="fork-dialog-input" type="text" placeholder="Satellite name..." maxlength="${SATELLITE_NAME_MAX_LENGTH}" autofocus>
229
+ <div class="fork-dialog-actions">
230
+ <button type="button" class="fork-dialog-cancel">Cancel</button>
231
+ <button type="button" class="fork-dialog-confirm">Create</button>
232
+ </div>
233
+ `;
234
+
235
+ const input = dialog.querySelector('input');
236
+ const cancelBtn = dialog.querySelector('.fork-dialog-cancel');
237
+ const confirmBtn = dialog.querySelector('.fork-dialog-confirm');
238
+
239
+ // Get all focusable elements in the dialog
240
+ const getFocusableElements = () => {
241
+ return dialog.querySelectorAll('input, button');
242
+ };
243
+
244
+ cancelBtn.onclick = closeDialog;
245
+ confirmBtn.onclick = () => {
246
+ const name = input.value.trim() || 'Forked conversation';
247
+ createForkedSatellite(msgElement, name);
248
+ closeDialog();
249
+ };
250
+
251
+ // Handle keyboard events for focus trap and shortcuts
252
+ const handleKeydown = (e) => {
253
+ if (e.key === 'Escape') {
254
+ e.preventDefault();
255
+ closeDialog();
256
+ return;
257
+ }
258
+
259
+ if (e.key === 'Enter' && e.target === input) {
260
+ e.preventDefault();
261
+ confirmBtn.click();
262
+ return;
263
+ }
264
+
265
+ // Focus trap
266
+ if (e.key === 'Tab') {
267
+ const focusable = getFocusableElements();
268
+ const firstElement = focusable[0];
269
+ const lastElement = focusable[focusable.length - 1];
270
+
271
+ if (e.shiftKey) {
272
+ // Shift+Tab: if on first element, go to last
273
+ if (document.activeElement === firstElement) {
274
+ e.preventDefault();
275
+ lastElement.focus();
276
+ }
277
+ } else {
278
+ // Tab: if on last element, go to first
279
+ if (document.activeElement === lastElement) {
280
+ e.preventDefault();
281
+ firstElement.focus();
282
+ }
283
+ }
284
+ }
285
+ };
286
+
287
+ dialog.addEventListener('keydown', handleKeydown);
288
+
289
+ backdrop.appendChild(dialog);
290
+ document.body.appendChild(backdrop);
291
+ input.focus();
292
+ }
293
+
294
+ function createForkedSatellite(msgElement, name) {
295
+ if (!window.UplinkSatellites) {
296
+ if (window.UplinkLogger?.error) {
297
+ window.UplinkLogger.error('Satellites module not available');
298
+ }
299
+ return;
300
+ }
301
+
302
+ // Get all messages up to and including the selected one
303
+ const messages = document.querySelectorAll('.message');
304
+ const messageHistory = [];
305
+
306
+ for (const msg of messages) {
307
+ const text = msg.dataset.originalText || msg.querySelector('.message-text')?.textContent || '';
308
+ const type = msg.classList.contains('user') ? 'user' :
309
+ msg.classList.contains('assistant') ? 'assistant' : 'system';
310
+
311
+ if (type !== 'system' && text) {
312
+ messageHistory.push({ text, type });
313
+ }
314
+
315
+ // Stop after the selected message
316
+ if (msg === msgElement) break;
317
+ }
318
+
319
+ // Create new satellite with this history
320
+ window.UplinkSatellites.launchSatellite(name, messageHistory);
321
+
322
+ // Show notification
323
+ if (window.showNotification) {
324
+ window.showNotification(`Forked to "${name}" with ${messageHistory.length} messages`, 'success');
325
+ }
326
+ }
327
+
328
+ // ============================================
329
+ // UTILITIES
330
+ // ============================================
331
+ function escapeHtml(text) {
332
+ const div = document.createElement('div');
333
+ div.textContent = text;
334
+ return div.innerHTML;
335
+ }
336
+
337
+ // ============================================
338
+ // INTEGRATION
339
+ // ============================================
340
+
341
+ // Hook into message creation
342
+ function init() {
343
+ // Use the chat module's hook system instead of monkey-patching window.addMessage (H-26)
344
+ if (window.UplinkChat?.onMessage) {
345
+ window.UplinkChat.onMessage(({ text, type }) => {
346
+ if (type !== 'system' && text) {
347
+ const messages = document.querySelectorAll('.message');
348
+ const lastMsg = messages[messages.length - 1];
349
+ if (lastMsg && !lastMsg.querySelector('.message-actions')) {
350
+ createActions(lastMsg, type, text);
351
+ }
352
+ }
353
+ });
354
+ } else if (window.addMessage) {
355
+ // Fallback: legacy monkey-patch if hook system not available
356
+ const originalAddMessage = window.addMessage;
357
+ window.addMessage = function(text, type, imageUrl, save) {
358
+ originalAddMessage(text, type, imageUrl, save);
359
+
360
+ // Add actions to the newly added message
361
+ if (type !== 'system') {
362
+ const messages = document.querySelectorAll('.message');
363
+ const lastMsg = messages[messages.length - 1];
364
+ if (lastMsg && !lastMsg.querySelector('.message-actions')) {
365
+ createActions(lastMsg, type, text);
366
+ }
367
+ }
368
+ };
369
+ }
370
+
371
+ // Add actions to existing messages on page load
372
+ setTimeout(() => {
373
+ document.querySelectorAll('.message').forEach(msg => {
374
+ if (msg.querySelector('.message-actions')) return;
375
+
376
+ const type = msg.classList.contains('user') ? 'user' :
377
+ msg.classList.contains('assistant') ? 'assistant' : 'system';
378
+ const text = msg.dataset.originalText || msg.querySelector('.message-text')?.textContent || '';
379
+
380
+ if (type !== 'system' && text) {
381
+ createActions(msg, type, text);
382
+ }
383
+ });
384
+ }, 500);
385
+ }
386
+
387
+ // Register with core for coordinated initialization
388
+ UplinkCore.registerModule('message-actions', init);
389
+
390
+ /**
391
+ * Refresh actions on all messages (call after satellite switch or reload)
392
+ */
393
+ function refreshActions() {
394
+ document.querySelectorAll('.message').forEach(msg => {
395
+ if (msg.querySelector('.message-actions')) return;
396
+
397
+ const type = msg.classList.contains('user') ? 'user' :
398
+ msg.classList.contains('assistant') ? 'assistant' : 'system';
399
+ const text = msg.dataset.originalText || msg.querySelector('.message-text')?.textContent || '';
400
+
401
+ if (type !== 'system' && text) {
402
+ createActions(msg, type, text);
403
+ }
404
+ });
405
+ }
406
+
407
+ // Listen for satellite switch events - clear reply context and refresh actions
408
+ window.addEventListener('uplink:satellite-switching', () => {
409
+ // Clear reply context BEFORE the switch happens
410
+ clearReply();
411
+ });
412
+
413
+ window.addEventListener('uplink:satellite-switched', () => {
414
+ // Clear reply again (in case) and refresh actions on new messages
415
+ clearReply();
416
+ refreshActions();
417
+ });
418
+
419
+ // Expose API
420
+ export const UplinkMessageActions = {
421
+ createActions,
422
+ startReply,
423
+ clearReply,
424
+ getReplyContext,
425
+ formatMessageWithReply,
426
+ refreshActions
427
+ };
428
+
429
+ // Backward compat: assign to window
430
+ window.UplinkMessageActions = UplinkMessageActions;
431
+