clay-server 2.10.0 → 2.11.0-beta.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/lib/dm.js ADDED
@@ -0,0 +1,135 @@
1
+ var fs = require("fs");
2
+ var path = require("path");
3
+ var config = require("./config");
4
+
5
+ var DM_DIR = path.join(config.CONFIG_DIR, "dm");
6
+
7
+ // Ensure dm directory exists
8
+ function ensureDmDir() {
9
+ fs.mkdirSync(DM_DIR, { recursive: true });
10
+ config.chmodSafe(DM_DIR, 0o700);
11
+ }
12
+
13
+ // Generate deterministic DM key from two user IDs (sorted, order-independent)
14
+ function dmKey(userId1, userId2) {
15
+ return [userId1, userId2].sort().join(":");
16
+ }
17
+
18
+ // File path for a DM conversation
19
+ function dmFilePath(key) {
20
+ // Replace : with _ for safe filename
21
+ return path.join(DM_DIR, key.replace(/:/g, "_") + ".jsonl");
22
+ }
23
+
24
+ // Load DM history from JSONL file
25
+ function loadHistory(key) {
26
+ var filePath = dmFilePath(key);
27
+ if (!fs.existsSync(filePath)) return [];
28
+ try {
29
+ var content = fs.readFileSync(filePath, "utf8").trim();
30
+ if (!content) return [];
31
+ var lines = content.split("\n");
32
+ var messages = [];
33
+ for (var i = 0; i < lines.length; i++) {
34
+ if (!lines[i].trim()) continue;
35
+ try {
36
+ messages.push(JSON.parse(lines[i]));
37
+ } catch (e) {
38
+ // skip malformed lines
39
+ }
40
+ }
41
+ return messages;
42
+ } catch (e) {
43
+ return [];
44
+ }
45
+ }
46
+
47
+ // Append a message to DM JSONL file
48
+ function appendMessage(key, message) {
49
+ ensureDmDir();
50
+ var filePath = dmFilePath(key);
51
+ var line = JSON.stringify(message) + "\n";
52
+ fs.appendFileSync(filePath, line);
53
+ }
54
+
55
+ // Open a DM conversation (find or create)
56
+ function openDm(userId1, userId2) {
57
+ var key = dmKey(userId1, userId2);
58
+ var history = loadHistory(key);
59
+ return { dmKey: key, messages: history };
60
+ }
61
+
62
+ // Send a DM message
63
+ function sendMessage(key, fromUserId, text) {
64
+ var message = {
65
+ type: "dm_message",
66
+ ts: Date.now(),
67
+ from: fromUserId,
68
+ text: text,
69
+ };
70
+ appendMessage(key, message);
71
+ return message;
72
+ }
73
+
74
+ // Get list of all DM conversations for a user
75
+ // Returns: [{ dmKey, otherUserId, lastMessage, lastTs }]
76
+ function getDmList(userId) {
77
+ ensureDmDir();
78
+ var files;
79
+ try {
80
+ files = fs.readdirSync(DM_DIR).filter(function (f) {
81
+ return f.endsWith(".jsonl");
82
+ });
83
+ } catch (e) {
84
+ return [];
85
+ }
86
+
87
+ var dms = [];
88
+ for (var i = 0; i < files.length; i++) {
89
+ // Reconstruct dmKey from filename (replace _ back to :)
90
+ var key = files[i].replace(".jsonl", "").replace(/_/g, ":");
91
+ var parts = key.split(":");
92
+ if (parts.length !== 2) continue;
93
+
94
+ // Check if this user is a participant
95
+ var idx = parts.indexOf(userId);
96
+ if (idx === -1) continue;
97
+
98
+ var otherUserId = parts[idx === 0 ? 1 : 0];
99
+
100
+ // Get last message
101
+ var messages = loadHistory(key);
102
+ var lastMessage = messages.length > 0 ? messages[messages.length - 1] : null;
103
+
104
+ dms.push({
105
+ dmKey: key,
106
+ otherUserId: otherUserId,
107
+ lastMessage: lastMessage ? lastMessage.text : null,
108
+ lastTs: lastMessage ? lastMessage.ts : 0,
109
+ messageCount: messages.length,
110
+ });
111
+ }
112
+
113
+ // Sort by most recent activity
114
+ dms.sort(function (a, b) {
115
+ return b.lastTs - a.lastTs;
116
+ });
117
+
118
+ return dms;
119
+ }
120
+
121
+ // Extension point: check if a user is a mate (AI persona)
122
+ // Returns false for now - will be implemented when Mates feature is added
123
+ function isMate(userId) {
124
+ return false;
125
+ }
126
+
127
+ module.exports = {
128
+ dmKey: dmKey,
129
+ openDm: openDm,
130
+ sendMessage: sendMessage,
131
+ getDmList: getDmList,
132
+ loadHistory: loadHistory,
133
+ isMate: isMate,
134
+ DM_DIR: DM_DIR,
135
+ };
package/lib/project.js CHANGED
@@ -1137,6 +1137,14 @@ function createProjectContext(opts) {
1137
1137
  }
1138
1138
 
1139
1139
  function handleMessage(ws, msg) {
1140
+ // --- DM messages (delegated to server-level handler) ---
1141
+ if (msg.type === "dm_open" || msg.type === "dm_send" || msg.type === "dm_list" || msg.type === "dm_typing") {
1142
+ if (typeof opts.onDmMessage === "function") {
1143
+ opts.onDmMessage(ws, msg);
1144
+ }
1145
+ return;
1146
+ }
1147
+
1140
1148
  if (msg.type === "push_subscribe") {
1141
1149
  if (pushModule && msg.subscription) pushModule.addSubscription(msg.subscription, msg.replaceEndpoint);
1142
1150
  return;
package/lib/public/app.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { showToast, copyToClipboard, escapeHtml } from './modules/utils.js';
2
2
  import { refreshIcons, iconHtml, randomThinkingVerb } from './modules/icons.js';
3
3
  import { renderMarkdown, highlightCodeBlocks, renderMermaidBlocks, closeMermaidModal, parseEmojis } from './modules/markdown.js';
4
- import { initSidebar, renderSessionList, handleSearchResults, updateSessionPresence, updatePageTitle, getActiveSearchQuery, buildSearchTimeline, removeSearchTimeline, populateCliSessionList, renderIconStrip, renderSidebarPresence, initIconStrip, getEmojiCategories } from './modules/sidebar.js';
4
+ import { initSidebar, renderSessionList, handleSearchResults, updateSessionPresence, updatePageTitle, getActiveSearchQuery, buildSearchTimeline, removeSearchTimeline, populateCliSessionList, renderIconStrip, renderSidebarPresence, initIconStrip, getEmojiCategories, renderUserStrip, setCurrentDmUser, updateDmBadge } from './modules/sidebar.js';
5
5
  import { initRewind, setRewindMode, showRewindModal, clearPendingRewindUuid, addRewindButton } from './modules/rewind.js';
6
6
  import { initNotifications, showDoneNotification, playDoneSound, isNotifAlertEnabled, isNotifSoundEnabled } from './modules/notifications.js';
7
7
  import { initInput, clearPendingImages, handleInputSync, autoResize, builtinCommands, sendMessage } from './modules/input.js';
@@ -49,6 +49,13 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
49
49
  var imagePreviewBar = $("image-preview-bar");
50
50
  var connectOverlay = $("connect-overlay");
51
51
 
52
+ // --- DM Mode ---
53
+ var dmMode = false;
54
+ var dmKey = null;
55
+ var dmTargetUser = null;
56
+ var dmUnread = {}; // { otherUserId: count }
57
+ var cachedAllUsers = [];
58
+
52
59
  // --- Home Hub ---
53
60
  var homeHub = $("home-hub");
54
61
  var homeHubVisible = false;
@@ -524,9 +531,232 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
524
531
  }
525
532
  }
