clay-server 2.35.1 → 2.36.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.
@@ -0,0 +1,193 @@
1
+ // project-user-mention.js - User-to-user @mention handling within a session.
2
+ //
3
+ // Mirrors the @mate mention pattern (see project-mate-interaction.js) but for
4
+ // human users. Each user_mention is a one-shot side-channel message that:
5
+ // 1. Is broadcast to all session viewers as a `user_mention` event
6
+ // 2. Is recorded in session.history so it survives reload/replay
7
+ // 3. Pushes a transcript line into session.pendingMentionContexts so the
8
+ // coding agent sees the exchange on its next regular turn
9
+ // 4. Fires a notification + push for the targeted user
10
+ //
11
+ // Multi-target (`@a @b`) is intentionally NOT supported here -- callers send
12
+ // one user_mention per target. This keeps the wire protocol and progress
13
+ // tracking simple. Multi-target support can be layered on later.
14
+
15
+ function attachUserMention(ctx) {
16
+ var slug = ctx.slug || "";
17
+ var sm = ctx.sm;
18
+ var send = ctx.send;
19
+ var sendTo = ctx.sendTo;
20
+ var sendToSession = ctx.sendToSession;
21
+ var sendToSessionOthers = ctx.sendToSessionOthers;
22
+ var getSessionForWs = ctx.getSessionForWs;
23
+ var getLinuxUserForSession = ctx.getLinuxUserForSession;
24
+ var saveImageFile = ctx.saveImageFile;
25
+ var hydrateImageRefs = ctx.hydrateImageRefs;
26
+ var usersModule = ctx.usersModule;
27
+ var pushModule = ctx.pushModule || null;
28
+ var isUserOnline = ctx.isUserOnline || function () { return false; };
29
+ var getNotificationsModule = ctx.getNotificationsModule || function () { return null; };
30
+ var getProjectTitle = ctx.getProjectTitle || function () { return slug; };
31
+
32
+ function buildPreview(text) {
33
+ var preview = (text || "").replace(/\s+/g, " ").trim();
34
+ if (preview.length > 140) preview = preview.substring(0, 140) + "...";
35
+ return preview;
36
+ }
37
+
38
+ function resolveSenderName(ws) {
39
+ if (!ws._clayUser) return "Someone";
40
+ var u = ws._clayUser;
41
+ var p = u.profile || {};
42
+ return p.name || u.displayName || u.username || "Someone";
43
+ }
44
+
45
+ function resolveSenderAvatar(ws) {
46
+ if (!ws._clayUser) return null;
47
+ var u = ws._clayUser;
48
+ var p = u.profile || {};
49
+ return {
50
+ style: p.avatarStyle || "thumbs",
51
+ seed: p.avatarSeed || u.username || u.id,
52
+ color: p.avatarColor || "#7c3aed",
53
+ custom: p.avatarCustom || "",
54
+ };
55
+ }
56
+
57
+ function resolveTargetUser(targetUserId) {
58
+ if (!targetUserId || !usersModule || typeof usersModule.findUserById !== "function") return null;
59
+ var u = usersModule.findUserById(targetUserId);
60
+ if (!u) return null;
61
+ var p = u.profile || {};
62
+ return {
63
+ id: u.id,
64
+ name: p.name || u.displayName || u.username || "User",
65
+ username: u.username,
66
+ avatarStyle: p.avatarStyle || "thumbs",
67
+ avatarSeed: p.avatarSeed || u.username,
68
+ avatarColor: p.avatarColor || "#7c3aed",
69
+ };
70
+ }
71
+
72
+ function handleUserMention(ws, msg) {
73
+ if (!msg.targetUserId) {
74
+ sendTo(ws, { type: "user_mention_error", error: "Missing targetUserId" });
75
+ return;
76
+ }
77
+ if (!msg.text && (!msg.images || msg.images.length === 0) && (!msg.pastes || msg.pastes.length === 0)) {
78
+ return;
79
+ }
80
+ var session = getSessionForWs(ws);
81
+ if (!session) return;
82
+
83
+ if (!ws._clayUser) {
84
+ sendTo(ws, { type: "user_mention_error", error: "You must be signed in to mention another user." });
85
+ return;
86
+ }
87
+ if (ws._clayUser.id === msg.targetUserId) {
88
+ sendTo(ws, { type: "user_mention_error", error: "You cannot mention yourself." });
89
+ return;
90
+ }
91
+
92
+ var target = resolveTargetUser(msg.targetUserId);
93
+ if (!target) {
94
+ sendTo(ws, { type: "user_mention_error", error: "Target user not found." });
95
+ return;
96
+ }
97
+
98
+ var fromId = ws._clayUser.id;
99
+ var fromName = resolveSenderName(ws);
100
+ var fromAvatar = resolveSenderAvatar(ws) || {};
101
+
102
+ // Save images to disk (same pattern as regular and mate-mention messages)
103
+ var imageRefs = [];
104
+ if (msg.images && msg.images.length > 0) {
105
+ for (var i = 0; i < msg.images.length; i++) {
106
+ var img = msg.images[i];
107
+ var savedName = saveImageFile(img.mediaType, img.data, getLinuxUserForSession(session));
108
+ if (savedName) {
109
+ imageRefs.push({ mediaType: img.mediaType, file: savedName });
110
+ }
111
+ }
112
+ }
113
+
114
+ var entry = {
115
+ type: "user_mention",
116
+ from: fromId,
117
+ fromName: fromName,
118
+ targetUserId: target.id,
119
+ targetName: target.name,
120
+ targetUsername: target.username,
121
+ targetAvatarStyle: target.avatarStyle,
122
+ targetAvatarSeed: target.avatarSeed,
123
+ targetAvatarColor: target.avatarColor,
124
+ text: msg.text || "",
125
+ _ts: Date.now(),
126
+ };
127
+ if (msg.pastes && msg.pastes.length > 0) entry.pastes = msg.pastes;
128
+ if (imageRefs.length > 0) entry.imageRefs = imageRefs;
129
+
130
+ session.history.push(entry);
131
+ sm.appendToSessionFile(session, entry);
132
+
133
+ // Hydrate image refs for live broadcast (clients want urls, not file names).
134
+ // Use sendToSessionOthers so the sender's tab renders the message locally
135
+ // (via input.js) without duplicating it from the WS echo.
136
+ var live = hydrateImageRefs ? hydrateImageRefs(entry) : entry;
137
+ sendToSessionOthers(ws, session.localId, live);
138
+
139
+ // Queue side-channel transcript for the coding agent's next turn so the
140
+ // agent sees what the humans discussed. Mirrors how mate mentions inject.
141
+ if (!session.pendingMentionContexts) session.pendingMentionContexts = [];
142
+ session.pendingMentionContexts.push(
143
+ "[Context: @" + fromName + " mentioned @" + target.name + " in a side conversation]\n" +
144
+ fromName + " to @" + target.name + ": " + (msg.text || "") + "\n" +
145
+ "[End of @mention context. This is for your reference only. Do not respond to it directly.]"
146
+ );
147
+
148
+ // Notification for the mentioned user
149
+ var preview = buildPreview(msg.text);
150
+ var notificationsModule = getNotificationsModule();
151
+ if (notificationsModule && typeof notificationsModule.notify === "function") {
152
+ notificationsModule.notify("user_mention", {
153
+ slug: slug,
154
+ sessionId: session.localId,
155
+ ownerId: session.ownerId || null,
156
+ targetUserId: target.id,
157
+ fromUserId: fromId,
158
+ fromName: fromName,
159
+ fromAvatarStyle: fromAvatar.style || null,
160
+ fromAvatarSeed: fromAvatar.seed || null,
161
+ fromAvatarColor: fromAvatar.color || null,
162
+ fromAvatarCustom: fromAvatar.custom || "",
163
+ preview: preview || "Mentioned you",
164
+ });
165
+ }
166
+
167
+ // Push notification: only if the target is offline. If they are online,
168
+ // the notification banner already handles in-app delivery. This prevents
169
+ // double-buzzing for users who are actively connected.
170
+ if (pushModule && typeof pushModule.sendPushToUser === "function" && !isUserOnline(target.id)) {
171
+ try {
172
+ pushModule.sendPushToUser(target.id, {
173
+ type: "user_mention",
174
+ title: "@" + fromName + " mentioned you",
175
+ body: preview || "Tap to open the session",
176
+ tag: "clay-user-mention-" + target.id,
177
+ data: {
178
+ slug: slug,
179
+ sessionId: session.localId,
180
+ },
181
+ });
182
+ } catch (e) {
183
+ console.error("[user-mention] push failed:", e && e.message ? e.message : e);
184
+ }
185
+ }
186
+ }
187
+
188
+ return {
189
+ handleUserMention: handleUserMention,
190
+ };
191
+ }
192
+
193
+ module.exports = { attachUserMention: attachUserMention };
package/lib/project.js CHANGED
@@ -17,6 +17,7 @@ var userPresence = require("./user-presence");
17
17
  var { attachDebate } = require("./project-debate");
