agentchannel 0.7.24 → 0.7.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/ui/app.js +221 -26
  3. package/ui/index.html +7 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentchannel",
3
- "version": "0.7.24",
3
+ "version": "0.7.26",
4
4
  "description": "Encrypted cross-network messaging for AI coding agents via MCP",
5
5
  "type": "module",
6
6
  "main": "dist/server.js",
package/ui/app.js CHANGED
@@ -38,6 +38,9 @@ var collapsedGroups = { "AgentChannel": true };
38
38
  var onlineMembers = {}; // channel -> Set of names
39
39
  var channelMetas = {}; // channel name -> meta object
40
40
 
41
+ var dmChannels = {}; // theirFingerprint -> {key, hash, name, theirFp}
42
+ var dmNames = {}; // theirFingerprint -> display name
43
+
41
44
  var encoder = new TextEncoder();
42
45
  var decoder = new TextDecoder();
43
46
 
@@ -87,6 +90,22 @@ async function hashSubWeb(channelKey, subName) {
87
90
  return Array.from(topicBytes).map(function(b) { return b.toString(16).padStart(2, "0"); }).join("");
88
91
  }
89
92
 
93
+ async function deriveDmKeyWeb(fpA, fpB) {
94
+ var sorted = [fpA, fpB].sort();
95
+ var ikm = sorted[0] + sorted[1];
96
+ var prk = await hkdfExtract(ikm);
97
+ var keyBytes = await hkdfExpand(prk, "acp1:dm", 32);
98
+ return crypto.subtle.importKey("raw", keyBytes, {name:"AES-GCM",length:256}, false, ["encrypt","decrypt"]);
99
+ }
100
+
101
+ async function hashDmWeb(fpA, fpB) {
102
+ var sorted = [fpA, fpB].sort();
103
+ var ikm = sorted[0] + sorted[1];
104
+ var prk = await hkdfExtract(ikm);
105
+ var topicBytes = await hkdfExpand(prk, "acp1:topic:dm", 16);
106
+ return Array.from(topicBytes).map(function(b) { return b.toString(16).padStart(2, "0"); }).join("");
107
+ }
108
+
90
109
  async function decryptPayload(payload, key) {
91
110
  var p = JSON.parse(payload);
92
111
  var iv = Uint8Array.from(atob(p.iv), function(c) { return c.charCodeAt(0); });
@@ -172,7 +191,7 @@ function richText(t) {
172
191
  function render() {
173
192
  var filtered;
174
193
  if (activeChannel === "all") {
175
- filtered = allMessages.slice();
194
+ filtered = allMessages.filter(function(m) { return !m.channel || !m.channel.startsWith("dm:"); });
176
195
  } else if (activeChannel === "@me") {
177
196
  filtered = allMessages.filter(function(m) {
178
197
  return m.content && CONFIG.name && m.content.indexOf("@" + CONFIG.name) !== -1;
@@ -207,6 +226,7 @@ function render() {
207
226
 
208
227
  var html = "";
209
228
  var lastSender = null;
229
+ var lastTimestamp = 0;
210
230
  var lastChannel = null;
211
231
 
212
232
  for (var i = 0; i < filtered.length; i++) {
@@ -230,28 +250,27 @@ function render() {
230
250
 
231
251
  var time = new Date(msg.timestamp).toLocaleTimeString([], {hour:"2-digit", minute:"2-digit"});
232
252
  var color = getColor(msg.sender);
233
- var isGrouped = lastSender === msg.sender && lastChannel === msg.channel;
253
+ // Don't group each message shows sender + time independently
254
+ // But reduce spacing if same sender within 5 minutes
255
+ var isCompact = lastSender === msg.sender && lastChannel === msg.channel && (msg.timestamp - lastTimestamp < 300000);
234
256
  var isMention = msg.content && msg.content.indexOf('@' + CONFIG.name) !== -1;
235
257
 
236
- if (!isGrouped) {
237
- if (lastSender !== null) html += '</div>'; // close previous conversation
238
- html += '<div class="conversation"' + (isMention ? ' style="background:var(--mention-bg);border-left:3px solid var(--mention-text);padding-left:12px;margin-left:-15px;border-radius:4px"' : '') + '>';
239
- html += '<div class="conversation__label">';
240
- var msgFp = msg.senderKey ? '(' + msg.senderKey.slice(0, 4) + ')' : '';
241
- html += '<span class="conversation__sender">' + esc(msg.sender) + '<span style="color:var(--text-muted);font-weight:400;font-size:0.65rem;margin-left:2px">' + msgFp + '</span></span>';
242
- if (activeChannel === "all") {
243
- var mlabel = msg.subchannel ? '#' + esc(msg.channel) + ' ##' + esc(msg.subchannel) : '#' + esc(msg.channel);
244
- html += '<span class="conversation__channel">' + mlabel + '</span>';
245
- }
246
- html += '<span class="conversation__time">' + time + '</span>';
247
- html += '</div>';
248
- html += '<button class="msg-copy" onclick="window.copyMsg(this)" data-msg="' + esc(msg.content).replace(/"/g, '&quot;') + '" title="Copy"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>';
258
+ if (lastSender !== null) html += '</div>'; // close previous
259
+ html += '<div class="conversation" style="' + (isCompact ? 'margin-top:4px' : 'margin-top:16px') + (isMention ? ';background:var(--mention-bg);border-left:3px solid var(--mention-text);padding-left:12px;margin-left:-15px;border-radius:4px' : '') + '">';
260
+ html += '<div class="conversation__label">';
261
+ var msgFp = msg.senderKey ? '(' + msg.senderKey.slice(0, 4) + ')' : '';
262
+ html += '<span class="conversation__sender">' + esc(msg.sender) + '<span style="color:var(--text-muted);font-weight:400;font-size:0.65rem;margin-left:2px">' + msgFp + '</span></span>';
263
+ if (activeChannel === "@me") {
264
+ var mlabel = msg.subchannel ? '#' + esc(msg.channel) + ' ##' + esc(msg.subchannel) : '#' + esc(msg.channel);
265
+ html += '<span class="conversation__channel">' + mlabel + '</span>';
249
266
  }
250
-
251
- html += '<div class="conversation__text' + (isGrouped ? ' conversation__text--grouped' : '') + '">' + richText(msg.content) + '</div>';
267
+ html += '<span class="conversation__time">' + time + '</span>';
268
+ html += '</div>';
269
+ html += '<div class="conversation__text">' + richText(msg.content) + '</div>';
252
270
 
253
271
  lastSender = msg.sender;
254
272
  lastChannel = msg.channel;
273
+ lastTimestamp = msg.timestamp;
255
274
  }
256
275
  if (lastSender !== null) html += '</div>'; // close last conversation
257
276
 
@@ -290,10 +309,10 @@ function renderSidebar() {
290
309
  if (mentionCount > 0 || activeChannel === "@me") {
291
310
  var meDiv = document.createElement("div");
292
311
  meDiv.className = "sidebar__channel" + (activeChannel === "@me" ? " active" : "");
293
- meDiv.innerHTML = '<span style="color:var(--mention-text);margin-right:2px">@</span>Mentions' + (mentionCount ? '<span class="badge" style="background:var(--mention-text);color:#fff;opacity:1">' + mentionCount + '</span>' : "");
312
+ meDiv.innerHTML = '<span style="color:var(--mention-text);margin-right:2px">@</span>' + (CONFIG.name || 'Me') + (mentionCount ? '<span class="badge" style="background:var(--mention-text);color:#fff;opacity:1">' + mentionCount + '</span>' : "");
294
313
  meDiv.onclick = function() {
295
314
  activeChannel = "@me";
296
- headerName.textContent = "@Me";
315
+ headerName.textContent = "@" + (CONFIG.name || "Me");
297
316
  headerDesc.textContent = "Messages mentioning you";
298
317
  document.title = "AgentChannel — @Me";
299
318
  renderSidebar();
@@ -303,6 +322,39 @@ function renderSidebar() {
303
322
  el.appendChild(meDiv);
304
323
  }
305
324
 
325
+ // Direct Messages section
326
+ var dmKeys = Object.keys(dmChannels);
327
+ if (dmKeys.length > 0) {
328
+ var dmHeader = document.createElement("div");
329
+ dmHeader.style.cssText = "font-size:0.6rem;color:var(--text-muted);padding:12px 12px 4px;text-transform:uppercase;letter-spacing:0.05em;font-weight:600";
330
+ dmHeader.textContent = "Direct Messages";
331
+ el.appendChild(dmHeader);
332
+
333
+ for (var di = 0; di < dmKeys.length; di++) {
334
+ var dmFp = dmKeys[di];
335
+ var dmInfo = dmChannels[dmFp];
336
+ var dmCid = dmInfo.channelId;
337
+ var dmDisplayName = dmNames[dmFp] || dmFp.slice(0, 8);
338
+ var dmDiv = document.createElement("div");
339
+ dmDiv.className = "sidebar__channel" + (activeChannel === dmCid ? " active" : "");
340
+ var dmCount = unreadCounts[dmCid] || 0;
341
+ dmDiv.innerHTML = '<span style="color:var(--accent);margin-right:2px;opacity:0.7">@</span>' + esc(dmDisplayName) + '<span style="color:var(--text-muted);font-size:0.6rem;margin-left:3px;opacity:0.8">(' + dmFp.slice(0, 4) + ')</span>' + (dmCount ? '<span class="badge">' + dmCount + '</span>' : "");
342
+ (function(fp, channelId, displayName) {
343
+ dmDiv.onclick = function() {
344
+ activeChannel = channelId;
345
+ unreadCounts[channelId] = 0;
346
+ headerName.textContent = "@" + displayName;
347
+ headerDesc.textContent = "DM with " + fp.slice(0, 8);
348
+ document.title = "AgentChannel — DM";
349
+ renderSidebar();
350
+ render();
351
+ if (window.renderMembers) window.renderMembers();
352
+ };
353
+ })(dmFp, dmCid, dmDisplayName);
354
+ el.appendChild(dmDiv);
355
+ }
356
+ }
357
+
306
358
  // Render each parent + children
307
359
  for (var pi = 0; pi < parents.length; pi++) {
308
360
  var ch = parents[pi];
@@ -709,10 +761,12 @@ async function init() {
709
761
  var isOnline = memberMap[name].online;
710
762
  var isYou = name === CONFIG.name;
711
763
  var memberInfo = Object.values(window.cloudMembers || {}).flat().find(function(m) { return m.name === name; });
712
- var fpStr = memberInfo && memberInfo.fingerprint
713
- ? '<span style="color:var(--text-muted);font-size:0.6rem;margin-left:2px">(' + memberInfo.fingerprint.slice(0, 4) + ')</span>'
764
+ var fp = memberInfo && memberInfo.fingerprint ? memberInfo.fingerprint : '';
765
+ var fpStr = fp
766
+ ? '<span style="color:var(--text-muted);font-size:0.6rem;margin-left:2px">(' + fp.slice(0, 4) + ')</span>'
714
767
  : '';
715
- return '<div class="members__item"><span class="members__dot" style="background:' + (isOnline ? "#22c55e" : "#666") + '"></span><span class="members__name">' + esc(name) + fpStr + '</span>' + (isYou ? '<span class="members__role">you</span>' : '') + '</div>';
768
+ var dmClick = (!isYou && fp) ? ' onclick="window.openDm(\x27' + fp + '\x27,\x27' + esc(name).replace(/'/g, '') + '\x27)" style="cursor:pointer" title="Open DM"' : '';
769
+ return '<div class="members__item"' + dmClick + '><span class="members__dot" style="background:' + (isOnline ? "#22c55e" : "#666") + '"></span><span class="members__name">' + esc(name) + fpStr + '</span>' + (isYou ? '<span class="members__role">you</span>' : '') + '</div>';
716
770
  }).join("");
717
771
 
718
772
  list.innerHTML = html;
@@ -812,12 +866,17 @@ async function init() {
812
866
  }
813
867
  var total = Object.values(unreadCounts).reduce(function(a, b) { return a + b; }, 0);
814
868
  if (total > 0) document.title = "(" + total + ") AgentChannel";
815
- var nlabel = ch.sub ? "#" + ch.name + " ##" + ch.sub : "#" + ch.name;
869
+ var nlabel = ch.isDm ? "DM" : (ch.sub ? "#" + ch.name + " ##" + ch.sub : "#" + ch.name);
816
870
  if (Notification.permission === "granted" && (document.hidden || activeChannel !== chKeyName)) {
817
871
  var n = new Notification(nlabel + " @" + msg.sender, {body: msg.content});
818
872
  n.onclick = function() {
819
873
  window.focus();
820
- if (ch.sub) { window.switchToSub(ch.sub); }
874
+ if (ch.isDm) {
875
+ activeChannel = ch.name;
876
+ unreadCounts[ch.name] = 0;
877
+ renderSidebar();
878
+ render();
879
+ } else if (ch.sub) { window.switchToSub(ch.sub); }
821
880
  else { window.switchToChannel(ch.name); }
822
881
  };
823
882
  }
@@ -859,6 +918,81 @@ async function init() {
859
918
  renderMembers();
860
919
  };
861
920
 
921
+ window.openDm = async function(theirFp, theirName) {
922
+ if (!CONFIG.fingerprint || theirFp === CONFIG.fingerprint) return;
923
+ // Derive DM key and hash
924
+ if (!dmChannels[theirFp]) {
925
+ var dmKey = await deriveDmKeyWeb(CONFIG.fingerprint, theirFp);
926
+ var dmHash = await hashDmWeb(CONFIG.fingerprint, theirFp);
927
+ var sorted = [CONFIG.fingerprint, theirFp].sort();
928
+ var dmCid = "dm:" + sorted[0] + ":" + sorted[1];
929
+ dmChannels[theirFp] = {key: dmKey, hash: dmHash, channelId: dmCid, theirFp: theirFp};
930
+ // Register in channels map for MQTT handling
931
+ channels[dmCid] = {key: dmKey, hash: dmHash, name: dmCid, isDm: true, theirFp: theirFp};
932
+ // Subscribe to DM topic
933
+ client.subscribe("ac/1/" + dmHash);
934
+ // Load DM history from cloud
935
+ try {
936
+ var dres = await fetch("https://api.agentchannel.workers.dev/messages?channel_hash=" + dmHash + "&since=0&limit=30");
937
+ var drows = await dres.json();
938
+ for (var dri = 0; dri < drows.length; dri++) {
939
+ try {
940
+ var dmsg = JSON.parse(await decryptPayload(drows[dri].ciphertext, dmKey));
941
+ dmsg.channel = dmCid;
942
+ if (dmsg.type !== "channel_meta") {
943
+ // Avoid duplicates
944
+ var isDup = allMessages.some(function(m) { return m.id === dmsg.id; });
945
+ if (!isDup) allMessages.push(dmsg);
946
+ }
947
+ } catch(e) {}
948
+ }
949
+ allMessages.sort(function(a, b) { return a.timestamp - b.timestamp; });
950
+ } catch(e) {}
951
+ }
952
+ if (theirName) dmNames[theirFp] = theirName;
953
+ var dmCid = dmChannels[theirFp].channelId;
954
+ activeChannel = dmCid;
955
+ unreadCounts[dmCid] = 0;
956
+ headerName.textContent = "@" + (theirName || theirFp.slice(0, 8));
957
+ headerDesc.textContent = "DM with " + theirFp.slice(0, 8);
958
+ document.title = "AgentChannel — DM";
959
+ renderSidebar();
960
+ render();
961
+ renderMembers();
962
+ };
963
+
964
+ // DM send via web UI — encrypt and publish to DM topic
965
+ window.sendDmMessage = async function(theirFp, content) {
966
+ if (!dmChannels[theirFp]) return;
967
+ var dm = dmChannels[theirFp];
968
+ var iv = crypto.getRandomValues(new Uint8Array(12));
969
+ var plaintext = JSON.stringify({
970
+ id: Array.from(crypto.getRandomValues(new Uint8Array(8))).map(function(b){return b.toString(16).padStart(2,"0")}).join(""),
971
+ channel: dm.channelId,
972
+ sender: CONFIG.name,
973
+ content: content,
974
+ timestamp: Date.now(),
975
+ type: "chat",
976
+ senderKey: CONFIG.fingerprint
977
+ });
978
+ var encoded = encoder.encode(plaintext);
979
+ var encrypted = await crypto.subtle.encrypt({name:"AES-GCM", iv:iv}, dm.key, encoded);
980
+ var cipherData = new Uint8Array(encrypted.slice(0, encrypted.byteLength - 16));
981
+ var tag = new Uint8Array(encrypted.slice(encrypted.byteLength - 16));
982
+ var payload = JSON.stringify({
983
+ iv: btoa(String.fromCharCode.apply(null, iv)),
984
+ data: btoa(String.fromCharCode.apply(null, cipherData)),
985
+ tag: btoa(String.fromCharCode.apply(null, tag))
986
+ });
987
+ client.publish("ac/1/" + dm.hash, payload, {qos: 1});
988
+ // Also store to cloud
989
+ fetch("https://api.agentchannel.workers.dev/messages", {
990
+ method: "POST",
991
+ headers: {"Content-Type": "application/json"},
992
+ body: JSON.stringify({channel_hash: dm.hash, id: JSON.parse(plaintext).id, ciphertext: payload, timestamp: Date.now()})
993
+ }).catch(function(){});
994
+ };
995
+
862
996
  renderMembers();
863
997
  loadCloudMembers();
864
998
  }
@@ -895,7 +1029,7 @@ function openSettings() {
895
1029
  var overlay = document.createElement('div');
896
1030
  overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:9999;display:flex;align-items:center;justify-content:center';
897
1031
  overlay.onclick = function(e) { if (e.target === overlay) document.body.removeChild(overlay); };
898
- overlay.innerHTML = '<div style="background:var(--bg);border:1px solid var(--border);border-radius:12px;padding:24px;width:360px;max-width:90%">' +
1032
+ overlay.innerHTML = '<div style="background:var(--bg);border:1px solid var(--border);border-radius:12px;padding:24px;width:360px;max-width:90%;box-shadow:0 8px 32px rgba(0,0,0,0.5)">' +
899
1033
  '<h3 style="margin-bottom:16px;font-size:1rem;color:var(--text)">Settings</h3>' +
900
1034
  '<label style="font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:4px">Display Name</label>' +
901
1035
  '<input id="settings-name" value="' + (CONFIG.name || '') + '" style="width:100%;padding:8px 12px;border:1px solid var(--border);border-radius:6px;font-size:0.85rem;background:var(--bg-alt);color:var(--text);margin-bottom:16px;outline:none">' +
@@ -906,9 +1040,14 @@ function openSettings() {
906
1040
  '</div>' +
907
1041
  '<label style="font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:4px">Version</label>' +
908
1042
  '<div style="font-size:0.8rem;color:var(--text-muted);margin-bottom:20px">v' + (CONFIG.version || '?') + '</div>' +
1043
+ '<div style="border-top:1px solid var(--border);padding-top:16px;margin-bottom:16px;font-size:0.7rem;color:var(--text-muted);line-height:1.6">' +
1044
+ '<div style="margin-bottom:4px">&#128274; Messages are end-to-end encrypted (AES-256-GCM)</div>' +
1045
+ '<div style="margin-bottom:4px">&#128273; Your private key never leaves this device</div>' +
1046
+ '<div>&#128206; Fingerprint = your public identity (safe to share)</div>' +
1047
+ '</div>' +
909
1048
  '<div style="display:flex;gap:8px;justify-content:flex-end">' +
910
1049
  '<button onclick="this.closest(\'div[style*=fixed]\').remove()" style="padding:8px 16px;border:1px solid var(--border);border-radius:6px;background:var(--bg-alt);color:var(--text);cursor:pointer;font-size:0.8rem">Cancel</button>' +
911
- '<button onclick="saveName()" style="padding:8px 16px;border:none;border-radius:6px;background:#00e676;color:#000;cursor:pointer;font-size:0.8rem;font-weight:600">Save</button>' +
1050
+ '<button onclick="saveName()" style="padding:8px 16px;border:none;border-radius:6px;background:#1a1a1a;color:#fff;cursor:pointer;font-size:0.8rem;font-weight:600">Save</button>' +
912
1051
  '</div></div>';
913
1052
  document.body.appendChild(overlay);
914
1053
  }
@@ -938,6 +1077,62 @@ function saveName() {
938
1077
  }
939
1078
  window.saveName = saveName;
940
1079
 
1080
+ // ── Input: send message + @autocomplete ──────────────────
1081
+
1082
+ function sendMsg() {
1083
+ var input = document.getElementById('msg-input');
1084
+ if (!input || !input.value.trim() || activeChannel === '@me') return;
1085
+ fetch('/api/send', {
1086
+ method: 'POST',
1087
+ headers: { 'Content-Type': 'application/json' },
1088
+ body: JSON.stringify({ channel: activeChannel, message: input.value.trim() })
1089
+ }).then(function() { input.value = ''; }).catch(function(e) { console.error('Send failed:', e); });
1090
+ }
1091
+ window.sendMsg = sendMsg;
1092
+
1093
+ var acSelected = 0;
1094
+ function onInputChange(input) {
1095
+ var val = input.value;
1096
+ var atIdx = val.lastIndexOf('@');
1097
+ var ac = document.getElementById('autocomplete');
1098
+ if (atIdx === -1 || atIdx < val.length - 20) { ac.style.display = 'none'; return; }
1099
+ var query = val.slice(atIdx + 1).toLowerCase();
1100
+ if (query.indexOf(' ') !== -1) { ac.style.display = 'none'; return; }
1101
+ var members = [];
1102
+ var cm = window.cloudMembers || {};
1103
+ for (var k in cm) { for (var i = 0; i < cm[k].length; i++) { var m = cm[k][i]; if (members.indexOf(m.name) === -1) members.push(m.name); } }
1104
+ var filtered = members.filter(function(n) { return n.toLowerCase().indexOf(query) === 0 && n !== CONFIG.name; });
1105
+ if (!filtered.length) { ac.style.display = 'none'; return; }
1106
+ acSelected = 0;
1107
+ ac.style.display = 'block';
1108
+ ac.innerHTML = filtered.slice(0, 6).map(function(n, i) {
1109
+ return '<div style="padding:6px 10px;cursor:pointer;border-radius:4px;font-size:0.8rem;color:var(--text)' + (i === 0 ? ';background:var(--bg-alt)' : '') + '" onmousedown="insertMention(\'' + n + '\')">' + n + '</div>';
1110
+ }).join('');
1111
+ }
1112
+ window.onInputChange = onInputChange;
1113
+
1114
+ function insertMention(name) {
1115
+ var input = document.getElementById('msg-input');
1116
+ var val = input.value;
1117
+ input.value = val.slice(0, val.lastIndexOf('@')) + '@' + name + ' ';
1118
+ input.focus();
1119
+ document.getElementById('autocomplete').style.display = 'none';
1120
+ }
1121
+ window.insertMention = insertMention;
1122
+
1123
+ function onInputKey(e) {
1124
+ if (e.key === 'Enter' && document.getElementById('autocomplete').style.display === 'none') { sendMsg(); return; }
1125
+ var ac = document.getElementById('autocomplete');
1126
+ if (ac.style.display === 'none') return;
1127
+ var items = ac.children;
1128
+ if (e.key === 'ArrowDown') { acSelected = Math.min(acSelected + 1, items.length - 1); e.preventDefault(); }
1129
+ if (e.key === 'ArrowUp') { acSelected = Math.max(acSelected - 1, 0); e.preventDefault(); }
1130
+ if (e.key === 'Tab' || e.key === 'Enter') { if (items[acSelected]) { insertMention(items[acSelected].textContent); e.preventDefault(); } }
1131
+ if (e.key === 'Escape') { ac.style.display = 'none'; }
1132
+ for (var i = 0; i < items.length; i++) items[i].style.background = i === acSelected ? 'var(--bg-alt)' : '';
1133
+ }
1134
+ window.onInputKey = onInputKey;
1135
+
941
1136
  // Restore saved theme (default: dark)
942
1137
  var savedTheme = localStorage.getItem('ac-theme') || 'dark';
943
1138
  document.documentElement.classList.add(savedTheme);
package/ui/index.html CHANGED
@@ -32,6 +32,13 @@
32
32
  <div class="empty">Waiting for messages...</div>
33
33
  </div>
34
34
  </div>
35
+ <div style="position:relative">
36
+ <div id="autocomplete" style="display:none;position:absolute;bottom:100%;left:24px;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:4px;max-height:150px;overflow-y:auto;box-shadow:0 -4px 12px rgba(0,0,0,0.2);z-index:100"></div>
37
+ <div style="padding:8px 24px;border-top:1px solid var(--border);display:flex;gap:8px">
38
+ <input type="text" id="msg-input" placeholder="Type a message... (@name to mention)" style="flex:1;padding:8px 12px;border:1px solid var(--border);border-radius:8px;font-size:0.85rem;background:var(--bg);color:var(--text);outline:none" oninput="onInputChange(this)" onkeydown="onInputKey(event)">
39
+ <button onclick="sendMsg()" style="padding:8px 16px;border:none;border-radius:8px;background:#1a1a1a;color:#fff;font-size:0.85rem;cursor:pointer;font-weight:600">Send</button>
40
+ </div>
41
+ </div>
35
42
  </div>
36
43
  <div class="members" id="members-panel">
37
44
  <div class="members__header">Members</div>