526
533
 
534
+ // --- DM Mode Functions ---
535
+ function openDm(targetUserId) {
536
+ if (!ws || ws.readyState !== 1) return;
537
+ ws.send(JSON.stringify({ type: "dm_open", targetUserId: targetUserId }));
538
+ }
539
+
540
+ function enterDmMode(key, targetUser, messages) {
541
+ dmMode = true;
542
+ dmKey = key;
543
+ dmTargetUser = targetUser;
544
+
545
+ // Clear unread for this user
546
+ if (targetUser) {
547
+ dmUnread[targetUser.id] = 0;
548
+ updateDmBadge(targetUser.id, 0);
549
+ }
550
+
551
+ // Update icon strip active state
552
+ setCurrentDmUser(targetUser ? targetUser.id : null);
553
+ var activeProj = document.querySelector("#icon-strip-projects .icon-strip-item.active");
554
+ if (activeProj) activeProj.classList.remove("active");
555
+ var homeIcon = document.querySelector(".icon-strip-home");
556
+ if (homeIcon) homeIcon.classList.remove("active");
557
+ // Re-render user strip to show active state
558
+ if (cachedProjects && cachedProjects.length > 0) {
559
+ renderProjectList();
560
+ }
561
+
562
+ // Hide home hub if visible
563
+ hideHomeHub();
564
+
565
+ // Hide project UI + sidebar, show DM UI
566
+ var mainCol = document.getElementById("main-column");
567
+ if (mainCol) mainCol.classList.add("dm-mode");
568
+ var sidebarCol = document.getElementById("sidebar-column");
569
+ if (sidebarCol) sidebarCol.classList.add("dm-mode");
570
+ var resizeHandle = document.getElementById("sidebar-resize-handle");
571
+ if (resizeHandle) resizeHandle.classList.add("dm-mode");
572
+
573
+ // Hide user-island (my avatar behind it becomes visible)
574
+ var userIsland = document.getElementById("user-island");
575
+ if (userIsland) userIsland.classList.add("dm-hidden");
576
+
577
+ // Render DM messages
578
+ messagesEl.innerHTML = "";
579
+ if (messages && messages.length > 0) {
580
+ for (var i = 0; i < messages.length; i++) {
581
+ appendDmMessage(messages[i]);
582
+ }
583
+ }
584
+ scrollToBottom();
585
+
586
+ // Focus input
587
+ if (inputEl) {
588
+ inputEl.placeholder = "Message " + (targetUser ? targetUser.displayName : "");
589
+ inputEl.focus();
590
+ }
591
+
592
+ // Populate DM header bar with user avatar, name, and personal color
593
+ if (targetUser) {
594
+ var dmHeaderBar = document.getElementById("dm-header-bar");
595
+ var dmAvatar = document.getElementById("dm-header-avatar");
596
+ var dmName = document.getElementById("dm-header-name");
597
+ if (dmAvatar) {
598
+ dmAvatar.src = "https://api.dicebear.com/9.x/" + (targetUser.avatarStyle || "thumbs") + "/svg?seed=" + encodeURIComponent(targetUser.avatarSeed || targetUser.username) + "&size=28";
599
+ }
600
+ if (dmName) dmName.textContent = targetUser.displayName;
601
+ if (dmHeaderBar && targetUser.avatarColor) {
602
+ dmHeaderBar.style.background = targetUser.avatarColor;
603
+ }
604
+ }
605
+ }
606
+
607
+ function exitDmMode() {
608
+ if (!dmMode) return;
609
+ dmMode = false;
610
+ dmKey = null;
611
+ dmTargetUser = null;
612
+ setCurrentDmUser(null);
613
+
614
+ var mainCol = document.getElementById("main-column");
615
+ if (mainCol) mainCol.classList.remove("dm-mode");
616
+ var sidebarCol = document.getElementById("sidebar-column");
617
+ if (sidebarCol) sidebarCol.classList.remove("dm-mode");
618
+ var resizeHandle = document.getElementById("sidebar-resize-handle");
619
+ if (resizeHandle) resizeHandle.classList.remove("dm-mode");
620
+
621
+ // Reset DM header
622
+ var dmHeaderBar = document.getElementById("dm-header-bar");
623
+ if (dmHeaderBar) dmHeaderBar.style.background = "";
624
+
625
+ // Restore user-island (covers my avatar again)
626
+ var userIsland = document.getElementById("user-island");
627
+ if (userIsland) userIsland.classList.remove("dm-hidden");
628
+
629
+ // Restore project UI
630
+ if (inputEl) inputEl.placeholder = "";
631
+ renderProjectList();
632
+ }
633
+
634
+ function appendDmMessage(msg) {
635
+ var isMe = msg.from === myUserId;
636
+ var d = new Date(msg.ts);
637
+ var timeStr = d.getHours().toString().padStart(2, "0") + ":" + d.getMinutes().toString().padStart(2, "0");
638
+
639
+ // Check if we can compact (same sender as previous, within 5 min)
640
+ var prev = messagesEl.lastElementChild;
641
+ var compact = false;
642
+ if (prev && prev.dataset.from === msg.from) {
643
+ var prevTs = parseInt(prev.dataset.ts || "0", 10);
644
+ if (msg.ts - prevTs < 300000) compact = true;
645
+ }
646
+
647
+ var div = document.createElement("div");
648
+ div.className = "dm-msg" + (compact ? " dm-msg-compact" : "");
649
+ div.dataset.from = msg.from;
650
+ div.dataset.ts = msg.ts;
651
+
652
+ if (compact) {
653
+ // Compact: just hover-time + text, no avatar/name
654
+ var hoverTime = document.createElement("span");
655
+ hoverTime.className = "dm-msg-hover-time";
656
+ hoverTime.textContent = timeStr;
657
+ div.appendChild(hoverTime);
658
+
659
+ var body = document.createElement("div");
660
+ body.className = "dm-msg-body";
661
+ body.textContent = msg.text;
662
+ div.appendChild(body);
663
+ } else {
664
+ // Full: avatar + header(name, time) + text
665
+ var avatar = document.createElement("img");
666
+ avatar.className = "dm-msg-avatar";
667
+ if (isMe) {
668
+ var myUser = cachedAllUsers.find(function (u) { return u.id === myUserId; });
669
+ var myStyle = myUser ? myUser.avatarStyle : "thumbs";
670
+ var mySeed = myUser ? (myUser.avatarSeed || myUser.username) : myUserId;
671
+ avatar.src = "https://api.dicebear.com/9.x/" + (myStyle || "thumbs") + "/svg?seed=" + encodeURIComponent(mySeed) + "&size=36";
672
+ } else if (dmTargetUser) {
673
+ avatar.src = "https://api.dicebear.com/9.x/" + (dmTargetUser.avatarStyle || "thumbs") + "/svg?seed=" + encodeURIComponent(dmTargetUser.avatarSeed || dmTargetUser.username) + "&size=36";
674
+ }
675
+ div.appendChild(avatar);
676
+
677
+ var content = document.createElement("div");
678
+ content.className = "dm-msg-content";
679
+
680
+ var header = document.createElement("div");
681
+ header.className = "dm-msg-header";
682
+
683
+ var name = document.createElement("span");
684
+ name.className = "dm-msg-name";
685
+ if (isMe) {
686
+ var mu = cachedAllUsers.find(function (u) { return u.id === myUserId; });
687
+ name.textContent = mu ? mu.displayName : "Me";
688
+ } else {
689
+ name.textContent = dmTargetUser ? dmTargetUser.displayName : "User";
690
+ }
691
+ header.appendChild(name);
692
+
693
+ var time = document.createElement("span");
694
+ time.className = "dm-msg-time";
695
+ time.textContent = timeStr;
696
+ header.appendChild(time);
697
+
698
+ content.appendChild(header);
699
+
700
+ var body = document.createElement("div");
701
+ body.className = "dm-msg-body";
702
+ body.textContent = msg.text;
703
+ content.appendChild(body);
704
+
705
+ div.appendChild(content);
706
+ }
707
+
708
+ messagesEl.appendChild(div);
709
+ }
710
+
711
+ var dmTypingTimer = null;
712
+
713
+ function showDmTypingIndicator(typing) {
714
+ var existing = document.getElementById("dm-typing-indicator");
715
+ if (!typing) {
716
+ if (existing) existing.remove();
717
+ return;
718
+ }
719
+ if (existing) return; // already showing
720
+ if (!dmTargetUser) return;
721
+
722
+ var div = document.createElement("div");
723
+ div.id = "dm-typing-indicator";
724
+ div.className = "dm-msg dm-typing-indicator";
725
+
726
+ var avatar = document.createElement("img");
727
+ avatar.className = "dm-msg-avatar";
728
+ avatar.src = "https://api.dicebear.com/9.x/" + (dmTargetUser.avatarStyle || "thumbs") + "/svg?seed=" + encodeURIComponent(dmTargetUser.avatarSeed || dmTargetUser.username) + "&size=36";
729
+ div.appendChild(avatar);
730
+
731
+ var dots = document.createElement("div");
732
+ dots.className = "dm-typing-dots";
733
+ dots.innerHTML = "<span></span><span></span><span></span>";
734
+ div.appendChild(dots);
735
+
736
+ messagesEl.appendChild(div);
737
+ scrollToBottom();
738
+
739
+ // Auto-hide after 5s in case stop signal is missed
740
+ clearTimeout(dmTypingTimer);
741
+ dmTypingTimer = setTimeout(function () {
742
+ showDmTypingIndicator(false);
743
+ }, 5000);
744
+ }
745
+
746
+ function handleDmSend() {
747
+ if (!dmMode || !dmKey || !inputEl) return false;
748
+ var text = inputEl.value.trim();
749
+ if (!text) return false;
750
+ ws.send(JSON.stringify({ type: "dm_send", dmKey: dmKey, text: text }));
751
+ inputEl.value = "";
752
+ autoResize();
753
+ return true;
754
+ }
755
+
527
756
  var hubCloseBtn = document.getElementById("home-hub-close");
