watercooler 0.0.1

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/public/app.js ADDED
@@ -0,0 +1,707 @@
1
+ import * as THREE from 'three';
2
+ import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
3
+
4
+ // State
5
+ let config = { user: '', mailbox: '' };
6
+ let messages = []; // Messages TO user (for main panel)
7
+ let allMessages = []; // All messages involving user (for house dialogs)
8
+ let recipients = [];
9
+ let scene, camera, renderer, controls;
10
+ let agentMeshes = new Map();
11
+ let connectionLines = [];
12
+ let raycaster, mouse;
13
+
14
+ // Color palette for agents
15
+ const agentColors = [
16
+ 0xFF6B6B, 0x4ECDC4, 0x45B7D1, 0xFFA07A, 0x98D8C8,
17
+ 0xF7DC6F, 0xBB8FCE, 0x85C1E2, 0xF8B500, 0x6C5CE7
18
+ ];
19
+
20
+ function getAgentColor(name) {
21
+ let hash = 0;
22
+ for (let i = 0; i < name.length; i++) {
23
+ hash = name.charCodeAt(i) + ((hash << 5) - hash);
24
+ }
25
+ return agentColors[Math.abs(hash) % agentColors.length];
26
+ }
27
+
28
+ // Initialize Three.js
29
+ function init() {
30
+ const container = document.getElementById('canvas-container');
31
+
32
+ scene = new THREE.Scene();
33
+ scene.background = new THREE.Color(0x667eea);
34
+
35
+ camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
36
+ camera.position.set(0, 30, 40);
37
+
38
+ renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
39
+ renderer.setSize(window.innerWidth, window.innerHeight);
40
+ renderer.shadowMap.enabled = true;
41
+ renderer.shadowMap.type = THREE.PCFSoftShadowMap;
42
+ container.appendChild(renderer.domElement);
43
+
44
+ controls = new OrbitControls(camera, renderer.domElement);
45
+ controls.enableDamping = true;
46
+ controls.dampingFactor = 0.05;
47
+ controls.maxPolarAngle = Math.PI / 2 - 0.1;
48
+ controls.minDistance = 20;
49
+ controls.maxDistance = 80;
50
+
51
+ // Lighting
52
+ const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
53
+ scene.add(ambientLight);
54
+
55
+ const dirLight = new THREE.DirectionalLight(0xffffff, 0.8);
56
+ dirLight.position.set(50, 100, 50);
57
+ dirLight.castShadow = true;
58
+ dirLight.shadow.camera.left = -50;
59
+ dirLight.shadow.camera.right = 50;
60
+ dirLight.shadow.camera.top = 50;
61
+ dirLight.shadow.camera.bottom = -50;
62
+ dirLight.shadow.mapSize.width = 2048;
63
+ dirLight.shadow.mapSize.height = 2048;
64
+ scene.add(dirLight);
65
+
66
+ // Ground
67
+ const groundGeo = new THREE.PlaneGeometry(200, 200);
68
+ const groundMat = new THREE.MeshStandardMaterial({
69
+ color: 0x7dd3c0,
70
+ roughness: 0.8
71
+ });
72
+ const ground = new THREE.Mesh(groundGeo, groundMat);
73
+ ground.rotation.x = -Math.PI / 2;
74
+ ground.receiveShadow = true;
75
+ scene.add(ground);
76
+
77
+ // Grid helper
78
+ const grid = new THREE.GridHelper(200, 50, 0xffffff, 0xffffff);
79
+ grid.material.opacity = 0.2;
80
+ grid.material.transparent = true;
81
+ scene.add(grid);
82
+
83
+ // Trees
84
+ createTrees();
85
+
86
+ window.addEventListener('resize', onWindowResize);
87
+
88
+ // Raycaster for house clicks
89
+ raycaster = new THREE.Raycaster();
90
+ mouse = new THREE.Vector2();
91
+ renderer.domElement.addEventListener('click', onHouseClick);
92
+
93
+ animate();
94
+ }
95
+
96
+ function createTrees() {
97
+ for (let i = 0; i < 30; i++) {
98
+ const x = (Math.random() - 0.5) * 150;
99
+ const z = (Math.random() - 0.5) * 150;
100
+
101
+ // Don't place trees too close to center
102
+ if (Math.sqrt(x*x + z*z) < 30) continue;
103
+
104
+ const trunkGeo = new THREE.CylinderGeometry(0.5, 0.8, 3, 8);
105
+ const trunkMat = new THREE.MeshStandardMaterial({ color: 0x8B4513 });
106
+ const trunk = new THREE.Mesh(trunkGeo, trunkMat);
107
+ trunk.position.set(x, 1.5, z);
108
+ trunk.castShadow = true;
109
+
110
+ const leavesGeo = new THREE.ConeGeometry(3, 8, 8);
111
+ const leavesMat = new THREE.MeshStandardMaterial({ color: 0x228B22 });
112
+ const leaves = new THREE.Mesh(leavesGeo, leavesMat);
113
+ leaves.position.set(x, 6, z);
114
+ leaves.castShadow = true;
115
+
116
+ scene.add(trunk);
117
+ scene.add(leaves);
118
+ }
119
+ }
120
+
121
+ function createAgentHouse(name, position) {
122
+ const color = getAgentColor(name);
123
+ const group = new THREE.Group();
124
+ group.position.copy(position);
125
+
126
+ // House base
127
+ const baseGeo = new THREE.BoxGeometry(6, 4, 6);
128
+ const baseMat = new THREE.MeshStandardMaterial({ color: color });
129
+ const base = new THREE.Mesh(baseGeo, baseMat);
130
+ base.position.y = 2;
131
+ base.castShadow = true;
132
+ base.receiveShadow = true;
133
+ group.add(base);
134
+
135
+ // Roof
136
+ const roofGeo = new THREE.ConeGeometry(5, 3, 4);
137
+ const roofMat = new THREE.MeshStandardMaterial({ color: 0x8B4513 });
138
+ const roof = new THREE.Mesh(roofGeo, roofMat);
139
+ roof.position.y = 5.5;
140
+ roof.rotation.y = Math.PI / 4;
141
+ roof.castShadow = true;
142
+ group.add(roof);
143
+
144
+ // Door
145
+ const doorGeo = new THREE.BoxGeometry(1.5, 2.5, 0.2);
146
+ const doorMat = new THREE.MeshStandardMaterial({ color: 0x4a3c28 });
147
+ const door = new THREE.Mesh(doorGeo, doorMat);
148
+ door.position.set(0, 1.25, 3.1);
149
+ group.add(door);
150
+
151
+ // Windows
152
+ const windowGeo = new THREE.BoxGeometry(1.2, 1.2, 0.2);
153
+ const windowMat = new THREE.MeshStandardMaterial({
154
+ color: 0xFFFF99,
155
+ emissive: 0xFFFF99,
156
+ emissiveIntensity: 0.3
157
+ });
158
+
159
+ const window1 = new THREE.Mesh(windowGeo, windowMat);
160
+ window1.position.set(-1.8, 2.5, 3.1);
161
+ group.add(window1);
162
+
163
+ const window2 = new THREE.Mesh(windowGeo, windowMat);
164
+ window2.position.set(1.8, 2.5, 3.1);
165
+ group.add(window2);
166
+
167
+ // Name label sprite
168
+ const canvas = document.createElement('canvas');
169
+ const context = canvas.getContext('2d');
170
+ // High DPI canvas for crisp text
171
+ const scale = 2;
172
+ canvas.width = 512;
173
+ canvas.height = 128;
174
+ context.scale(scale, scale);
175
+ context.fillStyle = 'rgba(0, 0, 0, 0.7)';
176
+ context.roundRect(0, 0, 256, 64, 16);
177
+ context.fill();
178
+ context.font = 'bold 24px Arial';
179
+ context.fillStyle = 'white';
180
+ context.textAlign = 'center';
181
+ context.textBaseline = 'middle';
182
+ context.fillText(name, 128, 32);
183
+
184
+ const texture = new THREE.CanvasTexture(canvas);
185
+ texture.minFilter = THREE.LinearFilter;
186
+ texture.magFilter = THREE.LinearFilter;
187
+ const spriteMat = new THREE.SpriteMaterial({ map: texture });
188
+ const sprite = new THREE.Sprite(spriteMat);
189
+ sprite.position.set(0, 8, 0);
190
+ sprite.scale.set(8, 2, 1);
191
+ group.add(sprite);
192
+
193
+ // Path to house
194
+ const pathGeo = new THREE.PlaneGeometry(2, 8);
195
+ const pathMat = new THREE.MeshStandardMaterial({ color: 0xD2B48C });
196
+ const path = new THREE.Mesh(pathGeo, pathMat);
197
+ path.rotation.x = -Math.PI / 2;
198
+ path.position.set(0, 0.02, 7);
199
+ group.add(path);
200
+
201
+ scene.add(group);
202
+ agentMeshes.set(name, group);
203
+
204
+ return group;
205
+ }
206
+
207
+ function createMessageParticle(fromPos, toPos) {
208
+ const particleGeo = new THREE.SphereGeometry(0.3, 8, 8);
209
+ const particleMat = new THREE.MeshStandardMaterial({
210
+ color: 0xFFD700,
211
+ emissive: 0xFFD700,
212
+ emissiveIntensity: 0.5
213
+ });
214
+ const particle = new THREE.Mesh(particleGeo, particleMat);
215
+
216
+ particle.position.copy(fromPos);
217
+ particle.position.y += 8;
218
+
219
+ scene.add(particle);
220
+
221
+ // Animate particle
222
+ const startTime = Date.now();
223
+ const duration = 2000;
224
+
225
+ function animateParticle() {
226
+ const elapsed = Date.now() - startTime;
227
+ const progress = Math.min(elapsed / duration, 1);
228
+
229
+ particle.position.lerpVectors(
230
+ new THREE.Vector3(fromPos.x, fromPos.y + 8, fromPos.z),
231
+ new THREE.Vector3(toPos.x, toPos.y + 8, toPos.z),
232
+ progress
233
+ );
234
+
235
+ // Add arc
236
+ particle.position.y += Math.sin(progress * Math.PI) * 3;
237
+
238
+ if (progress < 1) {
239
+ requestAnimationFrame(animateParticle);
240
+ } else {
241
+ scene.remove(particle);
242
+ }
243
+ }
244
+
245
+ animateParticle();
246
+ }
247
+
248
+ function createConnectionLine(fromPos, toPos, isUnread) {
249
+ const material = new THREE.LineBasicMaterial({
250
+ color: isUnread ? 0xff6b6b : 0x4CAF50,
251
+ opacity: isUnread ? 0.8 : 0.3,
252
+ transparent: true,
253
+ linewidth: isUnread ? 3 : 1
254
+ });
255
+
256
+ const points = [
257
+ new THREE.Vector3(fromPos.x, fromPos.y + 5, fromPos.z),
258
+ new THREE.Vector3(toPos.x, toPos.y + 5, toPos.z)
259
+ ];
260
+
261
+ const geometry = new THREE.BufferGeometry().setFromPoints(points);
262
+ const line = new THREE.Line(geometry, material);
263
+
264
+ scene.add(line);
265
+ connectionLines.push(line);
266
+
267
+ // Send particle
268
+ setTimeout(() => {
269
+ createMessageParticle(fromPos, toPos);
270
+ }, 100);
271
+ }
272
+
273
+ function clearConnections() {
274
+ connectionLines.forEach(line => scene.remove(line));
275
+ connectionLines = [];
276
+ }
277
+
278
+ function updateVillage() {
279
+ clearConnections();
280
+
281
+ // Use recipients (from coworkers.db) as the authoritative list of agents
282
+ // This ensures we only show houses for registered coworkers
283
+ const allAgents = new Set([config.user.toLowerCase(), ...recipients.map(r => r.toLowerCase())]);
284
+
285
+ // Also add message participants that might not be in coworker db yet
286
+ messages.forEach(m => {
287
+ allAgents.add(m.sender.toLowerCase());
288
+ allAgents.add(m.recipient.toLowerCase());
289
+ });
290
+
291
+ // Arrange agents in a circle
292
+ const agents = Array.from(allAgents);
293
+ const radius = 25;
294
+
295
+ agents.forEach((agent, index) => {
296
+ const angle = (index / agents.length) * Math.PI * 2;
297
+ const x = Math.cos(angle) * radius;
298
+ const z = Math.sin(angle) * radius;
299
+ const position = new THREE.Vector3(x, 0, z);
300
+
301
+ if (!agentMeshes.has(agent)) {
302
+ createAgentHouse(agent, position);
303
+ } else {
304
+ // Update position if needed
305
+ const house = agentMeshes.get(agent);
306
+ house.position.copy(position);
307
+ }
308
+ });
309
+
310
+ // Create connections based on messages
311
+ messages.forEach(msg => {
312
+ const fromHouse = agentMeshes.get(msg.sender.toLowerCase());
313
+ const toHouse = agentMeshes.get(msg.recipient.toLowerCase());
314
+
315
+ if (fromHouse && toHouse) {
316
+ createConnectionLine(
317
+ fromHouse.position,
318
+ toHouse.position,
319
+ !msg.read && msg.recipient.toLowerCase() === config.user.toLowerCase()
320
+ );
321
+ }
322
+ });
323
+
324
+ // Update house labels with unread indicators
325
+ updateHouseLabels();
326
+ }
327
+
328
+ function animate() {
329
+ requestAnimationFrame(animate);
330
+ controls.update();
331
+ renderer.render(scene, camera);
332
+ }
333
+
334
+ function onWindowResize() {
335
+ camera.aspect = window.innerWidth / window.innerHeight;
336
+ camera.updateProjectionMatrix();
337
+ renderer.setSize(window.innerWidth, window.innerHeight);
338
+ }
339
+
340
+ // API and UI Functions
341
+ async function loadData() {
342
+ try {
343
+ const [configRes, messagesRes, coworkersRes, allMessagesRes] = await Promise.all([
344
+ fetch('/api/config'),
345
+ fetch('/api/messages'),
346
+ fetch('/api/coworkers'),
347
+ fetch('/api/messages/all')
348
+ ]);
349
+
350
+ config = await configRes.json();
351
+ messages = await messagesRes.json();
352
+ recipients = await coworkersRes.json(); // Use coworkers endpoint
353
+ allMessages = await allMessagesRes.json();
354
+
355
+ updateUI();
356
+ updateVillage();
357
+ } catch (err) {
358
+ console.error('Error loading data:', err);
359
+ }
360
+ }
361
+
362
+ // Panel toggle functions
363
+ window.toggleSendPanel = function() {
364
+ const panel = document.getElementById('send-panel');
365
+ const btn = document.getElementById('collapse-btn');
366
+ panel.classList.toggle('collapsed');
367
+ btn.textContent = panel.classList.contains('collapsed') ? '+' : '−';
368
+ };
369
+
370
+ window.toggleMessagesPanel = function() {
371
+ const panel = document.getElementById('messages-panel');
372
+ panel.classList.toggle('open');
373
+ };
374
+
375
+ function updateUI() {
376
+ const unread = messages.filter(m => !m.read && m.recipient.toLowerCase() === config.user.toLowerCase()).length;
377
+
378
+ // Update messages button - change icon and show badge when unread
379
+ const msgBtn = document.getElementById('toggle-messages-btn');
380
+ const badge = document.getElementById('unread-badge');
381
+ if (unread > 0) {
382
+ msgBtn.innerHTML = `🔔 Messages <span class="badge" id="unread-badge">${unread}</span>`;
383
+ } else {
384
+ msgBtn.innerHTML = `📨 Messages <span class="badge" id="unread-badge" style="display: none;">0</span>`;
385
+ }
386
+
387
+ // Update recipient select (send panel) - only from coworkers.db
388
+ const select = document.getElementById('recipient-select');
389
+ const currentVal = select.value;
390
+ select.innerHTML = '<option value="">To: Select agent...</option>' +
391
+ recipients.sort().map(r =>
392
+ `<option value="${r}" ${r === currentVal ? 'selected' : ''}>${r}</option>`
393
+ ).join('');
394
+
395
+ // Update messages list (slide-out panel)
396
+ const messagesDiv = document.getElementById('messages-container');
397
+ if (messages.length === 0) {
398
+ messagesDiv.innerHTML = `
399
+ <div class="empty-state">
400
+ <div style="font-size: 2rem; margin-bottom: 8px;">📭</div>
401
+ <p>No messages yet</p>
402
+ </div>
403
+ `;
404
+ } else {
405
+ messagesDiv.innerHTML = messages.slice(0, 20).map(msg => `
406
+ <div class="message-card ${msg.read ? '' : 'unread'}" data-id="${msg.id}" data-sender="${msg.sender}" data-recipient="${msg.recipient}">
407
+ <div class="message-header">
408
+ <span class="message-sender">${msg.sender} → ${msg.recipient}</span>
409
+ <span class="message-time">${new Date(msg.timestamp).toLocaleString()}</span>
410
+ </div>
411
+ <div class="message-text">${msg.message}</div>
412
+ </div>
413
+ `).join('');
414
+
415
+ // Add click handlers for all messages (clicking marks as read and sets recipient for reply)
416
+ messagesDiv.querySelectorAll('.message-card').forEach(el => {
417
+ el.addEventListener('click', () => {
418
+ const msgId = el.dataset.id;
419
+ const sender = el.dataset.sender;
420
+ const recipient = el.dataset.recipient;
421
+
422
+ // Determine who to reply to
423
+ // If I received the message, reply to sender
424
+ // If I sent the message, reply to the original recipient
425
+ const myName = config.user.toLowerCase();
426
+ const replyTo = recipient.toLowerCase() === myName ? sender : recipient;
427
+
428
+ // Set the recipient select
429
+ const select = document.getElementById('recipient-select');
430
+ if (select) {
431
+ select.value = replyTo;
432
+ }
433
+
434
+ // Mark as read
435
+ markAsRead(msgId);
436
+
437
+ // Focus the message input for typing
438
+ const messageInput = document.getElementById('message-input');
439
+ if (messageInput) {
440
+ messageInput.focus();
441
+ }
442
+ });
443
+ });
444
+ }
445
+
446
+ // Update house dialog if it's open
447
+ if (document.getElementById('house-dialog').classList.contains('active')) {
448
+ updateHouseDialogContent();
449
+ }
450
+ }
451
+
452
+ async function markAsRead(id) {
453
+ try {
454
+ await fetch(`/api/messages/${id}/read`, { method: 'POST' });
455
+ loadData();
456
+ } catch (err) {
457
+ console.error('Error marking as read:', err);
458
+ }
459
+ }
460
+
461
+ async function sendMessage() {
462
+ const to = document.getElementById('recipient-select').value;
463
+ const message = document.getElementById('message-input').value.trim();
464
+
465
+ if (!to || !message) {
466
+ alert('Please select a coworker and enter a message');
467
+ return;
468
+ }
469
+
470
+ try {
471
+ const response = await fetch('/api/send', {
472
+ method: 'POST',
473
+ headers: { 'Content-Type': 'application/json' },
474
+ body: JSON.stringify({ to, from: config.user, message })
475
+ });
476
+
477
+ if (response.ok) {
478
+ // Clear input
479
+ document.getElementById('message-input').value = '';
480
+
481
+ // Show toast
482
+ const toast = document.getElementById('toast');
483
+ toast.classList.add('show');
484
+ setTimeout(() => toast.classList.remove('show'), 3000);
485
+
486
+ // Reload data
487
+ loadData();
488
+ } else {
489
+ alert('Failed to send message');
490
+ }
491
+ } catch (err) {
492
+ console.error('Error sending:', err);
493
+ alert('Error sending message');
494
+ }
495
+ }
496
+
497
+ // House click handler
498
+ function onHouseClick(event) {
499
+ // Calculate mouse position
500
+ mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
501
+ mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
502
+
503
+ raycaster.setFromCamera(mouse, camera);
504
+
505
+ // Get all house meshes
506
+ const houseMeshes = [];
507
+ agentMeshes.forEach((group, name) => {
508
+ group.children.forEach(child => {
509
+ if (child.isMesh && !child.userData.isBubble && !child.userData.isCup) {
510
+ child.userData.agentName = name;
511
+ houseMeshes.push(child);
512
+ }
513
+ });
514
+ });
515
+
516
+ const intersects = raycaster.intersectObjects(houseMeshes);
517
+
518
+ if (intersects.length > 0) {
519
+ const agentName = intersects[0].object.userData.agentName;
520
+ if (agentName) {
521
+ showHouseDialog(agentName);
522
+ }
523
+ }
524
+ }
525
+
526
+ // Global variable to track current agent for house dialog
527
+ let currentHouseAgent = null;
528
+ let currentTab = 'received';
529
+
530
+ // Show dialog with messages for a specific agent
531
+ async function showHouseDialog(agentName) {
532
+ currentHouseAgent = agentName.toLowerCase();
533
+ currentTab = 'received'; // Default to received tab
534
+
535
+ const dialog = document.getElementById('house-dialog');
536
+ const title = document.getElementById('house-dialog-title');
537
+
538
+ // Capitalize first letter
539
+ const displayName = agentName.charAt(0).toUpperCase() + agentName.slice(1);
540
+ title.textContent = `${displayName}'s Messages`;
541
+
542
+ // Update tab labels
543
+ document.getElementById('tab-received').innerHTML =
544
+ `📥 Received by ${displayName} <span id="received-count" class="tab-badge"></span>`;
545
+ document.getElementById('tab-sent').innerHTML =
546
+ `📤 Sent by ${displayName} <span id="sent-count" class="tab-badge"></span>`;
547
+
548
+ // Load all messages (both sent and received) for this dialog
549
+ try {
550
+ const response = await fetch('/api/messages/all');
551
+ allMessages = await response.json();
552
+ } catch (err) {
553
+ console.error('Error loading all messages:', err);
554
+ allMessages = [];
555
+ }
556
+
557
+ // Switch to received tab by default
558
+ switchTab('received');
559
+
560
+ dialog.classList.add('active');
561
+ }
562
+
563
+ // Tab switching function
564
+ window.switchTab = function(tab) {
565
+ currentTab = tab;
566
+
567
+ // Update tab buttons
568
+ document.getElementById('tab-received').classList.toggle('active', tab === 'received');
569
+ document.getElementById('tab-sent').classList.toggle('active', tab === 'sent');
570
+
571
+ // Update content
572
+ updateHouseDialogContent();
573
+ };
574
+
575
+ function updateHouseDialogContent() {
576
+ const content = document.getElementById('house-dialog-content');
577
+
578
+ if (!currentHouseAgent) return;
579
+
580
+ // Filter messages based on current tab - FROM THE AGENT'S PERSPECTIVE
581
+ let filteredMessages;
582
+ if (currentTab === 'received') {
583
+ // Messages RECEIVED BY the agent (sent TO the agent)
584
+ filteredMessages = allMessages.filter(m =>
585
+ m.recipient.toLowerCase() === currentHouseAgent
586
+ );
587
+ } else {
588
+ // Messages SENT BY the agent
589
+ filteredMessages = allMessages.filter(m =>
590
+ m.sender.toLowerCase() === currentHouseAgent
591
+ );
592
+ }
593
+
594
+ // Update count badges
595
+ const receivedCount = allMessages.filter(m =>
596
+ m.recipient.toLowerCase() === currentHouseAgent
597
+ ).length;
598
+
599
+ const sentCount = allMessages.filter(m =>
600
+ m.sender.toLowerCase() === currentHouseAgent
601
+ ).length;
602
+
603
+ const receivedBadge = document.getElementById('received-count');
604
+ const sentBadge = document.getElementById('sent-count');
605
+
606
+ receivedBadge.textContent = receivedCount > 0 ? receivedCount : '';
607
+ sentBadge.textContent = sentCount > 0 ? sentCount : '';
608
+
609
+ // Render messages
610
+ if (filteredMessages.length === 0) {
611
+ content.innerHTML = `
612
+ <div class="empty-state">
613
+ <div style="font-size: 2rem; margin-bottom: 8px;">📭</div>
614
+ <p>No ${currentTab} messages</p>
615
+ </div>
616
+ `;
617
+ } else {
618
+ content.innerHTML = filteredMessages.map(msg => `
619
+ <div class="message-card ${msg.read ? '' : 'unread'}" style="margin-bottom: 12px;">
620
+ <div class="message-header">
621
+ <span class="message-sender">${msg.sender} → ${msg.recipient}</span>
622
+ <span class="message-time">${new Date(msg.timestamp).toLocaleString()}</span>
623
+ </div>
624
+ <div class="message-text">${msg.message}</div>
625
+ </div>
626
+ `).join('');
627
+ }
628
+ }
629
+
630
+ window.closeHouseDialog = function() {
631
+ document.getElementById('house-dialog').classList.remove('active');
632
+ };
633
+
634
+ // Update house labels to show unread indicators
635
+ function updateHouseLabels() {
636
+ agentMeshes.forEach((group, name) => {
637
+ // Check for unread messages SENT TO this agent (messages they haven't read)
638
+ const unreadCount = allMessages.filter(m =>
639
+ m.recipient.toLowerCase() === name.toLowerCase() &&
640
+ m.sender.toLowerCase() === config.user.toLowerCase() &&
641
+ !m.read
642
+ ).length;
643
+
644
+ // Also check if this agent has sent unread messages TO user
645
+ const unreadFromAgent = allMessages.filter(m =>
646
+ m.sender.toLowerCase() === name.toLowerCase() &&
647
+ m.recipient.toLowerCase() === config.user.toLowerCase() &&
648
+ !m.read
649
+ ).length;
650
+
651
+ // Find the sprite label
652
+ const sprite = group.children.find(c => c.isSprite);
653
+ if (sprite) {
654
+ // Update the canvas texture - high DPI for crisp text
655
+ const canvas = document.createElement('canvas');
656
+ const context = canvas.getContext('2d');
657
+ const scale = 2;
658
+ canvas.width = 700;
659
+ canvas.height = 128;
660
+ context.scale(scale, scale);
661
+
662
+ // Background - change color if there are unread messages
663
+ if (unreadFromAgent > 0) {
664
+ // Red background for unread messages from agent
665
+ context.fillStyle = 'rgba(220, 53, 69, 0.9)';
666
+ } else if (unreadCount > 0) {
667
+ // Blue background for messages sent but not read
668
+ context.fillStyle = 'rgba(0, 123, 255, 0.9)';
669
+ } else {
670
+ // Default black background
671
+ context.fillStyle = 'rgba(0, 0, 0, 0.7)';
672
+ }
673
+ context.roundRect(0, 0, 350, 64, 16);
674
+ context.fill();
675
+
676
+ // Name
677
+ context.font = 'bold 24px Arial';
678
+ context.fillStyle = 'white';
679
+ context.textAlign = 'center';
680
+ context.textBaseline = 'middle';
681
+
682
+ if (unreadFromAgent > 0) {
683
+ // Show name with unread indicator from agent
684
+ context.fillText(`${name} 🔴 ${unreadFromAgent}`, 175, 32);
685
+ } else if (unreadCount > 0) {
686
+ // Show name with sent-but-unread count
687
+ context.fillText(`${name} 📤 ${unreadCount}`, 175, 32);
688
+ } else {
689
+ context.fillText(name, 175, 32);
690
+ }
691
+
692
+ const texture = new THREE.CanvasTexture(canvas);
693
+ texture.minFilter = THREE.LinearFilter;
694
+ texture.magFilter = THREE.LinearFilter;
695
+ sprite.material.map = texture;
696
+ sprite.material.needsUpdate = true;
697
+ }
698
+ });
699
+ }
700
+
701
+ // Event listeners
702
+ document.getElementById('send-btn').addEventListener('click', sendMessage);
703
+
704
+ // Initialize
705
+ init();
706
+ loadData();
707
+ setInterval(loadData, 5000);