watercooler 0.0.3 → 0.0.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "watercooler",
3
- "version": "0.0.3",
3
+ "version": "0.0.5",
4
4
  "description": "A beautiful 3D visualization of your mailbox messages as a village of coworkers",
5
5
  "type": "module",
6
6
  "main": "server.ts",
package/public/app.js CHANGED
@@ -2,10 +2,11 @@ import * as THREE from 'three';
2
2
  import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
3
3
 
4
4
  // State
5
- let config = { user: '', mailbox: '' };
5
+ let config = { user: '', mailbox: '', avatar: null };
6
6
  let messages = []; // Messages TO user (for main panel)
7
7
  let allMessages = []; // All messages involving user (for house dialogs)
8
8
  let recipients = [];
9
+ let avatarStates = {}; // Map of name -> {tool_name, timestamp}
9
10
  let scene, camera, renderer, controls;
10
11
  let agentMeshes = new Map();
11
12
  let connectionLines = [];
@@ -47,6 +48,13 @@ function init() {
47
48
  controls.maxPolarAngle = Math.PI / 2 - 0.1;
48
49
  controls.minDistance = 20;
49
50
  controls.maxDistance = 80;
51
+ controls.enableZoom = true;
52
+ controls.zoomSpeed = 0.8;
53
+ controls.enablePan = false; // Disable pan on touch for better mobile UX
54
+ controls.touches = {
55
+ ONE: THREE.TOUCH.ROTATE,
56
+ TWO: THREE.TOUCH.DOLLY_PAN
57
+ };
50
58
 
51
59
  // Lighting
52
60
  const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
@@ -90,6 +98,18 @@ function init() {
90
98
  mouse = new THREE.Vector2();
91
99
  renderer.domElement.addEventListener('click', onHouseClick);
92
100
 
101
+ // Add touch support for mobile
102
+ renderer.domElement.addEventListener('touchstart', onHouseTouchStart, { passive: false });
103
+ renderer.domElement.addEventListener('touchend', onHouseTouchEnd, { passive: false });
104
+
105
+ // Disable context menu on mobile for better UX
106
+ renderer.domElement.addEventListener('contextmenu', (e) => e.preventDefault());
107
+
108
+ // Handle orientation change
109
+ window.addEventListener('orientationchange', () => {
110
+ setTimeout(onWindowResize, 100);
111
+ });
112
+
93
113
  animate();
94
114
  }
95
115
 
@@ -118,7 +138,7 @@ function createTrees() {
118
138
  }
119
139
  }
120
140
 