528
757
 
529
758
  function showHomeHub() {
759
+ if (dmMode) exitDmMode();
530
760
  homeHubVisible = true;
531
761
  homeHub.classList.remove("hidden");
532
762
  // Show close button only if there's a project to return to
@@ -611,6 +841,23 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
611
841
  if (msg.serverUsers) {
612
842
  renderTopbarPresence(msg.serverUsers);
613
843
  }
844
+ // Update user strip (DM targets) in icon strip
845
+ if (msg.allUsers) {
846
+ cachedAllUsers = msg.allUsers;
847
+ var onlineIds = (msg.serverUsers || []).map(function (u) { return u.id; });
848
+ renderUserStrip(msg.allUsers, onlineIds, myUserId);
849
+ // Render my avatar (always present, hidden behind user-island)
850
+ var meEl = document.getElementById("icon-strip-me");
851
+ if (meEl && !meEl.hasChildNodes()) {
852
+ var myUser = cachedAllUsers.find(function (u) { return u.id === myUserId; });
853
+ if (myUser) {
854
+ var meAvatar = document.createElement("img");
855
+ meAvatar.className = "icon-strip-me-avatar";
856
+ meAvatar.src = "https://api.dicebear.com/9.x/" + (myUser.avatarStyle || "thumbs") + "/svg?seed=" + encodeURIComponent(myUser.avatarSeed || myUser.username) + "&size=34";
857
+ meEl.appendChild(meAvatar);
858
+ }
859
+ }
860
+ }
614
861
  }
615
862
 
