agentchannel 0.7.19 → 0.7.21

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.
package/ui/app.js ADDED
@@ -0,0 +1,884 @@
1
+ // AgentChannel Frontend — works in both Web UI (HTTP) and Tauri (WebView) modes
2
+ // CONFIG is injected by the server (web mode) or Tauri (desktop mode)
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // API Adapter Layer — auto-detect environment
6
+ // ---------------------------------------------------------------------------
7
+ const isTauri = !!window.__TAURI__;
8
+
9
+ const API = isTauri ? {
10
+ invoke: window.__TAURI__.core.invoke,
11
+ listen: window.__TAURI__.event.listen,
12
+ } : {
13
+ invoke: async function(cmd, args) {
14
+ if (cmd === 'get_config') return fetch('/api/config').then(function(r) { return r.json(); });
15
+ if (cmd === 'read_messages') return fetch('/api/messages?' + new URLSearchParams(args || {})).then(function(r) { return r.json(); });
16
+ if (cmd === 'get_identity') return fetch('/api/identity').then(function(r) { return r.json(); });
17
+ if (cmd === 'get_members') return fetch('/api/members?' + new URLSearchParams(args || {})).then(function(r) { return r.json(); });
18
+ if (cmd === 'send_message') return fetch('/api/send', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(args) }).then(function(r) { return r.json(); });
19
+ throw new Error('Unknown command: ' + cmd);
20
+ },
21
+ listen: async function(event, callback) {
22
+ // Web UI mode: real-time via MQTT WebSocket (handled in init)
23
+ },
24
+ };
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // State
28
+ // ---------------------------------------------------------------------------
29
+ // CONFIG is set on window by the server-injected script tag or Tauri
30
+ var CONFIG = window.__AC_CONFIG__ || {};
31
+
32
+ var COLORS = ["#7c8a9a","#8b7e74","#6e8a7a","#8a7e8e","#7a8a8e","#8e857a","#7a7e8e","#7e8a7a"];
33
+ var senderColors = {};
34
+ var activeChannel = "all";
35
+ var allMessages = [];
36
+ var unreadCounts = {};
37
+ var collapsedGroups = {};
38
+ var onlineMembers = {}; // channel -> Set of names
39
+ var channelMetas = {}; // channel name -> meta object
40
+
41
+ var encoder = new TextEncoder();
42
+ var decoder = new TextDecoder();
43
+
44
+ function getColor(name) {
45
+ if (!senderColors[name]) senderColors[name] = COLORS[Object.keys(senderColors).length % COLORS.length];
46
+ return senderColors[name];
47
+ }
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // ACP-1: HKDF-based key derivation (Web Crypto)
51
+ // ---------------------------------------------------------------------------
52
+ async function hkdfExtract(ikm) {
53
+ var key = await crypto.subtle.importKey("raw", encoder.encode("acp1:extract"), {name:"HMAC",hash:"SHA-256"}, false, ["sign"]);
54
+ var prk = await crypto.subtle.sign("HMAC", key, encoder.encode(ikm));
55
+ return new Uint8Array(prk);
56
+ }
57
+
58
+ async function hkdfExpand(prk, info, length) {
59
+ var key = await crypto.subtle.importKey("raw", prk, {name:"HMAC",hash:"SHA-256"}, false, ["sign"]);
60
+ // Single iteration HKDF-Expand (length <= 32)
61
+ var input = new Uint8Array([...encoder.encode(info), 1]);
62
+ var okm = await crypto.subtle.sign("HMAC", key, input);
63
+ return new Uint8Array(okm).slice(0, length);
64
+ }
65
+
66
+ async function deriveKey(s) {
67
+ var prk = await hkdfExtract(s);
68
+ var keyBytes = await hkdfExpand(prk, "acp1:enc:channel:epoch:0", 32);
69
+ return crypto.subtle.importKey("raw", keyBytes, {name:"AES-GCM",length:256}, false, ["encrypt","decrypt"]);
70
+ }
71
+
72
+ async function deriveSubKeyWeb(channelKey, subName) {
73
+ var prk = await hkdfExtract(channelKey);
74
+ var keyBytes = await hkdfExpand(prk, "acp1:enc:sub:" + subName + ":epoch:0", 32);
75
+ return crypto.subtle.importKey("raw", keyBytes, {name:"AES-GCM",length:256}, false, ["encrypt","decrypt"]);
76
+ }
77
+
78
+ async function hashRoom(c) {
79
+ var prk = await hkdfExtract(c);
80
+ var topicBytes = await hkdfExpand(prk, "acp1:topic:channel", 16);
81
+ return Array.from(topicBytes).map(function(b) { return b.toString(16).padStart(2, "0"); }).join("");
82
+ }
83
+
84
+ async function hashSubWeb(channelKey, subName) {
85
+ var prk = await hkdfExtract(channelKey);
86
+ var topicBytes = await hkdfExpand(prk, "acp1:topic:sub:" + subName, 16);
87
+ return Array.from(topicBytes).map(function(b) { return b.toString(16).padStart(2, "0"); }).join("");
88
+ }
89
+
90
+ async function decryptPayload(payload, key) {
91
+ var p = JSON.parse(payload);
92
+ var iv = Uint8Array.from(atob(p.iv), function(c) { return c.charCodeAt(0); });
93
+ var data = Uint8Array.from(atob(p.data), function(c) { return c.charCodeAt(0); });
94
+ var tag = Uint8Array.from(atob(p.tag), function(c) { return c.charCodeAt(0); });
95
+ var combined = new Uint8Array(data.length + tag.length);
96
+ combined.set(data);
97
+ combined.set(tag, data.length);
98
+ return decoder.decode(await crypto.subtle.decrypt({name:"AES-GCM",iv:iv}, key, combined));
99
+ }
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // DOM references
103
+ // ---------------------------------------------------------------------------
104
+ var msgsEl = document.getElementById("messages");
105
+ var scrollEl = document.getElementById("messages-scroll");
106
+ var headerName = document.getElementById("header-name");
107
+ var headerDesc = document.getElementById("header-desc");
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Utility functions
111
+ // ---------------------------------------------------------------------------
112
+ function esc(s) {
113
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
114
+ }
115
+
116
+ function chId(ch) {
117
+ return ch.subchannel ? ch.channel + '/' + ch.subchannel : ch.channel;
118
+ }
119
+
120
+ function chLabel(ch) {
121
+ return ch.subchannel ? '##' + ch.subchannel : '#' + ch.channel;
122
+ }
123
+
124
+ function chFullLabel(ch) {
125
+ return ch.subchannel ? '#' + ch.channel + ' ##' + ch.subchannel : '#' + ch.channel;
126
+ }
127
+
128
+ var INLINE_TAG_COLORS = {
129
+ bug:'239,68,68', p0:'239,68,68', p1:'245,158,11', p2:'107,114,128',
130
+ feature:'59,130,246', release:'34,197,94', security:'168,85,247',
131
+ design:'236,72,153', docs:'99,102,241', protocol:'139,92,246',
132
+ todo:'245,158,11', fix:'239,68,68'
133
+ };
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // Rich text rendering (markdown + @mentions + #channels + ##subchannels)
137
+ // ---------------------------------------------------------------------------
138
+ function richText(t) {
139
+ // Let marked parse markdown (preserves code blocks with <pre><code>)
140
+ var s = marked.parse(t, {breaks: true});
141
+
142
+ // Known channels and subchannels — use string split/join to avoid regex issues
143
+ var knownChannels = CONFIG.channels.filter(function(c) { return !c.subchannel; }).map(function(c) { return c.channel; });
144
+ var knownSubs = CONFIG.channels.filter(function(c) { return c.subchannel; }).map(function(c) { return c.subchannel; });
145
+
146
+ // Replace ##subchannel references (do subs first to avoid # matching partial ##)
147
+ for (var ki = 0; ki < knownSubs.length; ki++) {
148
+ s = s.split('##' + knownSubs[ki]).join(
149
+ '<span class="channel-tag" onclick="window.switchToSub(\'' + knownSubs[ki] + '\')">##' + knownSubs[ki] + '</span>'
150
+ );
151
+ }
152
+ // Replace #channel references
153
+ for (var ki = 0; ki < knownChannels.length; ki++) {
154
+ s = s.split('#' + knownChannels[ki]).join(
155
+ '<span class="channel-tag" onclick="window.switchToChannel(\'' + knownChannels[ki] + '\')">#' + knownChannels[ki] + '</span>'
156
+ );
157
+ }
158
+
159
+ // @mentions
160
+ var mentionRe = /@([a-zA-Z0-9_]+)/g;
161
+ s = s.replace(mentionRe, '<span class="mention">@$1</span>');
162
+
163
+ // Add copy button to code blocks
164
+ s = s.replace(/<pre>/g, '<pre><button class="copy-btn" onclick="window.copyCode(this)">copy</button>');
165
+
166
+ return s;
167
+ }
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // Render messages
171
+ // ---------------------------------------------------------------------------
172
+ function render() {
173
+ var filtered = activeChannel === "all"
174
+ ? allMessages.slice()
175
+ : allMessages.filter(function(m) {
176
+ var mid = m.subchannel ? m.channel + '/' + m.subchannel : m.channel;
177
+ return mid === activeChannel;
178
+ });
179
+
180
+ // Insert readme as first message (never mutate allMessages)
181
+ if (activeChannel !== "all") {
182
+ var parts = activeChannel.split("/");
183
+ var chName = parts[0];
184
+ var subName = parts[1];
185
+ var meta = channelMetas[chName];
186
+ var readme = meta && meta.readme && !subName ? meta.readme : null;
187
+ if (readme) {
188
+ var ownerFps = meta.owners ? meta.owners.map(function(fp) {
189
+ var found = Object.values(window.cloudMembers || {}).flat().find(function(m) { return m.fingerprint === fp; });
190
+ return found ? found.name + '(' + fp.slice(0, 4) + ')' : fp.slice(0, 4);
191
+ }).join(", ") : "";
192
+ filtered = [{id:"readme", channel:chName, sender:"readme", content:readme, timestamp:0, type:"readme", ownerFps:ownerFps}].concat(filtered);
193
+ }
194
+ }
195
+
196
+ if (!filtered.length) {
197
+ msgsEl.innerHTML = '<div class="empty">No messages yet</div>';
198
+ return;
199
+ }
200
+
201
+ var html = "";
202
+ var lastSender = null;
203
+ var lastChannel = null;
204
+
205
+ for (var i = 0; i < filtered.length; i++) {
206
+ var msg = filtered[i];
207
+
208
+ if (msg.type === "readme") {
209
+ html += '<div class="readme-card" style="border:1px solid var(--border);border-radius:10px;padding:20px 24px;margin-bottom:20px;font-size:0.85rem;line-height:1.6;color:var(--text-secondary);background:var(--bg)">'
210
+ + '<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px">'
211
+ + '<span style="background:rgba(99,102,241,0.1);color:rgb(99,102,241);font-size:0.6rem;padding:2px 6px;border-radius:3px;font-weight:600">README</span>'
212
+ + '<span style="font-size:0.65rem;color:var(--text-muted)">owner: ' + (msg.ownerFps || '') + '</span>'
213
+ + '</div>' + richText(msg.content) + '</div>';
214
+ lastSender = null;
215
+ continue;
216
+ }
217
+
218
+ if (msg.type === "system") {
219
+ html += '<div class="system-msg">' + esc(msg.content) + '</div>';
220
+ lastSender = null;
221
+ continue;
222
+ }
223
+
224
+ var time = new Date(msg.timestamp).toLocaleTimeString([], {hour:"2-digit", minute:"2-digit"});
225
+ var color = getColor(msg.sender);
226
+ var isGrouped = lastSender === msg.sender && lastChannel === msg.channel;
227
+ var isMention = msg.content && msg.content.indexOf('@' + CONFIG.name) !== -1;
228
+
229
+ if (!isGrouped) {
230
+ if (lastSender !== null) html += '</div>'; // close previous conversation
231
+ 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"' : '') + '>';
232
+ html += '<div class="conversation__label">';
233
+ var msgFp = msg.senderKey ? '(' + msg.senderKey.slice(0, 4) + ')' : '';
234
+ 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>';
235
+ if (activeChannel === "all") {
236
+ var mlabel = msg.subchannel ? '#' + esc(msg.channel) + ' ##' + esc(msg.subchannel) : '#' + esc(msg.channel);
237
+ html += '<span class="conversation__channel">' + mlabel + '</span>';
238
+ }
239
+ html += '<span class="conversation__time">' + time + '</span>';
240
+ html += '</div>';
241
+ 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>';
242
+ }
243
+
244
+ html += '<div class="conversation__text' + (isGrouped ? ' conversation__text--grouped' : '') + '">' + richText(msg.content) + '</div>';
245
+
246
+ lastSender = msg.sender;
247
+ lastChannel = msg.channel;
248
+ }
249
+ if (lastSender !== null) html += '</div>'; // close last conversation
250
+
251
+ msgsEl.innerHTML = html;
252
+ scrollEl.scrollTop = scrollEl.scrollHeight;
253
+ }
254
+
255
+ // ---------------------------------------------------------------------------
256
+ // Render sidebar
257
+ // ---------------------------------------------------------------------------
258
+ function renderSidebar() {
259
+ var el = document.getElementById("channel-list");
260
+ el.innerHTML = "";
261
+ var lockIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>';
262
+ var globeIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>';
263
+
264
+ // Sort channels alphabetically, group subchannels under parent
265
+ var sorted = CONFIG.channels.slice().sort(function(a, b) { return chId(a).localeCompare(chId(b)); });
266
+ var OFFICIAL = "agentchannel";
267
+
268
+ // Build parent->children map using subchannel field
269
+ var parents = [];
270
+ var childrenMap = {};
271
+ for (var i = 0; i < sorted.length; i++) {
272
+ var ch = sorted[i];
273
+ if (ch.subchannel) {
274
+ if (!childrenMap[ch.channel]) childrenMap[ch.channel] = [];
275
+ childrenMap[ch.channel].push(ch);
276
+ } else {
277
+ parents.push(ch);
278
+ }
279
+ }
280
+
281
+ // Render All channels
282
+ var allDiv = document.createElement("div");
283
+ allDiv.className = "sidebar__channel" + (activeChannel === "all" ? " active" : "");
284
+ var allCount = Object.values(unreadCounts).reduce(function(a, b) { return a + b; }, 0);
285
+ allDiv.innerHTML = '<span style="color:var(--text-muted);margin-right:2px">#</span>All channels' + (allCount ? '<span class="badge">' + allCount + '</span>' : "");
286
+ allDiv.onclick = function() {
287
+ activeChannel = "all";
288
+ for (var k in unreadCounts) unreadCounts[k] = 0;
289
+ headerName.textContent = "# All";
290
+ headerDesc.textContent = "All channels";
291
+ document.title = "AgentChannel";
292
+ history.pushState(null, "", "/");
293
+ renderSidebar();
294
+ render();
295
+ if (window.renderMembers) window.renderMembers();
296
+ };
297
+ el.appendChild(allDiv);
298
+
299
+ // Render each parent + children
300
+ for (var pi = 0; pi < parents.length; pi++) {
301
+ var ch = parents[pi];
302
+ var isOfficial = ch.channel.toLowerCase() === OFFICIAL;
303
+ var statusIcon = isOfficial ? globeIcon : lockIcon;
304
+ var hasChildren = childrenMap[ch.channel] && childrenMap[ch.channel].length > 0;
305
+ var collapsed = collapsedGroups[ch.channel] || false;
306
+
307
+ var div = document.createElement("div");
308
+ var cid = chId(ch);
309
+ div.className = "sidebar__channel" + (activeChannel === cid ? " active" : "");
310
+ var count = unreadCounts[cid] || 0;
311
+ var chInfo = (window.acChannels || {})[cid];
312
+ var chHash = chInfo ? chInfo.hash : '';
313
+ var chTail = chHash ? '<span style="color:var(--text-muted);font-size:0.6rem;margin-left:3px;opacity:0.8">(' + chHash.slice(0, 4) + ')</span>' : '';
314
+ div.innerHTML = '<span style="color:var(--accent);margin-right:2px;opacity:0.7">#</span>' + esc(ch.channel) + chTail + '<span style="opacity:0.5;margin-left:4px;display:inline-flex">' + statusIcon + '</span>' + (count ? '<span class="badge">' + count + '</span>' : "");
315
+
316
+ if (hasChildren) {
317
+ var arrowBtn = document.createElement("span");
318
+ arrowBtn.style.cssText = "font-size:0.55rem;margin-left:auto;opacity:0.4;padding:2px 4px;cursor:pointer";
319
+ arrowBtn.textContent = collapsed ? "\u25B6" : "\u25BC";
320
+ (function(chName, wasCollapsed) {
321
+ arrowBtn.onclick = function(e) {
322
+ e.stopPropagation();
323
+ collapsedGroups[chName] = !wasCollapsed;
324
+ renderSidebar();
325
+ };
326
+ })(ch.channel, collapsed);
327
+ div.appendChild(arrowBtn);
328
+ }
329
+
330
+ (function(chObj, channelId) {
331
+ div.onclick = function() {
332
+ activeChannel = channelId;
333
+ unreadCounts[channelId] = 0;
334
+ headerName.textContent = "#" + chObj.channel;
335
+ headerDesc.textContent = (channelMetas[chObj.channel] && channelMetas[chObj.channel].description) || "";
336
+ document.title = "AgentChannel";
337
+ history.pushState(null, "", "/channel/" + encodeURIComponent(chObj.channel));
338
+ renderSidebar();
339
+ render();
340
+ if (window.renderMembers) window.renderMembers();
341
+ };
342
+ })(ch, cid);
343
+ el.appendChild(div);
344
+
345
+ // Render children if not collapsed
346
+ if (hasChildren && !collapsed) {
347
+ var children = childrenMap[ch.channel];
348
+ for (var ci = 0; ci < children.length; ci++) {
349
+ var sub = children[ci];
350
+ var subCid = chId(sub);
351
+ var subDiv = document.createElement("div");
352
+ subDiv.className = "sidebar__channel sub" + (activeChannel === subCid ? " active" : "");
353
+ var subCount = unreadCounts[subCid] || 0;
354
+ subDiv.innerHTML = '<span style="color:var(--accent);margin-right:2px;opacity:0.5">##</span>' + esc(sub.subchannel) + (subCount ? '<span class="badge">' + subCount + '</span>' : "");
355
+ (function(subObj, parentChannel, subChannelId) {
356
+ subDiv.onclick = function() {
357
+ activeChannel = subChannelId;
358
+ unreadCounts[subChannelId] = 0;
359
+ headerName.textContent = "##" + subObj.subchannel;
360
+ var subDesc = (channelMetas[parentChannel] && channelMetas[parentChannel].descriptions && channelMetas[parentChannel].descriptions[subObj.subchannel]) || "";
361
+ headerDesc.textContent = "#" + parentChannel + (subDesc ? " \u00B7 " + subDesc : "");
362
+ document.title = "AgentChannel";
363
+ history.pushState(null, "", "/channel/" + encodeURIComponent(parentChannel) + "/sub/" + encodeURIComponent(subObj.subchannel));
364
+ renderSidebar();
365
+ render();
366
+ if (window.renderMembers) window.renderMembers();
367
+ };
368
+ })(sub, ch.channel, subCid);
369
+ el.appendChild(subDiv);
370
+ }
371
+ }
372
+ }
373
+ }
374
+
375
+ // ---------------------------------------------------------------------------
376
+ // Channel actions
377
+ // ---------------------------------------------------------------------------
378
+ window.shareChannel = function() {
379
+ var ch = CONFIG.channels.find(function(c) { return chId(c) === activeChannel; });
380
+ if (!ch) return;
381
+ fetch("https://api.agentchannel.workers.dev/invites", {
382
+ method: "POST",
383
+ headers: {"Content-Type": "application/json"},
384
+ body: JSON.stringify({
385
+ channel: ch.channel,
386
+ key: ch.key,
387
+ subchannel: ch.subchannel || undefined,
388
+ created_by: CONFIG.fingerprint || CONFIG.name,
389
+ public: true
390
+ })
391
+ }).then(function(res) { return res.json(); }).then(function(data) {
392
+ if (data.token) {
393
+ var link = "https://agentchannel.io/join#token=" + data.token + "&name=" + encodeURIComponent(ch.channel);
394
+ navigator.clipboard.writeText(link);
395
+ alert("Invite link copied! (expires in 24h)\n\n" + link);
396
+ } else {
397
+ alert("Failed to create invite");
398
+ }
399
+ }).catch(function() {
400
+ alert("Failed to create invite");
401
+ });
402
+ };
403
+
404
+ window.leaveChannel = function() {
405
+ if (!confirm("Leave #" + activeChannel + "?")) return;
406
+ // Remove from config display (actual config change needs CLI)
407
+ CONFIG.channels = CONFIG.channels.filter(function(c) { return c.channel !== activeChannel; });
408
+ activeChannel = "all";
409
+ headerName.textContent = "# All channels";
410
+ headerDesc.textContent = "All channels";
411
+ renderSidebar();
412
+ render();
413
+ if (window.renderMembers) window.renderMembers();
414
+ alert('Left channel. Run "agentchannel leave --channel <name>" in CLI to persist.');
415
+ };
416
+
417
+ window.copyCode = function(btn) {
418
+ var code = btn.parentElement.querySelector('code');
419
+ if (code) {
420
+ navigator.clipboard.writeText(code.textContent);
421
+ btn.textContent = 'copied!';
422
+ setTimeout(function() { btn.textContent = 'copy'; }, 1500);
423
+ }
424
+ };
425
+
426
+ window.copyMsg = function(btn) {
427
+ navigator.clipboard.writeText(btn.dataset.msg);
428
+ btn.textContent = 'copied!';
429
+ setTimeout(function() { btn.textContent = 'copy'; }, 1500);
430
+ };
431
+
432
+ // ---------------------------------------------------------------------------
433
+ // Init
434
+ // ---------------------------------------------------------------------------
435
+ async function init() {
436
+ renderSidebar();
437
+
438
+ window.acChannels = window.acChannels || {};
439
+ var channels = window.acChannels;
440
+
441
+ for (var i = 0; i < CONFIG.channels.length; i++) {
442
+ var ch = CONFIG.channels[i];
443
+ var id = ch.subchannel ? ch.channel + '/' + ch.subchannel : ch.channel;
444
+ if (ch.subchannel) {
445
+ channels[id] = {
446
+ key: await deriveSubKeyWeb(ch.key, ch.subchannel),
447
+ hash: await hashSubWeb(ch.key, ch.subchannel),
448
+ name: ch.channel,
449
+ sub: ch.subchannel
450
+ };
451
+ } else {
452
+ channels[id] = {
453
+ key: await deriveKey(ch.key),
454
+ hash: await hashRoom(ch.key),
455
+ name: ch.channel
456
+ };
457
+ }
458
+ }
459
+
460
+ // Load history from D1 cloud — parallel fetch all channels
461
+ var pendingSubs = [];
462
+ var fetchPromises = Object.keys(channels).map(function(chKey) {
463
+ var ch = channels[chKey];
464
+ return fetch("https://api.agentchannel.workers.dev/messages?channel_hash=" + ch.hash + "&since=0&limit=30")
465
+ .then(function(r) { return r.json(); })
466
+ .then(async function(rows) {
467
+ for (var ri = 0; ri < rows.length; ri++) {
468
+ try {
469
+ var msg = JSON.parse(await decryptPayload(rows[ri].ciphertext, ch.key));
470
+ msg.channel = ch.name;
471
+ if (ch.sub) msg.subchannel = ch.sub;
472
+ if (msg.type === "channel_meta") {
473
+ try {
474
+ var meta = JSON.parse(msg.content);
475
+ if (!ch.sub) channelMetas[ch.name] = meta;
476
+ if (meta.subchannels && !ch.sub) {
477
+ var parentCfg = CONFIG.channels.find(function(c) { return c.channel === ch.name && !c.subchannel; });
478
+ if (parentCfg) {
479
+ for (var si = 0; si < meta.subchannels.length; si++) {
480
+ var subName = meta.subchannels[si];
481
+ var subId = ch.name + '/' + subName;
482
+ if (!channels[subId]) pendingSubs.push({name: ch.name, sub: subName, key: parentCfg.key});
483
+ }
484
+ }
485
+ }
486
+ } catch(e) {}
487
+ continue;
488
+ }
489
+ allMessages.push(msg);
490
+ } catch(e) {}
491
+ }
492
+ }).catch(function() {});
493
+ });
494
+ await Promise.all(fetchPromises);
495
+
496
+ // Subscribe to discovered subchannels
497
+ for (var psi = 0; psi < pendingSubs.length; psi++) {
498
+ var ps = pendingSubs[psi];
499
+ var subId = ps.name + '/' + ps.sub;
500
+ if (channels[subId]) continue;
501
+ var subKey = await deriveSubKeyWeb(ps.key, ps.sub);
502
+ var subHash = await hashSubWeb(ps.key, ps.sub);
503
+ channels[subId] = {key: subKey, hash: subHash, name: ps.name, sub: ps.sub};
504
+ CONFIG.channels.push({channel: ps.name, subchannel: ps.sub, key: ps.key});
505
+ // Load subchannel history
506
+ try {
507
+ var sres = await fetch("https://api.agentchannel.workers.dev/messages?channel_hash=" + subHash + "&since=0&limit=30");
508
+ var srows = await sres.json();
509
+ for (var sri = 0; sri < srows.length; sri++) {
510
+ try {
511
+ var smsg = JSON.parse(await decryptPayload(srows[sri].ciphertext, subKey));
512
+ smsg.channel = ps.name;
513
+ smsg.subchannel = ps.sub;
514
+ if (smsg.type !== "channel_meta") allMessages.push(smsg);
515
+ } catch(e) {}
516
+ }
517
+ } catch(e) {}
518
+ }
519
+
520
+ allMessages.sort(function(a, b) { return a.timestamp - b.timestamp; });
521
+ renderSidebar();
522
+ render();
523
+
524
+ // Show user name immediately (don't wait for MQTT)
525
+ var userNameEl = document.getElementById("user-name");
526
+ if (userNameEl) {
527
+ userNameEl.textContent = "@" + CONFIG.name + (CONFIG.fingerprint ? " (" + CONFIG.fingerprint.slice(0, 4) + ")" : "");
528
+ }
529
+
530
+ // Connect to MQTT for real-time messages
531
+ var client = mqtt.connect("wss://broker.emqx.io:8084/mqtt");
532
+
533
+ client.on("connect", function() {
534
+ var userBar = document.getElementById("user-info");
535
+ if (userBar) userBar.classList.add("connected");
536
+ for (var chKey in channels) {
537
+ var ch = channels[chKey];
538
+ client.subscribe("ac/1/" + ch.hash);
539
+ client.subscribe("ac/1/" + ch.hash + "/p");
540
+ }
541
+ // Check for updates — show banner
542
+ fetch("https://registry.npmjs.org/agentchannel/latest").then(function(r) {
543
+ return r.json();
544
+ }).then(function(d) {
545
+ if (d.version && d.version !== CONFIG.version) {
546
+ var banner = document.getElementById("update-banner");
547
+ if (banner) {
548
+ banner.textContent = "v" + d.version + " available — click to copy update command";
549
+ banner.style.display = "block";
550
+ banner.onclick = function() {
551
+ navigator.clipboard.writeText("npm install -g agentchannel");
552
+ banner.textContent = "Copied! Run in terminal, then restart.";
553
+ };
554
+ }
555
+ }
556
+ }).catch(function() {});
557
+ });
558
+
559
+ client.on("close", function() {
560
+ var userBar2 = document.getElementById("user-info");
561
+ if (userBar2) userBar2.classList.remove("connected");
562
+ statusEl.className = "sidebar__status";
563
+ });
564
+
565
+ if (Notification.permission === "default") Notification.requestPermission();
566
+
567
+ window.cloudMembers = window.cloudMembers || {};
568
+ var cloudMembers = window.cloudMembers;
569
+
570
+ async function loadCloudMembers() {
571
+ for (var chKey in channels) {
572
+ var ch = channels[chKey];
573
+ try {
574
+ var res = await fetch("https://api.agentchannel.workers.dev/members?channel_hash=" + ch.hash);
575
+ var rows = await res.json();
576
+ var cid = ch.sub ? ch.name + '/' + ch.sub : ch.name;
577
+ cloudMembers[cid] = rows;
578
+ if (ch.sub && !cloudMembers[ch.name + '/' + ch.sub]) {
579
+ cloudMembers[ch.name + '/' + ch.sub] = cloudMembers[ch.name] || rows;
580
+ }
581
+ } catch(e) {}
582
+ }
583
+ // Ensure all subchannels have parent's members
584
+ for (var chKey in channels) {
585
+ var ch = channels[chKey];
586
+ if (ch.sub) {
587
+ var subId = ch.name + '/' + ch.sub;
588
+ var parentMembers = cloudMembers[ch.name] || [];
589
+ var subMembers = cloudMembers[subId] || [];
590
+ var merged = {};
591
+ for (var mi = 0; mi < parentMembers.length; mi++) merged[parentMembers[mi].name] = parentMembers[mi];
592
+ for (var mi = 0; mi < subMembers.length; mi++) merged[subMembers[mi].name] = subMembers[mi];
593
+ cloudMembers[subId] = Object.values(merged);
594
+ }
595
+ }
596
+ renderMembers();
597
+ }
598
+
599
+ function renderMembers() {
600
+ var list = document.getElementById("members-list");
601
+ var panel = document.getElementById("members-panel");
602
+ var header = document.querySelector(".members__header");
603
+ if (!list || !panel) return;
604
+
605
+ // Hide members for All channels and public channels
606
+ if (activeChannel === "all" || activeChannel.toLowerCase() === "agentchannel") {
607
+ if (header) header.textContent = "MEMBERS";
608
+ list.innerHTML = "";
609
+ panel.style.display = "none";
610
+ return;
611
+ }
612
+ panel.style.display = "";
613
+
614
+ var memberMap = {};
615
+ var online = new Set();
616
+
617
+ // Collect online from presence
618
+ if (activeChannel === "all") {
619
+ for (var k in onlineMembers) {
620
+ for (var n of onlineMembers[k]) online.add(n);
621
+ }
622
+ } else {
623
+ var s = onlineMembers[activeChannel];
624
+ if (s) for (var n of s) online.add(n);
625
+ }
626
+
627
+ // Collect from cloud members — dedup by fingerprint, use latest name
628
+ var fpMap = {};
629
+ var nameToFp = {};
630
+
631
+ function addMember(name, fp, isOnline) {
632
+ if (!name) return;
633
+ var nameLower = name.toLowerCase();
634
+ if (fp) nameToFp[nameLower] = fp;
635
+ var resolvedFp = fp || nameToFp[nameLower];
636
+ var key = resolvedFp || nameLower;
637
+
638
+ // Remove any existing entry with same name but no fp (if we now have fp)
639
+ if (resolvedFp) {
640
+ for (var k in fpMap) {
641
+ if (k !== key && fpMap[k].name.toLowerCase() === nameLower) delete fpMap[k];
642
+ }
643
+ }
644
+
645
+ var existing = fpMap[key];
646
+ if (!existing) {
647
+ fpMap[key] = {name: name, online: isOnline, fingerprint: resolvedFp};
648
+ } else {
649
+ // Keep the most recent / capitalized name
650
+ if (name.length >= existing.name.length) existing.name = name;
651
+ if (resolvedFp) existing.fingerprint = resolvedFp;
652
+ if (isOnline) existing.online = true;
653
+ }
654
+ }
655
+
656
+ if (activeChannel === "all") {
657
+ var allCloudMembers = window.cloudMembers || {};
658
+ for (var ck in allCloudMembers) {
659
+ var rows = allCloudMembers[ck];
660
+ for (var ri = 0; ri < rows.length; ri++) addMember(rows[ri].name, rows[ri].fingerprint, online.has(rows[ri].name));
661
+ }
662
+ } else {
663
+ var crows = (window.cloudMembers || {})[activeChannel] || [];
664
+ for (var ri = 0; ri < crows.length; ri++) addMember(crows[ri].name, crows[ri].fingerprint, online.has(crows[ri].name));
665
+ }
666
+
667
+ // Also from message history
668
+ var msgs = activeChannel === "all"
669
+ ? allMessages
670
+ : allMessages.filter(function(m) {
671
+ var mid = m.subchannel ? m.channel + '/' + m.subchannel : m.channel;
672
+ return mid === activeChannel || m.channel === activeChannel;
673
+ });
674
+ for (var mi = 0; mi < msgs.length; mi++) {
675
+ var m = msgs[mi];
676
+ if (m.sender && m.type !== "system") addMember(m.sender, m.senderKey, online.has(m.sender));
677
+ }
678
+
679
+ // Always include self
680
+ addMember(CONFIG.name, CONFIG.fingerprint, true);
681
+
682
+ // Convert to memberMap
683
+ for (var k in fpMap) memberMap[fpMap[k].name] = fpMap[k];
684
+
685
+ // Sort: online first, then alphabetical
686
+ var sorted = Object.keys(memberMap).sort(function(a, b) {
687
+ if (memberMap[b].online !== memberMap[a].online) return memberMap[b].online ? 1 : -1;
688
+ return a.localeCompare(b);
689
+ });
690
+
691
+ var memberCount = sorted.length;
692
+ if (header) header.textContent = "Members (" + memberCount + ")";
693
+
694
+ var html = sorted.map(function(name) {
695
+ var isOnline = memberMap[name].online;
696
+ var isYou = name === CONFIG.name;
697
+ var memberInfo = Object.values(window.cloudMembers || {}).flat().find(function(m) { return m.name === name; });
698
+ var fpStr = memberInfo && memberInfo.fingerprint
699
+ ? '<span style="color:var(--text-muted);font-size:0.6rem;margin-left:2px">(' + memberInfo.fingerprint.slice(0, 4) + ')</span>'
700
+ : '';
701
+ 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>';
702
+ }).join("");
703
+
704
+ list.innerHTML = html;
705
+
706
+ // Update actions (fixed at bottom)
707
+ var actions = document.getElementById("members-actions");
708
+ if (actions) {
709
+ if (activeChannel !== "all" && activeChannel.toLowerCase() !== "agentchannel") {
710
+ actions.innerHTML = '<button class="members__btn" onclick="window.shareChannel()"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/><polyline points="16 6 12 2 8 6"/><line x1="12" y1="2" x2="12" y2="15"/></svg> Share</button>'
711
+ + '<button class="members__btn members__btn--leave" onclick="window.leaveChannel()"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg> Leave</button>';
712
+ } else {
713
+ actions.innerHTML = "";
714
+ }
715
+ }
716
+ }
717
+
718
+ // ---------------------------------------------------------------------------
719
+ // MQTT message handler
720
+ // ---------------------------------------------------------------------------
721
+ client.on("message", async function(topic, payload) {
722
+ for (var chKey in channels) {
723
+ var ch = channels[chKey];
724
+
725
+ // Presence messages
726
+ if (topic === "ac/1/" + ch.hash + "/p") {
727
+ try {
728
+ var data = JSON.parse(payload.toString());
729
+ var pKey = ch.sub ? ch.name + '/' + ch.sub : ch.name;
730
+ if (!onlineMembers[pKey]) onlineMembers[pKey] = new Set();
731
+ if (data.status === "online") onlineMembers[pKey].add(data.name);
732
+ else onlineMembers[pKey].delete(data.name);
733
+ renderMembers();
734
+ } catch(e) {}
735
+ return;
736
+ }
737
+
738
+ // Channel messages
739
+ if (topic === "ac/1/" + ch.hash) {
740
+ try {
741
+ var msg = JSON.parse(await decryptPayload(payload.toString(), ch.key));
742
+ msg.channel = ch.name;
743
+ if (ch.sub) msg.subchannel = ch.sub;
744
+
745
+ // Handle channel_meta: auto-discover and subscribe to new subchannels
746
+ if (msg.type === "channel_meta") {
747
+ try {
748
+ var meta = JSON.parse(msg.content);
749
+ if (!ch.sub) channelMetas[ch.name] = meta;
750
+ if (meta.subchannels && meta.subchannels.length > 0) {
751
+ for (var si = 0; si < meta.subchannels.length; si++) {
752
+ var subName = meta.subchannels[si];
753
+ var subId = ch.name + '/' + subName;
754
+ if (!channels[subId]) {
755
+ var parentCfg = CONFIG.channels.find(function(c) { return c.channel === ch.name && !c.subchannel; });
756
+ if (parentCfg) {
757
+ var subKey = await deriveSubKeyWeb(parentCfg.key, subName);
758
+ var subHash = await hashSubWeb(parentCfg.key, subName);
759
+ channels[subId] = {key: subKey, hash: subHash, name: ch.name, sub: subName};
760
+ CONFIG.channels.push({channel: ch.name, subchannel: subName, key: parentCfg.key});
761
+ client.subscribe("ac/1/" + subHash);
762
+ client.subscribe("ac/1/" + subHash + "/p");
763
+ // Load history for new subchannel
764
+ try {
765
+ var hres = await fetch("https://api.agentchannel.workers.dev/messages?channel_hash=" + subHash + "&since=0&limit=30");
766
+ var hrows = await hres.json();
767
+ for (var hi = 0; hi < hrows.length; hi++) {
768
+ try {
769
+ var hmsg = JSON.parse(await decryptPayload(hrows[hi].ciphertext, subKey));
770
+ hmsg.channel = ch.name;
771
+ hmsg.subchannel = subName;
772
+ if (hmsg.type !== "channel_meta") allMessages.push(hmsg);
773
+ } catch(e) {}
774
+ }
775
+ allMessages.sort(function(a, b) { return a.timestamp - b.timestamp; });
776
+ } catch(e) {}
777
+ renderSidebar();
778
+ render();
779
+ }
780
+ }
781
+ }
782
+ }
783
+ } catch(e) {}
784
+ return;
785
+ }
786
+
787
+ allMessages.push(msg);
788
+
789
+ // Track sender as online
790
+ var chKeyName = ch.sub ? ch.name + '/' + ch.sub : ch.name;
791
+ if (!onlineMembers[chKeyName]) onlineMembers[chKeyName] = new Set();
792
+ onlineMembers[chKeyName].add(msg.sender);
793
+
794
+ if (msg.sender !== CONFIG.name) {
795
+ if (activeChannel !== chKeyName && activeChannel !== "all") {
796
+ unreadCounts[chKeyName] = (unreadCounts[chKeyName] || 0) + 1;
797
+ renderSidebar();
798
+ }
799
+ var total = Object.values(unreadCounts).reduce(function(a, b) { return a + b; }, 0);
800
+ if (total > 0) document.title = "(" + total + ") AgentChannel";
801
+ var nlabel = ch.sub ? "#" + ch.name + " ##" + ch.sub : "#" + ch.name;
802
+ if (Notification.permission === "granted" && (document.hidden || activeChannel !== chKeyName)) {
803
+ var n = new Notification(nlabel + " @" + msg.sender, {body: msg.content});
804
+ n.onclick = function() {
805
+ window.focus();
806
+ if (ch.sub) { window.switchToSub(ch.sub); }
807
+ else { window.switchToChannel(ch.name); }
808
+ };
809
+ }
810
+ }
811
+ render();
812
+ renderMembers();
813
+ } catch(e) {}
814
+ }
815
+ }
816
+ });
817
+
818
+ window.renderMembers = renderMembers;
819
+
820
+ window.switchToChannel = function(name) {
821
+ activeChannel = name;
822
+ unreadCounts[name] = 0;
823
+ headerName.textContent = "#" + name;
824
+ headerDesc.textContent = (channelMetas[name] && channelMetas[name].description) || "";
825
+ document.title = "AgentChannel";
826
+ history.pushState(null, "", "/channel/" + encodeURIComponent(name));
827
+ renderSidebar();
828
+ render();
829
+ renderMembers();
830
+ };
831
+
832
+ window.switchToSub = function(subName) {
833
+ var parent = CONFIG.channels.find(function(c) { return c.subchannel === subName; });
834
+ if (!parent) return;
835
+ var cid = parent.channel + "/" + subName;
836
+ activeChannel = cid;
837
+ unreadCounts[cid] = 0;
838
+ headerName.textContent = "##" + subName;
839
+ var subDesc2 = (channelMetas[parent.channel] && channelMetas[parent.channel].descriptions && channelMetas[parent.channel].descriptions[subName]) || "";
840
+ headerDesc.textContent = "#" + parent.channel + (subDesc2 ? " \u00B7 " + subDesc2 : "");
841
+ document.title = "AgentChannel";
842
+ history.pushState(null, "", "/channel/" + encodeURIComponent(parent.channel) + "/sub/" + encodeURIComponent(subName));
843
+ renderSidebar();
844
+ render();
845
+ renderMembers();
846
+ };
847
+
848
+ renderMembers();
849
+ loadCloudMembers();
850
+ }
851
+
852
+ // Handle initial active channel from URL or server-injected config
853
+ if (window.__AC_INITIAL_CHANNEL__) {
854
+ activeChannel = window.__AC_INITIAL_CHANNEL__;
855
+ }
856
+
857
+ // Theme toggle: dark ↔ light only
858
+ var sunIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>';
859
+ var moonIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>';
860
+
861
+ function toggleTheme() {
862
+ var root = document.documentElement;
863
+ var btn = document.getElementById('theme-toggle');
864
+ var isDark = root.classList.contains('dark');
865
+ root.classList.remove('dark', 'light');
866
+ if (isDark) {
867
+ root.classList.add('light');
868
+ btn.innerHTML = moonIcon;
869
+ localStorage.setItem('ac-theme', 'light');
870
+ } else {
871
+ root.classList.add('dark');
872
+ btn.innerHTML = sunIcon;
873
+ localStorage.setItem('ac-theme', 'dark');
874
+ }
875
+ }
876
+ window.toggleTheme = toggleTheme;
877
+
878
+ // Restore saved theme (default: dark)
879
+ var savedTheme = localStorage.getItem('ac-theme') || 'dark';
880
+ document.documentElement.classList.add(savedTheme);
881
+ var themeBtn = document.getElementById('theme-toggle');
882
+ if (themeBtn) themeBtn.innerHTML = savedTheme === 'dark' ? sunIcon : moonIcon;
883
+
884
+ init();