18
18
  var { attachMemory } = require("./project-memory");
19
19
  var { attachMateInteraction } = require("./project-mate-interaction");
20
+ var { attachUserMention } = require("./project-user-mention");
20
21
  var { attachLoop } = require("./project-loop");
21
22
  var { attachFileWatch } = require("./project-file-watch");
22
23
  var { attachHTTP } = require("./project-http");
@@ -848,6 +849,12 @@ function createProjectContext(opts) {
848
849
  return;
849
850
  }
850
851
 
852
+ // --- @Mention: user-to-user side conversation in this session ---
853
+ if (msg.type === "user_mention") {
854
+ handleUserMention(ws, msg);
855
+ return;
856
+ }
857
+
851
858
  if (msg.type === "mention_stop") {
852
859
  var session = getSessionForWs(ws);
853
860
  if (session && session._mentionInProgress) {
@@ -1046,6 +1053,26 @@ function createProjectContext(opts) {
1046
1053
  var digestDmTurn = _mateInteraction.digestDmTurn;
1047
1054
  var enqueueDigest = _mateInteraction.enqueueDigest;
1048
1055
 
1056
+ // --- User-to-user mention engine (delegated to project-user-mention.js) ---
1057
+ var _userMention = attachUserMention({
1058
+ slug: slug,
1059
+ sm: sm,
1060
+ send: send,
1061
+ sendTo: sendTo,
1062
+ sendToSession: sendToSession,
1063
+ sendToSessionOthers: sendToSessionOthers,
1064
+ getSessionForWs: getSessionForWs,
1065
+ getLinuxUserForSession: getLinuxUserForSession,
1066
+ saveImageFile: saveImageFile,
1067
+ hydrateImageRefs: hydrateImageRefs,
1068
+ usersModule: usersModule,
1069
+ pushModule: pushModule,
1070
+ isUserOnline: opts.isUserOnline || function () { return false; },
1071
+ getNotificationsModule: function () { return _notifications; },
1072
+ getProjectTitle: function () { return title || slug; },
1073
+ });
1074
+ var handleUserMention = _userMention.handleUserMention;
1075
+
1049
1076
  // --- Debate engine (delegated to project-debate.js) ---
1050
1077
  var _debate = attachDebate({
1051
1078
  cwd: cwd,
package/lib/public/app.js CHANGED
@@ -730,6 +730,12 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
730
730
  getMateAvatarUrl: function () { return document.body.dataset.mateAvatarUrl || ""; },
731
731
  showMatePreThinking: function () { showMatePreThinking(); },
732
732
  showClaudePreThinking: function () { showClaudePreThinking(); },
733
+ myUserId: function () { return store.get('myUserId'); },
734
+ myDisplayName: function () {
735
+ var u = null;
736
+ try { u = JSON.parse(localStorage.getItem("clay_my_user") || "null"); } catch (e) {}
737
+ return (u && (u.displayName || u.username)) || document.body.dataset.myDisplayName || "Me";
738
+ },
733
739
  });
734
740
 
735
741
  // --- @Mention module ---
@@ -740,6 +746,8 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
740
746
  messagesEl: messagesEl,
741
747
  matesList: function () { return store.get('cachedMatesList') || []; },
742
748
  availableBuiltins: function () { return store.get('cachedAvailableBuiltins') || []; },
749
+ allUsers: function () { return store.get('cachedAllUsers') || []; },
750
+ myUserId: function () { return store.get('myUserId'); },
743
751
  scrollToBottom: scrollToBottom,
744
752
  addUserMessage: addUserMessage,
745
753
  addCopyHandler: addCopyHandler,
@@ -53,7 +53,7 @@ import { showDebateSticky, showDebateConcludeConfirm, showDebateUserFloor, exitD
53
53
  import { handleSkillInstallWs } from './app-skills-install.js';
54
54
  import { handleNotificationsState, handleNotificationCreated, handleNotificationDismissed, handleNotificationDismissedAll, showUpdateBanner } from './app-notifications.js';
55
55
  import { handleDebatePreparing, handleDebateBriefReady, renderDebateBriefReady, handleDebateStarted, renderDebateStarted, handleDebateTurn, handleDebateActivity, handleDebateStream, handleDebateTurnDone, handleDebateCommentQueued, handleDebateCommentInjected, renderDebateCommentInjected, handleDebateResumed, handleDebateEnded, renderDebateEnded, handleDebateError, isDebateActive, renderMcpDebateProposal, renderDebateUserResume } from './debate.js';
56
- import { handleMentionStart, handleMentionActivity, handleMentionStream, handleMentionDone, handleMentionError, renderMentionUser, renderMentionResponse } from './mention.js';
56
+ import { handleMentionStart, handleMentionActivity, handleMentionStream, handleMentionDone, handleMentionError, renderMentionUser, renderMentionResponse, renderUserMention } from './mention.js';
57
57
 
58
58
  // --- DOM refs (cached once, stable for page lifetime) ---
59
59
  var messagesEl = document.getElementById("messages");
@@ -1434,6 +1434,18 @@ export function processMessage(msg) {
1434
1434
  renderMentionResponse(msg);
1435
1435
  break;
1436
1436
 
1437
+ case "user_mention":
1438
+ // User-to-user side conversation entry. Renders for any session viewer
1439
+ // (sender's other tabs and the mentioned user, if they are watching the
1440
+ // session). On the sender's own tab, the server uses sendToSessionOthers
1441
+ // so we never get a duplicate here.
1442
+ finalizeAssistantBlock();
1443
+ renderUserMention(msg);
1444
+ break;
1445
+ case "user_mention_error":
1446
+ if (msg.error) showToast("@Mention: " + msg.error, "error");
1447
+ break;
1448
+
1437
1449
  // --- Debate ---
1438
1450
  case "debate_preparing":
1439
1451
  if (!store.get('replayingHistory')) showDebateSticky("preparing", msg);
@@ -9,7 +9,7 @@ import { getWs } from './ws-ref.js';
9
9
  import { openDm } from './app-dm.js';
10
10
  import { getCachedProjects } from './app-projects.js';
11
11
  import { switchProject } from './app-projects.js';
12
- import { mateAvatarUrl } from './avatar.js';
12
+ import { mateAvatarUrl, userAvatarUrl } from './avatar.js';
13
13
  import { openTerminal } from './terminal.js';
14
14
  var notifications = [];
15
15
  var unreadCount = 0;
@@ -87,16 +87,20 @@ function showBanner(notif, autoDismissMs) {
87
87
  var isEmpty = notif.id === "_empty";
88
88
  var isPermission = notif.type === "permission_request" && notif.meta && notif.meta.requestId;
89
89
  var isAuthRequired = notif.type === "auth_required";
90
+ var isUserMention = notif.type === "user_mention";
90
91
  var projectIcon = isEmpty ? null : getProjectIcon(notif.slug);
91
92
  var projectName = isEmpty ? "" : (isAuthRequired ? "CLAY" : getProjectName(notif.slug));
92
93
  var mate = isEmpty ? null : getMateForNotification(notif);
94
+ var userMentionAvatarSrc = isUserMention ? buildUserMentionAvatarSrc(notif) : null;
93
95
 
94
96
  var banner = document.createElement("div");
95
- banner.className = "notif-banner" + (isAuthRequired ? " notif-banner-update notif-banner-auth" : isPermission ? " notif-banner-permission" : "");
97
+ banner.className = "notif-banner" + (isAuthRequired ? " notif-banner-update notif-banner-auth" : isPermission ? " notif-banner-permission" : isUserMention ? " notif-banner-user-mention" : "");
96
98
  if (!isEmpty) banner.setAttribute("data-notif-id", notif.id);
97
99
  if (isAuthRequired) banner.setAttribute("data-auth-banner", "true");
98
100
 
99
- var iconHtmlStr = mate
101
+ var iconHtmlStr = userMentionAvatarSrc
102
+ ? '<img class="notif-banner-avatar" src="' + escapeHtml(userMentionAvatarSrc) + '" alt="' + escapeHtml((notif.meta && notif.meta.fromName) || "User") + '">'
103
+ : mate
100
104
  ? '<img class="notif-banner-avatar" src="' + escapeHtml(mateAvatarUrl(mate, 32)) + '" alt="' + escapeHtml(mate.displayName || mate.name || "Mate") + '">'
101
105
  : isAuthRequired
102
106
  ? '<img src="/icon-banded-76.png" width="32" height="32" alt="Clay" style="border-radius:8px">'
@@ -428,7 +432,15 @@ export function handleNotificationCreated(msg) {
428
432
  unreadCount = msg.unreadCount;
429
433
  updateBadge();
430
434
 
431
- var _autoDismiss = notif.type === "permission_request" || notif.type === "auth_required" ? false : true;
435
+ // user_mention banners are persistent (no auto-dismiss). They go away when:
436
+ // 1. The user clicks the banner (which navigates to the session — see
437
+ // banner click handler in showBanner / navigateToNotification), OR
438
+ // 2. The user navigates to the target session by some other path, in which
439
+ // case handleNotificationCreated above already dismissed the notif on
440
+ // activeSessionId match, OR
441
+ // 3. The user clicks the X close button.
442
+ // Permission and auth banners are also persistent for the same UX reason.
443
+ var _autoDismiss = (notif.type === "permission_request" || notif.type === "auth_required" || notif.type === "user_mention") ? false : true;
432
444
  showBanner(notif, _autoDismiss);
433
445
  }
434
446
 
@@ -506,6 +518,30 @@ function deriveMateIdFromNotification(notif) {
506
518
  return null;
507
519
  }
508
520
 
521
+ // Build the sender avatar URL for a user_mention notification banner.
522
+ // Prefers the avatar fields stashed in `notif.meta` (set by the server when the
523
+ // notification is created), and falls back to the cached user list from
524
+ // `cachedAllUsers` so the banner still has a face even if meta is missing.
525
+ function buildUserMentionAvatarSrc(notif) {
526
+ var meta = (notif && notif.meta) || {};
527
+ if (meta.fromAvatarStyle || meta.fromAvatarSeed || meta.fromAvatarCustom) {
528
+ return userAvatarUrl({
529
+ avatarStyle: meta.fromAvatarStyle,
530
+ avatarSeed: meta.fromAvatarSeed,
531
+ avatarCustom: meta.fromAvatarCustom || "",
532
+ username: meta.fromName,
533
+ id: meta.fromUserId,
534
+ }, 32);
535
+ }
536
+ if (meta.fromUserId) {
537
+ var users = store.get('cachedAllUsers') || [];
538
+ for (var i = 0; i < users.length; i++) {
539
+ if (users[i] && users[i].id === meta.fromUserId) return userAvatarUrl(users[i], 32);
540
+ }
541
+ }
542
+ return null;
543
+ }
544
+
509
545
  function getMateForNotification(notif) {
510
546
  var mateId = notif && notif.meta ? notif.meta.avatarMateId : null;
511
547
  if (!mateId) mateId = deriveMateIdFromNotification(notif);
@@ -1,7 +1,7 @@
1
1
  import { iconHtml, refreshIcons } from './icons.js';
2
2
  import { setRewindMode, isRewindMode } from './rewind.js';
3
3
  import { renderPicker as renderContextPicker } from './context-sources.js';
4
- import { checkForMention, showMentionMenu, hideMentionMenu, isMentionMenuVisible, mentionMenuKeydown, setMentionAtIdx, parseMentionFromInput, clearMentionState, stickyReapplyMention, sendMention, renderMentionUser, removeMentionChip } from './mention.js';
4
+ import { checkForMention, showMentionMenu, hideMentionMenu, isMentionMenuVisible, mentionMenuKeydown, setMentionAtIdx, parseMentionFromInput, clearMentionState, stickyReapplyMention, sendMention, sendUserMention, renderMentionUser, renderUserMention, removeMentionChip } from './mention.js';
5
5
  import { store } from './store.js';
6
6
  import { mateAvatarUrl } from './avatar.js';
7
7
 
@@ -152,9 +152,44 @@ export function sendMessage() {
152
152
  return;
153
153
  }
154
154
 
155
- // Check for @mention: if a mate was selected, route to mention handler
156
- // Exception: if we're in a DM with the same mate, send as regular message instead
155
+ // Check for @mention: if a mate or user was selected, route to mention handler.
156
+ // Exception: if we're in a DM with the same mate, send as regular message instead.
157
+ // (User mentions never short-circuit to DM mode; user-to-user side conversations
158
+ // are scoped to the active session, not pulled into DMs.)
157
159
  var mention = parseMentionFromInput(text);
160
+ if (mention && mention.kind === "user") {
161
+ hideMentionMenu();
162
+ if (ctx.hideSuggestionChips) ctx.hideSuggestionChips();
163
+ var uMentionImages = pendingImages.slice();
164
+ var uMentionPastes = pendingPastes.map(function (p) { return p.text; });
165
+ var uMentionFiles = pendingFiles.slice();
166
+ var uMentionText = mention.text;
167
+ if (uMentionFiles.length > 0) {
168
+ var uFilePaths = uMentionFiles.map(function (f) { return "[Uploaded file: " + f.path + "]"; }).join("\n");
169
+ uMentionText = uMentionText ? uFilePaths + "\n\n" + uMentionText : uFilePaths;
170
+ }
171
+ // Optimistic local render so the sender sees their own message immediately.
172
+ // The server uses sendToSessionOthers, so this tab does not get a duplicate echo.
173
+ var myUserId = ctx.myUserId ? ctx.myUserId() : null;
174
+ var myDisplayName = ctx.myDisplayName ? ctx.myDisplayName() : "Me";
175
+ renderUserMention({
176
+ from: myUserId,
177
+ fromName: myDisplayName,
178
+ targetUserId: mention.userId,
179
+ targetName: mention.mateName,
180
+ text: uMentionText,
181
+ images: uMentionImages.length > 0 ? uMentionImages : null,
182
+ pastes: uMentionPastes.length > 0 ? uMentionPastes : null,
183
+ });
184
+ sendUserMention(mention.userId, uMentionText, uMentionPastes, uMentionImages);
185
+ ctx.inputEl.value = "";
186
+ stickyReapplyMention();
187
+ sendInputSync();
188
+ clearPendingImages();
189
+ autoResize();
190
+ ctx.inputEl.focus();
191
+ return;
192
+ }
158
193
  if (mention) {
159
194
  var dmMateId = ctx.getDmMateId ? ctx.getDmMateId() : null;
160
195
  if (dmMateId && dmMateId === mention.mateId) {