616
863
  function renderTopbarPresence(serverUsers) {
@@ -972,6 +1219,7 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
972
1219
  getUpcomingSchedules: getUpcomingSchedules,
973
1220
  get multiUser() { return isMultiUserMode; },
974
1221
  get myUserId() { return myUserId; },
1222
+ openDm: function (userId) { openDm(userId); },
975
1223
  };
976
1224
  initSidebar(sidebarCtx);
977
1225
  initIconStrip(sidebarCtx);
@@ -2358,6 +2606,7 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
2358
2606
  // --- Project switching (no full reload) ---
2359
2607
  function switchProject(slug) {
2360
2608
  if (!slug) return;
2609
+ if (dmMode) exitDmMode();
2361
2610
  if (homeHubVisible) {
2362
2611
  hideHomeHub();
2363
2612
  if (slug === currentSlug) return;
@@ -2724,7 +2973,7 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
2724
2973
  break;
2725
2974
 
2726
2975
  case "input_sync":
2727
- handleInputSync(msg.text);
2976
+ if (!dmMode) handleInputSync(msg.text);
2728
2977
  break;
2729
2978
 
2730
2979
  case "session_list":
@@ -3184,6 +3433,36 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
3184
3433
  updateProjectList(msg);
3185
3434
  break;
3186
3435
 
3436
+ // --- DM ---
3437
+ case "dm_history":
3438
+ enterDmMode(msg.dmKey, msg.targetUser, msg.messages);
3439
+ break;
3440
+
3441
+ case "dm_message":
3442
+ if (dmMode && msg.dmKey === dmKey) {
3443
+ showDmTypingIndicator(false); // hide typing when message arrives
3444
+ appendDmMessage(msg.message);
3445
+ scrollToBottom();
3446
+ } else if (msg.message) {
3447
+ // DM notification when not in that DM
3448
+ var fromId = msg.message.from;
3449
+ if (fromId && fromId !== myUserId) {
3450
+ dmUnread[fromId] = (dmUnread[fromId] || 0) + 1;
3451
+ updateDmBadge(fromId, dmUnread[fromId]);
3452
+ }
3453
+ }
3454
+ break;
3455
+
3456
+ case "dm_typing":
3457
+ if (dmMode && msg.dmKey === dmKey) {
3458
+ showDmTypingIndicator(msg.typing);
3459
+ }
3460
+ break;
3461
+
3462
+ case "dm_list":
3463
+ // Could be used for DM list view later
3464
+ break;
3465
+
3187
3466
  case "daemon_config":
3188
3467
  updateDaemonConfig(msg.config);
3189
3468
  break;
@@ -3488,6 +3767,9 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
3488
3767
  showImageModal: showImageModal,
3489
3768
  hideSuggestionChips: hideSuggestionChips,
3490
3769
  setSendBtnMode: setSendBtnMode,
3770
+ isDmMode: function () { return dmMode; },
3771
+ getDmKey: function () { return dmKey; },
3772
+ handleDmSend: function () { handleDmSend(); },
3491
3773
  });
3492
3774
 
3493
3775
  // --- STT module (voice input via Web Speech API) ---
@@ -3,13 +3,14 @@
3
3
  ========================================================================== */
4
4
 
5
5
  #icon-strip {
6
+ position: relative;
6
7
  width: 72px;
7
8
  flex-shrink: 0;
8
9
  background: var(--sidebar-bg);
9
10
  display: flex;
10
11
  flex-direction: column;
11
12
  align-items: center;
12
- padding: 0 0 58px;
13
+ padding: 0 0 80px;
13
14
  overflow: hidden;
14
15
  z-index: 2;
15
16
  }
@@ -114,6 +115,7 @@
114
115
  /* --- Scrollable project list area --- */
115
116
  .icon-strip-projects {
116
117
  width: 100%;
118
+ min-height: 0;
117
119
  overflow-y: auto;
118
120
  overflow-x: hidden;
119
121
  display: flex;
@@ -605,6 +607,165 @@
605
607
  height: 22px;
606
608
  }
607
609
 