121
- function createAgentHouse(name, position) {
141
+ function createAgentHouse(name, position, toolName = null) {
122
142
  const color = getAgentColor(name);
123
143
  const group = new THREE.Group();
124
144
  group.position.copy(position);
@@ -164,30 +184,42 @@ function createAgentHouse(name, position) {
164
184
  window2.position.set(1.8, 2.5, 3.1);
165
185
  group.add(window2);
166
186
 
167
- // Name label sprite
187
+ // Name label sprite (with optional tool name)
168
188
  const canvas = document.createElement('canvas');
169
189
  const context = canvas.getContext('2d');
170
190
  // High DPI canvas for crisp text
171
191
  const scale = 2;
172
192
  canvas.width = 512;
173
- canvas.height = 128;
193
+ canvas.height = toolName ? 160 : 128; // Taller if showing tool
174
194
  context.scale(scale, scale);
195
+
196
+ // Background
175
197
  context.fillStyle = 'rgba(0, 0, 0, 0.7)';
176
- context.roundRect(0, 0, 256, 64, 16);
198
+ context.roundRect(0, 0, 256, toolName ? 80 : 64, 16);
177
199
  context.fill();
200
+
201
+ // Name text
178
202
  context.font = 'bold 24px Arial';
179
203
  context.fillStyle = 'white';
180
204
  context.textAlign = 'center';
181
205
  context.textBaseline = 'middle';
182
- context.fillText(name, 128, 32);
206
+ context.fillText(name, 128, 24);
207
+
208
+ // Tool name text (if available)
209
+ if (toolName) {
210
+ context.font = 'italic 16px Arial';
211
+ context.fillStyle = '#FFD700'; // Gold color for tool name
212
+ context.fillText(toolName, 128, 56);
213
+ }
183
214
 
184
215
  const texture = new THREE.CanvasTexture(canvas);
185
216
  texture.minFilter = THREE.LinearFilter;
186
217
  texture.magFilter = THREE.LinearFilter;
187
218
  const spriteMat = new THREE.SpriteMaterial({ map: texture });
188
219
  const sprite = new THREE.Sprite(spriteMat);
189
- sprite.position.set(0, 8, 0);
190
- sprite.scale.set(8, 2, 1);
220
+ sprite.position.set(0, toolName ? 8.5 : 8, 0);
221
+ sprite.scale.set(8, toolName ? 2.5 : 2, 1);
222
+ sprite.name = 'label'; // Tag for easy updates
191
223
  group.add(sprite);
192
224
 
193
225
  // Path to house
@@ -204,6 +236,49 @@ function createAgentHouse(name, position) {
204
236
  return group;
205
237
  }
206
238
 
239
+ function updateHouseLabel(house, name, toolName = null) {
240
+ const sprite = house.getObjectByName('label');
241
+ if (!sprite) return;
242
+
243
+ // Create new canvas with updated text
244
+ const canvas = document.createElement('canvas');
245
+ const context = canvas.getContext('2d');
246
+ const scale = 2;
247
+ canvas.width = 512;
248
+ canvas.height = toolName ? 160 : 128;
249
+ context.scale(scale, scale);
250
+
251
+ // Background
252
+ context.fillStyle = 'rgba(0, 0, 0, 0.7)';
253
+ context.roundRect(0, 0, 256, toolName ? 80 : 64, 16);
254
+ context.fill();
255
+
256
+ // Name text
257
+ context.font = 'bold 24px Arial';
258
+ context.fillStyle = 'white';
259
+ context.textAlign = 'center';
260
+ context.textBaseline = 'middle';
261
+ context.fillText(name, 128, 24);
262
+
263
+ // Tool name text (if available)
264
+ if (toolName) {
265
+ context.font = 'italic 16px Arial';
266
+ context.fillStyle = '#FFD700';
267
+ context.fillText(toolName, 128, 56);
268
+ }
269
+
270
+ // Update texture
271
+ const texture = new THREE.CanvasTexture(canvas);
272
+ texture.minFilter = THREE.LinearFilter;
273
+ texture.magFilter = THREE.LinearFilter;
274
+ sprite.material.map = texture;
275
+ sprite.material.needsUpdate = true;
276
+
277
+ // Update position and scale
278
+ sprite.position.set(0, toolName ? 8.5 : 8, 0);
279
+ sprite.scale.set(8, toolName ? 2.5 : 2, 1);
280
+ }
281
+
207
282
  function createMessageParticle(fromPos, toPos) {
208
283
  const particleGeo = new THREE.SphereGeometry(0.3, 8, 8);
209
284
  const particleMat = new THREE.MeshStandardMaterial({
@@ -321,12 +396,44 @@ function updateVillage() {
321
396
  const z = Math.sin(angle) * radius;
322
397
  const position = new THREE.Vector3(x, 0, z);
323
398
 
399
+ // Get tool name for this agent from avatar states
400
+ const avatarState = avatarStates[agent.toLowerCase()];
401
+ const toolName = avatarState?.tool_name || null;
402
+
324
403
  if (!agentMeshes.has(agent)) {
325
- createAgentHouse(agent, position);
404
+ createAgentHouse(agent, position, toolName);
326
405
  } else {
327
406
  // Update position if needed
328
407
  const house = agentMeshes.get(agent);
329
408
  house.position.copy(position);
409
+
410
+ // Update label with current tool name
411
+ updateHouseLabel(house, agent, toolName);
412
+ }
413
+ });
414
+
415
+ // Remove houses for coworkers that no longer exist
416
+ agentMeshes.forEach((house, name) => {
417
+ if (!allAgents.has(name)) {
418
+ // Remove from scene
419
+ scene.remove(house);
420
+
421
+ // Dispose of geometries and materials to prevent memory leaks
422
+ house.traverse((child) => {
423
+ if (child.isMesh) {
424
+ child.geometry.dispose();
425
+ if (child.material) {
426
+ if (Array.isArray(child.material)) {
427
+ child.material.forEach(m => m.dispose());
428
+ } else {
429
+ child.material.dispose();
430
+ }
431
+ }
432
+ }
433
+ });
434
+
435
+ // Remove from Map
436
+ agentMeshes.delete(name);
330
437
  }
331
438
  });
332
439
 
@@ -354,9 +461,21 @@ function animate() {
354
461
  }
355
462
 
356
463
  function onWindowResize() {
357
- camera.aspect = window.innerWidth / window.innerHeight;
464
+ const width = window.innerWidth;
465
+ const height = window.innerHeight;
466
+ camera.aspect = width / height;
358
467
  camera.updateProjectionMatrix();
359
- renderer.setSize(window.innerWidth, window.innerHeight);
468
+ renderer.setSize(width, height);
469
+
470
+ // Adjust camera position for better mobile view
471
+ if (width < 768) {
472
+ // On mobile, position camera slightly higher and further back
473
+ camera.position.y = Math.max(camera.position.y, 35);
474
+ camera.position.z = Math.max(camera.position.z, 45);
475
+ controls.minDistance = 30; // Prevent zooming too close on mobile
476
+ } else {
477
+ controls.minDistance = 20;
478
+ }
360
479
  }
361
480
 
362
481
  // API and UI Functions
@@ -379,6 +498,17 @@ async function loadData() {
379
498
  recipients = Array.isArray(recipientsData) ? recipientsData : [];
380
499
  allMessages = Array.isArray(allMessagesData) ? allMessagesData : [];
381
500
 
501
+ // Load avatar states if avatar DB is configured
502
+ if (config.avatar) {
503
+ try {
504
+ const avatarsRes = await fetch('/api/avatars');
505
+ avatarStates = await avatarsRes.json();
506
+ } catch (err) {
507
+ console.error('Error loading avatar states:', err);
508
+ avatarStates = {};
509
+ }
510
+ }
511
+
382
512
  updateUI();
383
513
  updateVillage();
384
514
  } catch (err) {
@@ -396,7 +526,10 @@ window.toggleSendPanel = function() {
396
526
 
397
527
  window.toggleMessagesPanel = function() {
398
528
  const panel = document.getElementById('messages-panel');
529
+ const btn = document.getElementById('toggle-messages-btn');
399
530
  panel.classList.toggle('open');
531
+ btn.style.opacity = panel.classList.contains('open') ? '0' : '1';
532
+ btn.style.pointerEvents = panel.classList.contains('open') ? 'none' : 'auto';
400
533
  };
401
534
 
402
535
  function updateUI() {
@@ -491,6 +624,18 @@ async function markAsRead(id) {
491
624
  }
492
625
  }
493
626
 
627
+ window.markAllAsRead = async function() {
628
+ const unreadMessages = messages.filter(m => !m.read && m.recipient.toLowerCase() === config.user.toLowerCase());
629
+ if (unreadMessages.length === 0) return;
630
+
631
+ try {
632
+ await Promise.all(unreadMessages.map(m => fetch(`/api/messages/${m.id}/read`, { method: 'POST' })));
633
+ loadData();
634
+ } catch (err) {
635
+ console.error('Error marking all as read:', err);
636
+ }
637
+ };
638
+
494
639
  async function sendMessage() {
495
640
  const to = document.getElementById('recipient-select').value;
496
641
  const message = document.getElementById('message-input').value.trim();
@@ -529,9 +674,47 @@ async function sendMessage() {
529
674
 
530
675
  // House click handler
531
676
  function onHouseClick(event) {
532
- // Calculate mouse position
533
- mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
534
- mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
677
+ handleHouseInteraction(event.clientX, event.clientY);
678
+ }
679
+
680
+ // Touch handlers for mobile
681
+ let touchStartX = 0;
682
+ let touchStartY = 0;
683
+
684
+ function onHouseTouchStart(event) {
685
+ if (event.touches.length === 1) {
686
+ touchStartX = event.touches[0].clientX;
687
+ touchStartY = event.touches[0].clientY;
688
+ }
689
+ }
690
+
691
+ function onHouseTouchEnd(event) {
692
+ if (event.changedTouches.length === 1) {
693
+ const touchEndX = event.changedTouches[0].clientX;
694
+ const touchEndY = event.changedTouches[0].clientY;
695
+
696
+ // Check if touch moved significantly (if so, it's a drag/pan, not a tap)
697
+ const moveDistance = Math.sqrt(
698
+ Math.pow(touchEndX - touchStartX, 2) +
699
+ Math.pow(touchEndY - touchStartY, 2)
700
+ );
701
+
702
+ // Only trigger if touch didn't move much (tap vs swipe)
703
+ if (moveDistance < 20) {
704
+ handleHouseInteraction(touchEndX, touchEndY);
705
+ }
706
+ }
707
+ }
708
+
709
+ // Common house interaction handler
710
+ function handleHouseInteraction(clientX, clientY) {
711
+ // Calculate normalized device coordinates
712
+ const rect = renderer.domElement.getBoundingClientRect();
713
+ const x = ((clientX - rect.left) / rect.width) * 2 - 1;
714
+ const y = -((clientY - rect.top) / rect.height) * 2 + 1;
715
+
716
+ mouse.x = x;
717
+ mouse.y = y;
535
718
 
536
719
  raycaster.setFromCamera(mouse, camera);
537
720
 
@@ -664,7 +847,7 @@ window.closeHouseDialog = function() {
664
847
  document.getElementById('house-dialog').classList.remove('active');
665
848
  };
666
849
 
667
- // Update house labels to show unread indicators
850
+ // Update house labels to show unread indicators and tool names
668
851
  function updateHouseLabels() {
669
852
  agentMeshes.forEach((group, name) => {
670
853
  // Check for unread messages SENT TO this agent (messages they haven't read)
@@ -681,6 +864,10 @@ function updateHouseLabels() {
681
864
  !m.read
682
865
  ).length;
683
866
 
867
+ // Get tool name for this agent
868
+ const avatarState = avatarStates[name.toLowerCase()];
869
+ const toolName = avatarState?.tool_name || null;
870
+
684
871
  // Find the sprite label
685
872
  const sprite = group.children.find(c => c.isSprite);
686
873
  if (sprite) {
@@ -689,7 +876,7 @@ function updateHouseLabels() {
689
876
  const context = canvas.getContext('2d');
690
877
  const scale = 2;
691
878
  canvas.width = 700;
692
- canvas.height = 128;
879
+ canvas.height = toolName ? 160 : 128;
693
880
  context.scale(scale, scale);
694
881
 
695
882
  // Background - change color if there are unread messages
@@ -703,7 +890,7 @@ function updateHouseLabels() {
703
890
  // Default black background
704
891
  context.fillStyle = 'rgba(0, 0, 0, 0.7)';
705
892
  }
706
- context.roundRect(0, 0, 350, 64, 16);
893
+ context.roundRect(0, 0, 350, toolName ? 80 : 64, 16);
707
894
  context.fill();
708
895
 
709
896
  // Name
@@ -714,12 +901,19 @@ function updateHouseLabels() {
714
901
 
715
902
  if (unreadFromAgent > 0) {
716
903
  // Show name with unread indicator from agent
717
- context.fillText(`${name} šŸ”“ ${unreadFromAgent}`, 175, 32);
904
+ context.fillText(`${name} šŸ”“ ${unreadFromAgent}`, 175, 24);
718
905
  } else if (unreadCount > 0) {
719
906
  // Show name with sent-but-unread count
720
- context.fillText(`${name} šŸ“¤ ${unreadCount}`, 175, 32);
907
+ context.fillText(`${name} šŸ“¤ ${unreadCount}`, 175, 24);
721
908
  } else {
722
- context.fillText(name, 175, 32);
909
+ context.fillText(name, 175, 24);
910
+ }
911
+
912
+ // Tool name text (if available)
913
+ if (toolName) {
914
+ context.font = 'italic 16px Arial';
915
+ context.fillStyle = '#FFD700'; // Gold color for tool name
916
+ context.fillText(toolName, 175, 56);
723
917
  }
724
918
 
725
919
  const texture = new THREE.CanvasTexture(canvas);
@@ -727,6 +921,10 @@ function updateHouseLabels() {
727
921
  texture.magFilter = THREE.LinearFilter;
728
922
  sprite.material.map = texture;
729
923
  sprite.material.needsUpdate = true;
924
+
925
+ // Update position and scale based on whether tool name is shown
926
+ sprite.position.set(0, toolName ? 8.5 : 8, 0);
927
+ sprite.scale.set(8, toolName ? 2.5 : 2, 1);
730
928
  }
731
929
  });
732
930
  }
package/public/index.html CHANGED
@@ -2,7 +2,7 @@
2
2
  <html lang="en">
3
3
  <head>
4
4
  <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
6
6
  <title>Watercooler</title>
7
7
  <style>
8
8
  * {
@@ -199,6 +199,23 @@
199
199
  opacity: 1;
200
200
  }
201
201
 
202
+ .mark-all-read-btn {
203
+ background: rgba(255, 255, 255, 0.1);
204
+ border: 1px solid rgba(255, 255, 255, 0.2);
205
+ color: rgba(255, 255, 255, 0.8);
206
+ font-size: 0.75rem;
207
+ padding: 4px 10px;
208
+ border-radius: 4px;
209
+ cursor: pointer;
210
+ transition: all 0.2s;
211
+ white-space: nowrap;
212
+ }
213
+
214
+ .mark-all-read-btn:hover {
215
+ background: rgba(255, 255, 255, 0.2);
216
+ color: white;
217
+ }
218
+
202
219
  .messages-container {
203
220
  flex: 1;
204
221
  overflow-y: auto;
@@ -303,7 +320,7 @@
303
320
  }
304
321
 
305
322
  .message-text li {
306
- margin: 4px 0;
323
+ margin: 2px 0;
307
324
  }
308
325
 
309
326
  /* Toggle Messages Button */
@@ -320,7 +337,7 @@
320
337
  font-size: 0.9rem;
321
338
  font-weight: 600;
322
339
  cursor: pointer;
323
- z-index: 99;
340
+ z-index: 101;
324
341
  transition: all 0.3s ease;
325
342
  box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
326
343
  }
@@ -382,6 +399,239 @@
382
399
  border-radius: 3px;
383
400
  }
384
401
 
402
+ /* Mobile responsive styles */
403
+ @media (max-width: 768px) {
404
+ /* Send panel - full width at bottom */
405
+ .send-panel {
406
+ top: auto;
407
+ bottom: 0;
408
+ left: 0;
409
+ right: 0;
410
+ width: 100%;
411
+ max-width: 100%;
412
+ border-radius: 16px 16px 0 0;
413
+ border-bottom: none;
414
+ border-left: none;
415
+ border-right: none;
416
+ box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.3);
417
+ }
418
+
419
+ .send-panel.collapsed {
420
+ width: 100%;
421
+ min-width: 100%;
422
+ transform: translateY(calc(100% - 75px));
423
+ }
424
+
425
+ .send-header {
426
+ padding: 14px 20px;
427
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
428
+ }
429
+
430
+ .send-header h2 {
431
+ font-size: 1rem;
432
+ }
433
+
434
+ .collapse-btn {
435
+ font-size: 1.4rem;
436
+ padding: 8px 16px;
437
+ }
438
+
439
+ .send-form {
440
+ padding: 20px;
441
+ max-height: 50vh;
442
+ overflow-y: auto;
443
+ }
444
+
445
+ .recipient-select, .message-input, .send-btn {
446
+ font-size: 16px; /* Prevents zoom on iOS */
447
+ padding: 14px 16px;
448
+ margin-bottom: 12px;
449
+ }
450
+
451
+ .message-input {
452
+ min-height: 80px;
453
+ }
454
+
455
+ /* Messages panel - nearly full screen on mobile */
456
+ .messages-panel {
457
+ top: auto;
458
+ bottom: 0;
459
+ left: 0;
460
+ right: 0;
461
+ width: 100%;
462
+ max-width: 100%;
463
+ max-height: 90vh;
464
+ height: 90vh;
465
+ border-radius: 16px 16px 0 0;
466
+ transform: translateY(100%);
467
+ transition: transform 0.3s ease;
468
+ display: flex;
469
+ flex-direction: column;
470
+ }
471
+
472
+ .messages-panel.open {
473
+ right: 0;
474
+ transform: translateY(0);
475
+ }
476
+
477
+ .messages-header {
478
+ padding: 20px 24px;
479
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
480
+ flex-shrink: 0;
481
+ }
482
+
483
+ .messages-header h2 {
484
+ font-size: 1.2rem;
485
+ }
486
+
487
+ .close-btn {
488
+ font-size: 1.8rem;
489
+ padding: 8px;
490
+ }
491
+
492
+ .messages-container {
493
+ padding: 20px;
494
+ flex: 1;
495
+ max-height: calc(90vh - 80px);
496
+ }
497
+
498
+ .message-card {
499
+ padding: 16px 16px 16px 24px;
500
+ margin-bottom: 12px;
501
+ }
502
+
503
+ .message-sender {
504
+ font-size: 0.9rem;
505
+ }
506
+
507
+ .message-time {
508
+ font-size: 0.75rem;
509
+ }
510
+
511
+ .message-text {
512
+ font-size: 0.9rem;
513
+ }
514
+
515
+ /* Toggle messages button - repositioned */
516
+ .toggle-messages-btn {
517
+ top: 20px;
518
+ right: 20px;
519
+ padding: 14px 18px;
520
+ font-size: 1rem;
521
+ border-radius: 30px;
522
+ z-index: 101;
523
+ }
524
+
525
+ /* Toast notification - centered */
526
+ .toast {
527
+ left: 20px;
528
+ right: 20px;
529
+ bottom: auto;
530
+ top: 50%;
531
+ transform: translateY(-50%) translateY(20px);
532
+ text-align: center;
533
+ padding: 16px 24px;
534
+ font-size: 1rem;
535
+ }
536
+
537
+ .toast.show {
538
+ transform: translateY(-50%) translateY(0);
539
+ }
540
+
541
+ /* House dialog - full screen on mobile */
542
+ .house-dialog-content {
543
+ width: 100%;
544
+ max-width: 100%;
545
+ max-height: 95vh;
546
+ height: 95vh;
547
+ border-radius: 16px 16px 0 0;
548
+ margin: 0;
549
+ position: fixed;
550
+ bottom: 0;
551
+ left: 0;
552
+ right: 0;
553
+ display: flex;
554
+ flex-direction: column;
555
+ }
556
+
557
+ .house-dialog-header {
558
+ padding: 20px 24px;
559
+ flex-shrink: 0;
560
+ }
561
+
562
+ .house-dialog-header h2 {
563
+ font-size: 1.2rem;
564
+ }
565
+
566
+ .house-dialog-tabs {
567
+ padding: 0 12px;
568
+ flex-shrink: 0;
569
+ }
570
+
571
+ .tab-btn {
572
+ padding: 16px 12px;
573
+ font-size: 0.9rem;
574
+ }
575
+
576
+ .house-dialog-body {
577
+ padding: 20px;
578
+ max-height: calc(95vh - 140px);
579
+ flex: 1;
580
+ overflow-y: auto;
581
+ }
582
+
583
+ .house-dialog-body .message-card {
584
+ padding: 16px 16px 16px 24px;
585
+ }
586
+
587
+ /* Empty state */
588
+ .empty-state {
589
+ padding: 60px 20px;
590
+ }
591
+
592
+ .empty-state p {
593
+ font-size: 1rem;
594
+ }
595
+ }
596
+
597
+ /* Extra small screens */
598
+ @media (max-width: 480px) {
599
+ .send-panel.collapsed {
600
+ transform: translateY(calc(100% - 70px));
601
+ }
602
+
603
+ .send-header {
604
+ padding: 12px 16px;
605
+ }
606
+
607
+ .send-header h2 {
608
+ font-size: 0.9rem;
609
+ }
610
+
611
+ .send-form {
612
+ padding: 16px;
613
+ }
614
+
615
+ .recipient-select, .message-input, .send-btn {
616
+ font-size: 16px;
617
+ padding: 12px 14px;
618
+ }
619
+
620
+ .toggle-messages-btn {
621
+ padding: 12px 16px;
622
+ font-size: 0.9rem;
623
+ }
624
+
625
+ .messages-panel {
626
+ max-height: 75vh;
627
+ }
628
+
629
+ .tab-btn {
630
+ font-size: 0.8rem;
631
+ padding: 12px 8px;
632
+ }
633
+ }
634
+
385
635
  /* House Dialog */
386
636
  .house-dialog {
387
637
  display: none;
@@ -392,7 +642,7 @@
392
642
  bottom: 0;
393
643
  background: rgba(0, 0, 0, 0.7);
394
644
  z-index: 300;
395
- align-items: center;
645
+ align-items: flex-end;
396
646
  justify-content: center;
397
647
  }
398
648
 
@@ -400,15 +650,43 @@
400
650
  display: flex;
401
651
  }
402
652
 
653
+ /* Desktop override for house dialog */
654
+ @media (min-width: 769px) {
655
+ .house-dialog {
656
+ align-items: center;
657
+ }
658
+
659
+ .house-dialog-content {
660
+ position: relative;
661
+ bottom: auto;
662
+ left: auto;
663
+ right: auto;
664
+ border-radius: 20px;
665
+ }
666
+ }
667
+
668
+ /* Desktop house dialog styles */
669
+ @media (min-width: 769px) {
670
+ .house-dialog-content {
671
+ background: rgba(255, 255, 255, 0.15);
672
+ backdrop-filter: blur(20px);
673
+ border-radius: 20px;
674
+ border: 1px solid rgba(255, 255, 255, 0.2);
675
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
676
+ width: 500px;
677
+ max-width: 90%;
678
+ max-height: 80vh;
679
+ display: flex;
680
+ flex-direction: column;
681
+ }
682
+ }
683
+
684
+ /* Base house dialog styles (shared) */
403
685
  .house-dialog-content {
404
686
  background: rgba(255, 255, 255, 0.15);
405
687
  backdrop-filter: blur(20px);
406
- border-radius: 20px;
407
688
  border: 1px solid rgba(255, 255, 255, 0.2);
408
689
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
409
- width: 500px;
410
- max-width: 90%;
411
- max-height: 80vh;
412
690
  display: flex;
413
691
  flex-direction: column;
414
692
  }
@@ -529,7 +807,10 @@
529
807
  <div class="messages-panel" id="messages-panel">
530
808
  <div class="messages-header">
531
809
  <h2>šŸ“Ø Message History</h2>
532
- <button class="close-btn" onclick="toggleMessagesPanel()">Ɨ</button>
810
+ <div style="display: flex; gap: 8px; align-items: center;">
811
+ <button class="mark-all-read-btn" onclick="markAllAsRead()">Mark all as read</button>
812
+ <button class="close-btn" onclick="toggleMessagesPanel()">Ɨ</button>
813
+ </div>
533
814
  </div>
534
815
  <div class="messages-container" id="messages-container">
535
816
  <div class="empty-state">
package/server.ts CHANGED
@@ -7,13 +7,15 @@ const __filename = fileURLToPath(import.meta.url);
7
7
  const __dirname = path.dirname(__filename);
8
8
 
9
9
  const app = express();
10
- const PORT = process.env.PORT || 3000;
11
10
 
12
11
  // Parse CLI args
13
12
  const args = process.argv.slice(2);
14
13
  let user: string | null = null;
15
14
  let mailboxPath: string | null = null;
16
15
  let coworkerPath: string | null = null;
16
+ let avatarPath: string | null = null;
17
+ let port: number = parseInt(process.env.PORT || '3000', 10);
18
+ let host: string = process.env.HOST || '0.0.0.0';
17
19
 
18
20
  for (let i = 0; i < args.length; i++) {
19
21
  if (args[i] === '--user' || args[i] === '-u') {
@@ -22,11 +24,18 @@ for (let i = 0; i < args.length; i++) {
22
24
  mailboxPath = args[++i];
23
25
  } else if (args[i] === '--coworkers' || args[i] === '-c') {
24
26
  coworkerPath = args[++i];
27
+ } else if (args[i] === '--avatars' || args[i] === '-a') {
28
+ avatarPath = args[++i];
29
+ } else if (args[i] === '--port' || args[i] === '-p') {
30
+ const p = parseInt(args[++i], 10);
31
+ if (!isNaN(p)) port = p;
32
+ } else if (args[i] === '--host' || args[i] === '-h') {
33
+ host = args[++i];
25
34
  }
26
35
  }
27
36
 
28
37
  if (!user || !mailboxPath) {
29
- console.error('Usage: watercooler --user <name> --mailbox <path> [--coworkers <path>]');
38
+ console.error('Usage: watercooler --user <name> --mailbox <path> [--coworkers <path>] [--avatars <path>] [--port <number>] [--host <address>]');
30
39
  process.exit(1);
31
40
  }
32
41
 
@@ -35,11 +44,15 @@ console.log(` Mailbox: ${mailboxPath}`);
35
44
  if (coworkerPath) {
36
45
  console.log(` Coworker DB: ${coworkerPath}`);
37
46
  }
38
- console.log(` URL: http://localhost:${PORT}`);
47
+ if (avatarPath) {
48
+ console.log(` Avatar DB: ${avatarPath}`);
49
+ }
50
+ console.log(` URL: http://${host}:${port}`);
39
51
 
40
52
  // Databases
41
53
  let db: Database.Database | null = null;
42
54
  let coworkerDb: Database.Database | null = null;
55
+ let avatarDb: Database.Database | null = null;
43
56
 
44
57
  try {
45
58
  db = new Database(mailboxPath);
@@ -58,6 +71,15 @@ if (coworkerPath) {
58
71
  }
59
72
  }
60
73
 
74
+ if (avatarPath) {
75
+ try {
76
+ avatarDb = new Database(avatarPath);
77
+ console.log(' Avatar DB: connected');
78
+ } catch (err: any) {
79
+ console.warn(' Avatar DB error:', err.message);
80
+ }
81
+ }
82
+
61
83
  // Helper: Check if table exists
62
84
  function tableExists(database: Database.Database | null, tableName: string): boolean {
63
85
  if (!database) return false;
@@ -225,11 +247,57 @@ app.post('/api/messages/:id/read', (req, res) => {
225
247
  }
226
248
  });
227
249
 
250
+ // API: Get avatar states (latest tool usage per coworker)
251
+ app.get('/api/avatars', (req, res) => {
252
+ try {
253
+ if (!avatarDb) {
254
+ res.json({});
255
+ return;
256
+ }
257
+
258
+ // Check if latest_tool_usage table exists
259
+ const tableCheck = avatarDb.prepare(`
260
+ SELECT name FROM sqlite_master
261
+ WHERE type='table' AND name='latest_tool_usage'
262
+ `).get();
263
+
264
+ if (!tableCheck) {
265
+ res.json({});
266
+ return;
267
+ }
268
+
269
+ // Get latest tool usage per name
270
+ const stmt = avatarDb.prepare(`
271
+ SELECT name, tool_name, timestamp
272
+ FROM latest_tool_usage
273
+ ORDER BY timestamp DESC
274
+ `);
275
+
276
+ const rows = stmt.all() as Array<{name: string; tool_name: string; timestamp: number}>;
277
+
278
+ // Build map of name -> latest tool (first occurrence is latest due to ORDER BY)
279
+ const avatarStates: Record<string, {tool_name: string; timestamp: number}> = {};
280
+ for (const row of rows) {
281
+ if (!avatarStates[row.name]) {
282
+ avatarStates[row.name] = {
283
+ tool_name: row.tool_name,
284
+ timestamp: row.timestamp
285
+ };
286
+ }
287
+ }
288
+
289
+ res.json(avatarStates);
290
+ } catch (err: any) {
291
+ console.error('Error in /api/avatars:', err.message);
292
+ res.status(500).json({ error: err.message });
293
+ }
294
+ });
295
+
228
296
  // Config endpoint
229
297
  app.get('/api/config', (req, res) => {
230
- res.json({ user, mailbox: mailboxPath, coworker: coworkerPath });
298
+ res.json({ user, mailbox: mailboxPath, coworker: coworkerPath, avatar: avatarPath });
231
299
  });
232
300
 
233
- app.listen(PORT, () => {
301
+ app.listen(port, host, () => {
234
302
  console.log('\nāœ… Watercooler running!');
235
303
  });