610
+ /* --- User avatars (circle, above my-avatar at bottom) --- */
611
+ .icon-strip-users {
612
+ position: absolute;
613
+ bottom: 74px; /* above user-island (58px + 8px bottom + 8px gap) */
614
+ left: 50%;
615
+ transform: translateX(-50%);
616
+ width: 56px;
617
+ display: flex;
618
+ flex-direction: column;
619
+ align-items: center;
620
+ gap: 0;
621
+ padding: 6px 0;
622
+ background: var(--bg-alt);
623
+ border-radius: 8px;
624
+ }
625
+
626
+ .icon-strip-users.hidden {
627
+ display: none;
628
+ }
629
+
630
+ /* "Invite" mini button at bottom of user strip */
631
+ .icon-strip-invite {
632
+ position: relative;
633
+ width: 40px;
634
+ height: 32px;
635
+ border-radius: 10px;
636
+ background: transparent;
637
+ border: none;
638
+ display: flex;
639
+ align-items: center;
640
+ justify-content: center;
641
+ cursor: pointer;
642
+ flex-shrink: 0;
643
+ color: var(--text-dimmer);
644
+ transition: color 0.2s, background 0.2s;
645
+ }
646
+
647
+ .icon-strip-invite:hover {
648
+ color: var(--accent);
649
+ background: rgba(var(--overlay-rgb), 0.08);
650
+ }
651
+
652
+ .icon-strip-invite .lucide {
653
+ width: 16px;
654
+ height: 16px;
655
+ }
656
+
657
+ .icon-strip-user {
658
+ position: relative;
659
+ width: 42px;
660
+ height: 42px;
661
+ border-radius: 0;
662
+ background: transparent;
663
+ display: flex;
664
+ align-items: center;
665
+ justify-content: center;
666
+ cursor: pointer;
667
+ flex-shrink: 0;
668
+ }
669
+
670
+ .icon-strip-user-avatar {
671
+ width: 34px;
672
+ height: 34px;
673
+ border-radius: 50%;
674
+ object-fit: cover;
675
+ transition: opacity 0.2s ease, transform 0.2s ease;
676
+ opacity: 0.7;
677
+ }
678
+
679
+ .icon-strip-user:hover .icon-strip-user-avatar {
680
+ opacity: 1;
681
+ transform: scale(1.08);
682
+ }
683
+
684
+ .icon-strip-user.active .icon-strip-user-avatar {
685
+ opacity: 1;
686
+ box-shadow: 0 0 0 2px var(--accent);
687
+ }
688
+
689
+ /* Online status dot (bottom-right on avatar, like project blink dot) */
690
+ .icon-strip-user-online {
691
+ position: absolute;
692
+ bottom: 5px;
693
+ right: 5px;
694
+ width: 10px;
695
+ height: 10px;
696
+ border-radius: 50%;
697
+ background: var(--text-dimmer);
698
+ border: 2px solid var(--sidebar-bg);
699
+ opacity: 1;
700
+ transition: opacity 0.2s, background 0.3s;
701
+ }
702
+
703
+ .icon-strip-user.online .icon-strip-user-online {
704
+ background: var(--success);
705
+ }
706
+
707
+ /* DM unread badge */
708
+ .icon-strip-user-badge {
709
+ position: absolute;
710
+ top: -2px;
711
+ right: -2px;
712
+ min-width: 18px;
713
+ height: 18px;
714
+ border-radius: 9px;
715
+ background: #e74c3c;
716
+ color: #fff;
717
+ font-size: 11px;
718
+ font-weight: 700;
719
+ display: none;
720
+ align-items: center;
721
+ justify-content: center;
722
+ padding: 0 5px;
723
+ line-height: 18px;
724
+ text-align: center;
725
+ box-shadow: 0 0 0 2px var(--bg);
726
+ z-index: 2;
727
+ }
728
+
729
+ .icon-strip-user-badge.has-unread {
730
+ display: flex;
731
+ }
732
+
733
+ /* Pill for user icons */
734
+ .icon-strip-user:hover .icon-strip-pill {
735
+ opacity: 1;
736
+ height: 20px;
737
+ }
738
+
739
+ .icon-strip-user.active .icon-strip-pill {
740
+ opacity: 1;
741
+ height: 32px;
742
+ }
743
+
744
+ /* DM mode: keep same position (user-island gone but layout stays) */
745
+
746
+ /* --- My avatar at bottom of icon strip (always present, behind user-island) --- */
747
+ /* Positioned to align exactly with user-island avatar (left:8px + padding:6px + 18px center = 32px from left edge of layout-body) */
748
+ /* icon-strip is 72px wide, so 32px from left = left offset within icon-strip */
749
+ .icon-strip-me {
750
+ position: absolute;
751
+ bottom: 19px; /* vertically center with user-island (bottom:8px + height:58px/2 - 16px) */
752
+ left: 32px;
753
+ transform: translateX(-50%);
754
+ display: flex;
755
+ align-items: center;
756
+ justify-content: center;
757
+ width: 32px;
758
+ height: 32px;
759
+ z-index: 1; /* below user-island z-index: 15 */
760
+ }
761
+
762
+ .icon-strip-me-avatar {
763
+ width: 32px;
764
+ height: 32px;
765
+ border-radius: 50%;
766
+ object-fit: cover;
767
+ }
768
+
608
769
  /* --- Per-project presence avatars --- */
609
770
  /* --- Mobile: hide icon strip --- */
610
771
  @media (max-width: 768px) {
@@ -1465,3 +1465,177 @@ pre.mermaid-error {
1465
1465
 
1466
1466
  /* Rate limit inline cards removed — now header-only with popover (see title-bar.css) */
1467
1467
 
1468
+ /* ==========================================================================
1469
+ DM Messages (P2P chat)
1470
+ ========================================================================== */
1471
+
1472
+ /* --- Slack-style DM messages --- */
1473
+
1474
+ .dm-msg {
1475
+ display: flex;
1476
+ align-items: flex-start;
1477
+ gap: 8px;
1478
+ padding: 4px 16px;
1479
+ }
1480
+
1481
+ .dm-msg:hover {
1482
+ background: var(--bg-alt);
1483
+ }
1484
+
1485
+ .dm-msg-avatar {
1486
+ width: 36px;
1487
+ height: 36px;
1488
+ border-radius: 6px;
1489
+ flex-shrink: 0;
1490
+ margin-top: 2px;
1491
+ }
1492
+
1493
+ .dm-msg-content {
1494
+ flex: 1;
1495
+ min-width: 0;
1496
+ }
1497
+
1498
+ .dm-msg-header {
1499
+ display: flex;
1500
+ align-items: baseline;
1501
+ gap: 8px;
1502
+ }
1503
+
1504
+ .dm-msg-name {
1505
+ font-weight: 700;
1506
+ font-size: 15px;
1507
+ color: var(--text);
1508
+ }
1509
+
1510
+ .dm-msg-time {
1511
+ font-size: 12px;
1512
+ color: var(--text-dimmer);
1513
+ }
1514
+
1515
+ .dm-msg-body {
1516
+ font-size: 15px;
1517
+ line-height: 1.46;
1518
+ color: var(--text);
1519
+ word-wrap: break-word;
1520
+ white-space: pre-wrap;
1521
+ }
1522
+
1523
+ /* Compact: same sender within 5 min */
1524
+ .dm-msg-compact {
1525
+ padding: 1px 16px 1px 60px; /* 16 + 36 avatar + 8 gap = 60 */
1526
+ position: relative;
1527
+ }
1528
+
1529
+ .dm-msg-compact:hover .dm-msg-hover-time {
1530
+ opacity: 1;
1531
+ }
1532
+
1533
+ .dm-msg-hover-time {
1534
+ position: absolute;
1535
+ left: 16px;
1536
+ width: 36px;
1537
+ text-align: center;
1538
+ font-size: 11px;
1539
+ color: var(--text-dimmer);
1540
+ opacity: 0;
1541
+ transition: opacity 0.1s;
1542
+ line-height: 1.46;
1543
+ }
1544
+
1545
+ /* Typing indicator */
1546
+ .dm-typing-indicator {
1547
+ align-items: center;
1548
+ gap: 8px;
1549
+ padding: 4px 16px;
1550
+ }
1551
+
1552
+ .dm-typing-indicator .dm-msg-avatar {
1553
+ opacity: 0.7;
1554
+ }
1555
+
1556
+ .dm-typing-dots {
1557
+ display: flex;
1558
+ align-items: center;
1559
+ gap: 4px;
1560
+ padding: 8px 12px;
1561
+ background: var(--bg-alt);
1562
+ border-radius: 12px;
1563
+ }
1564
+
1565
+ .dm-typing-dots span {
1566
+ width: 7px;
1567
+ height: 7px;
1568
+ border-radius: 50%;
1569
+ background: var(--text-dimmer);
1570
+ animation: dm-typing-bounce 1.4s infinite ease-in-out both;
1571
+ }
1572
+
1573
+ .dm-typing-dots span:nth-child(1) { animation-delay: 0s; }
1574
+ .dm-typing-dots span:nth-child(2) { animation-delay: 0.2s; }
1575
+ .dm-typing-dots span:nth-child(3) { animation-delay: 0.4s; }
1576
+
1577
+ @keyframes dm-typing-bounce {
1578
+ 0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
1579
+ 40% { transform: scale(1); opacity: 1; }
1580
+ }
1581
+
1582
+ /* DM mode: hide project-specific UI elements */
1583
+ #main-column.dm-mode .msg-group,
1584
+ #main-column.dm-mode #suggestion-chips,
1585
+ #main-column.dm-mode .tool-group {
1586
+ display: none;
1587
+ }
1588
+
1589
+ /* DM mode: simplify input bar (no voice, no model config, no context, no slash menu) */
1590
+ #main-column.dm-mode #stt-btn,
1591
+ #main-column.dm-mode #config-chip-wrap,
1592
+ #main-column.dm-mode #context-mini,
1593
+ #main-column.dm-mode #slash-menu {
1594
+ display: none !important;
1595
+ }
1596
+
1597
+ /* DM mode: hide sidebar column and resize handle */
1598
+ #sidebar-column.dm-mode {
1599
+ display: none !important;
1600
+ }
1601
+
1602
+ #sidebar-resize-handle.dm-mode {
1603
+ display: none !important;
1604
+ }
1605
+
1606
+ /* DM header bar: replaces normal title-bar-content */
1607
+ .dm-header-bar {
1608
+ display: none;
1609
+ align-items: center;
1610
+ gap: 12px;
1611
+ padding: 0 16px;
1612
+ height: 48px;
1613
+ flex-shrink: 0;
1614
+ }
1615
+
1616
+ #main-column.dm-mode .title-bar-content {
1617
+ display: none;
1618
+ }
1619
+
1620
+ #main-column.dm-mode .dm-header-bar {
1621
+ display: flex;
1622
+ }
1623
+
1624
+ .dm-header-avatar {
1625
+ width: 28px;
1626
+ height: 28px;
1627
+ border-radius: 50%;
1628
+ object-fit: cover;
1629
+ flex-shrink: 0;
1630
+ }
1631
+
1632
+ .dm-header-name {
1633
+ font-size: 15px;
1634
+ font-weight: 700;
1635
+ color: #fff;
1636
+ white-space: nowrap;
1637
+ overflow: hidden;
1638
+ text-overflow: ellipsis;
1639
+ }
1640
+
1641
+
@@ -848,6 +848,10 @@
848
848
  border-radius: 10px;
849
849
  }
850
850
 
851
+ #user-island.dm-hidden {
852
+ display: none;
853
+ }
854
+
851
855
  #user-island::before {
852
856
  content: "";
853
857
  position: absolute;
@@ -68,6 +68,8 @@
68
68
  <div class="icon-strip-separator"></div>
69
69
  <div class="icon-strip-projects" id="icon-strip-projects"></div>
70
70
  <button class="icon-strip-add" id="icon-strip-add" title="Add project"><i data-lucide="plus"></i></button>
71
+ <div class="icon-strip-users hidden" id="icon-strip-users"></div>
72
+ <div class="icon-strip-me" id="icon-strip-me"></div>
71
73
  </div>
72
74
 
73
75
  <!-- === Main Area (sidebar + resize-handle + main-column) === -->
@@ -191,6 +193,10 @@
191
193
  <div id="sidebar-resize-handle"></div>
192
194
  <div id="sidebar-overlay"></div>
193
195
  <div id="main-column">
196
+ <div class="dm-header-bar" id="dm-header-bar">
197
+ <img id="dm-header-avatar" class="dm-header-avatar" alt="">
198
+ <span id="dm-header-name" class="dm-header-name"></span>
199
+ </div>
194
200
  <div class="title-bar-content">
195
201
  <div id="header-left">
196
202
  <button id="sidebar-expand-btn" title="Open sidebar"><i data-lucide="panel-left-open"></i></button>
@@ -23,6 +23,11 @@ export var builtinCommands = [
23
23
 
24
24
  // --- Send ---
25
25
  export function sendMessage() {
26
+ // DM mode intercept: if in DM mode, route to DM handler instead
27
+ if (ctx.isDmMode && ctx.isDmMode() && ctx.handleDmSend) {
28
+ ctx.handleDmSend();
29
+ return;
30
+ }
26
31
  var text = ctx.inputEl.value.trim();
27
32
  var images = pendingImages.slice();
28
33
  if (!text && images.length === 0 && pendingPastes.length === 0 && pendingFiles.length === 0) return;
@@ -443,9 +448,15 @@ function updateSlashHighlight() {
443
448
  // --- Input sync across devices ---
444
449
  function sendInputSync() {
445
450
  if (isRemoteInput) return;
446
- if (ctx.ws && ctx.connected) {
447
- ctx.ws.send(JSON.stringify({ type: "input_sync", text: ctx.inputEl.value }));
451
+ if (!ctx.ws || !ctx.connected) return;
452
+ // In DM mode, send typing indicator instead of input_sync
453
+ if (ctx.isDmMode && ctx.isDmMode()) {
454
+ var hasText = ctx.inputEl.value.length > 0;
455
+ var dk = ctx.getDmKey ? ctx.getDmKey() : null;
456
+ if (dk) ctx.ws.send(JSON.stringify({ type: "dm_typing", dmKey: dk, typing: hasText }));
457
+ return;
448
458
  }
459
+ ctx.ws.send(JSON.stringify({ type: "input_sync", text: ctx.inputEl.value }));
449
460
  }
450
461
 
451
462
  export function handleInputSync(text) {
@@ -2087,7 +2087,8 @@ export function renderIconStrip(projects, currentSlug) {
2087
2087
  for (var i = 0; i < projects.length; i++) {
2088
2088
  var p = projects[i];
2089
2089
  var el = document.createElement("a");
2090
- el.className = "icon-strip-item" + (p.slug === currentSlug ? " active" : "");
2090
+ var isActive = p.slug === currentSlug && !currentDmUserId;
2091
+ el.className = "icon-strip-item" + (isActive ? " active" : "");
2091
2092
  el.href = "/p/" + p.slug + "/";
2092
2093
  el.dataset.slug = p.slug;
2093
2094
 
@@ -2144,7 +2145,7 @@ export function renderIconStrip(projects, currentSlug) {
2144
2145
  // Update home icon active state
2145
2146
  var homeIcon = document.querySelector(".icon-strip-home");
2146
2147
  if (homeIcon) {
2147
- if (!currentSlug || projects.length === 0) {
2148
+ if ((!currentSlug || projects.length === 0) && !currentDmUserId) {
2148
2149
  homeIcon.classList.add("active");
2149
2150
  } else {
2150
2151
  homeIcon.classList.remove("active");
@@ -2193,11 +2194,112 @@ function renderProjectList(projects, currentSlug) {
2193
2194
 
2194
2195
  export function getEmojiCategories() { return EMOJI_CATEGORIES; }
2195
2196
 
2197
+ // --- User strip (DM targets) ---
2198
+ var cachedAllUsers = [];
2199
+ var cachedOnlineUserIds = [];
2200
+ var currentDmUserId = null;
2201
+
2202
+ export function renderUserStrip(allUsers, onlineUserIds, myUserId) {
2203
+ cachedAllUsers = allUsers || [];
2204
+ cachedOnlineUserIds = onlineUserIds || [];
2205
+ var container = document.getElementById("icon-strip-users");
2206
+ if (!container) return;
2207
+
2208
+ // Filter out self, only show other users
2209
+ var others = cachedAllUsers.filter(function (u) { return u.id !== myUserId; });
2210
+
2211
+ // Hide section if no other users (single-user mode or alone)
2212
+ if (others.length === 0) {
2213
+ container.innerHTML = "";
2214
+ container.classList.add("hidden");
2215
+ return;
2216
+ }
2217
+
2218
+ container.classList.remove("hidden");
2219
+ container.innerHTML = "";
2220
+
2221
+ for (var i = 0; i < others.length; i++) {
2222
+ (function (u) {
2223
+ var el = document.createElement("div");
2224
+ el.className = "icon-strip-user";
2225
+ el.dataset.userId = u.id;
2226
+ if (u.id === currentDmUserId) el.classList.add("active");
2227
+ if (onlineUserIds.indexOf(u.id) !== -1) el.classList.add("online");
2228
+
2229
+ var pill = document.createElement("span");
2230
+ pill.className = "icon-strip-pill";
2231
+ el.appendChild(pill);
2232
+
2233
+ var avatar = document.createElement("img");
2234
+ avatar.className = "icon-strip-user-avatar";
2235
+ avatar.src = "https://api.dicebear.com/9.x/" + (u.avatarStyle || "thumbs") + "/svg?seed=" + encodeURIComponent(u.avatarSeed || u.username) + "&size=34";
2236
+ avatar.alt = u.displayName;
2237
+ el.appendChild(avatar);
2238
+
2239
+ var onlineDot = document.createElement("span");
2240
+ onlineDot.className = "icon-strip-user-online";
2241
+ el.appendChild(onlineDot);
2242
+
2243
+ var badge = document.createElement("span");
2244
+ badge.className = "icon-strip-user-badge";
2245
+ badge.dataset.userId = u.id;
2246
+ el.appendChild(badge);
2247
+
2248
+ // Tooltip
2249
+ el.addEventListener("mouseenter", function () { showIconTooltip(el, u.displayName); });
2250
+ el.addEventListener("mouseleave", hideIconTooltip);
2251
+
2252
+ // Click: open DM
2253
+ el.addEventListener("click", function () {
2254
+ if (ctx.openDm) ctx.openDm(u.id);
2255
+ });
2256
+
2257
+ container.appendChild(el);
2258
+ })(others[i]);
2259
+ }
2260
+
2261
+ // Invite button at bottom of user strip
2262
+ var inviteBtn = document.createElement("button");
2263
+ inviteBtn.className = "icon-strip-invite";
2264
+ inviteBtn.innerHTML = iconHtml("user-plus");
2265
+ inviteBtn.addEventListener("click", function () { triggerShare(); });
2266
+ inviteBtn.addEventListener("mouseenter", function () { showIconTooltip(inviteBtn, "Invite"); });
2267
+ inviteBtn.addEventListener("mouseleave", hideIconTooltip);
2268
+ container.appendChild(inviteBtn);
2269
+ refreshIcons();
2270
+ }
2271
+
2272
+ export function setCurrentDmUser(userId) {
2273
+ currentDmUserId = userId;
2274
+ // Update active state on user icons immediately
2275
+ var container = document.getElementById("icon-strip-users");
2276
+ if (!container) return;
2277
+ var items = container.querySelectorAll(".icon-strip-user");
2278
+ for (var i = 0; i < items.length; i++) {
2279
+ if (items[i].dataset.userId === userId) {
2280
+ items[i].classList.add("active");
2281
+ } else {
2282
+ items[i].classList.remove("active");
2283
+ }
2284
+ }
2285
+ }
2286
+
2287
+ export function updateDmBadge(userId, count) {
2288
+ var badge = document.querySelector('.icon-strip-user-badge[data-user-id="' + userId + '"]');
2289
+ if (!badge) return;
2290
+ if (count > 0) {
2291
+ badge.textContent = count > 99 ? "99+" : String(count);
2292
+ badge.classList.add("has-unread");
2293
+ } else {
2294
+ badge.textContent = "";
2295
+ badge.classList.remove("has-unread");
2296
+ }
2297
+ }
2298
+
2196
2299
  export function initIconStrip(_ctx) {
2197
2300
  var addBtn = document.getElementById("icon-strip-add");
2198
2301
  if (addBtn) {
2199
2302
  addBtn.addEventListener("click", function () {
2200
- // Reuse existing add-project modal
2201
2303
  var modal = _ctx.$("add-project-modal");
2202
2304
  if (modal) modal.classList.remove("hidden");
2203
2305
  });
package/lib/server.js CHANGED
@@ -7,6 +7,7 @@ var { pinPageHtml, setupPageHtml, adminSetupPageHtml, multiUserLoginPageHtml, sm
7
7
  var smtp = require("./smtp");
8
8
  var { createProjectContext } = require("./project");
9
9
  var users = require("./users");
10
+ var dm = require("./dm");
10
11
 
11
12
  var { CONFIG_DIR } = require("./config");
12
13
 
@@ -1894,12 +1895,100 @@ function createServer(opts) {
1894
1895
  onSetPin: onSetPin,
1895
1896
  onSetKeepAwake: onSetKeepAwake,
1896
1897
  onShutdown: onShutdown,
1898
+ onDmMessage: handleDmMessage,
1897
1899
  });
1898
1900
  projects.set(slug, ctx);
1899
1901
  ctx.warmup();
1900
1902
  return true;
1901
1903
  }
1902
1904
 
1905
+ // --- DM message handler (server-level, cross-project) ---
1906
+ function handleDmMessage(ws, msg) {
1907
+ if (!users.isMultiUser() || !ws._clayUser) return;
1908
+ var userId = ws._clayUser.id;
1909
+
1910
+ if (msg.type === "dm_list") {
1911
+ var dmList = dm.getDmList(userId);
1912
+ // Enrich with user info
1913
+ for (var i = 0; i < dmList.length; i++) {
1914
+ var otherUser = users.findUserById(dmList[i].otherUserId);
1915
+ if (otherUser) {
1916
+ var p = otherUser.profile || {};
1917
+ dmList[i].otherUser = {
1918
+ id: otherUser.id,
1919
+ displayName: p.name || otherUser.displayName || otherUser.username,
1920
+ username: otherUser.username,
1921
+ avatarStyle: p.avatarStyle || "thumbs",
1922
+ avatarSeed: p.avatarSeed || otherUser.username,
1923
+ avatarColor: p.avatarColor || "#7c3aed",
1924
+ };
1925
+ }
1926
+ }
1927
+ ws.send(JSON.stringify({ type: "dm_list", dms: dmList }));
1928
+ return;
1929
+ }
1930
+
1931
+ if (msg.type === "dm_open") {
1932
+ if (!msg.targetUserId) return;
1933
+ var result = dm.openDm(userId, msg.targetUserId);
1934
+ var targetUser = users.findUserById(msg.targetUserId);
1935
+ var tp = targetUser ? (targetUser.profile || {}) : {};
1936
+ ws.send(JSON.stringify({
1937
+ type: "dm_history",
1938
+ dmKey: result.dmKey,
1939
+ messages: result.messages,
1940
+ targetUser: targetUser ? {
1941
+ id: targetUser.id,
1942
+ displayName: tp.name || targetUser.displayName || targetUser.username,
1943
+ username: targetUser.username,
1944
+ avatarStyle: tp.avatarStyle || "thumbs",
1945
+ avatarSeed: tp.avatarSeed || targetUser.username,
1946
+ avatarColor: tp.avatarColor || "#7c3aed",
1947
+ } : null,
1948
+ }));
1949
+ return;
1950
+ }
1951
+
1952
+ if (msg.type === "dm_typing") {
1953
+ // Relay typing indicator to DM partner
1954
+ var dmKey = msg.dmKey;
1955
+ if (!dmKey) return;
1956
+ var parts = dmKey.split(":");
1957
+ if (parts.indexOf(userId) === -1) return;
1958
+ var targetId = parts[0] === userId ? parts[1] : parts[0];
1959
+ projects.forEach(function (ctx) {
1960
+ ctx.forEachClient(function (otherWs) {
1961
+ if (otherWs === ws) return;
1962
+ if (!otherWs._clayUser || otherWs._clayUser.id !== targetId) return;
1963
+ if (otherWs.readyState !== 1) return;
1964
+ otherWs.send(JSON.stringify({ type: "dm_typing", dmKey: dmKey, userId: userId, typing: !!msg.typing }));
1965
+ });
1966
+ });
1967
+ return;
1968
+ }
1969
+
1970
+ if (msg.type === "dm_send") {
1971
+ if (!msg.dmKey || !msg.text) return;
1972
+ // Verify sender is a participant
1973
+ var parts = msg.dmKey.split(":");
1974
+ if (parts.indexOf(userId) === -1) return;
1975
+ var message = dm.sendMessage(msg.dmKey, userId, msg.text);
1976
+ // Send confirmation to sender
1977
+ ws.send(JSON.stringify({ type: "dm_message", dmKey: msg.dmKey, message: message }));
1978
+ // Broadcast to target user's connections across all projects
1979
+ var targetId = parts[0] === userId ? parts[1] : parts[0];
1980
+ projects.forEach(function (ctx) {
1981
+ ctx.forEachClient(function (otherWs) {
1982
+ if (otherWs === ws) return;
1983
+ if (!otherWs._clayUser || otherWs._clayUser.id !== targetId) return;
1984
+ if (otherWs.readyState !== 1) return;
1985
+ otherWs.send(JSON.stringify({ type: "dm_message", dmKey: msg.dmKey, message: message }));
1986
+ });
1987
+ });
1988
+ return;
1989
+ }
1990
+ }
1991
+
1903
1992
  function removeProject(slug) {
1904
1993
  var ctx = projects.get(slug);
1905
1994
  if (!ctx) return false;
@@ -1989,6 +2078,18 @@ function createServer(opts) {
1989
2078
  return;
1990
2079
  }
1991
2080
  var serverUsers = getServerUsers();
2081
+ var allUsers = users.getAllUsers().map(function (u) {
2082
+ var p = u.profile || {};
2083
+ return {
2084
+ id: u.id,
2085
+ displayName: p.name || u.displayName || u.username,
2086
+ username: u.username,
2087
+ role: u.role,
2088
+ avatarStyle: p.avatarStyle || "thumbs",
2089
+ avatarSeed: p.avatarSeed || u.username,
2090
+ avatarColor: p.avatarColor || "#7c3aed",
2091
+ };
2092
+ });
1992
2093
  // Build per-user filtered lists, send individually
1993
2094
  var sentUsers = {};
1994
2095
  projects.forEach(function (ctx) {
@@ -2014,6 +2115,7 @@ function createServer(opts) {
2014
2115
  projects: filteredProjects,
2015
2116
  projectCount: projects.size,
2016
2117
  serverUsers: serverUsers,
2118
+ allUsers: allUsers,
2017
2119
  });
2018
2120
  sentUsers[key] = msgStr;
2019
2121
  ws.send(msgStr);
package/lib/users.js CHANGED
@@ -223,6 +223,12 @@ function getAllUsers() {
223
223
  });
224
224
  }
225
225
 
226
+ function getOtherUsers(excludeUserId) {
227
+ return getAllUsers().filter(function (u) {
228
+ return u.id !== excludeUserId;
229
+ });
230
+ }
231
+
226
232
  function removeUser(userId) {
227
233
  var data = loadUsers();
228
234
  var before = data.users.length;
@@ -456,4 +462,5 @@ module.exports = {
456
462
  canAccessProject: canAccessProject,
457
463
  getAccessibleProjects: getAccessibleProjects,
458
464
  canAccessSession: canAccessSession,
465
+ getOtherUsers: getOtherUsers,
459
466
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clay-server",
3
- "version": "2.10.0",
3
+ "version": "2.11.0-beta.1",
4
4
  "description": "Web UI for Claude Code. Any device. Push notifications.",
5
5
  "bin": {
6
6
  "clay-server": "./bin/cli.js",