clay-server 2.34.1-beta.3 → 2.35.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/daemon.js CHANGED
@@ -681,6 +681,7 @@ var relay = createServer({
681
681
  keepAwake: !!config.keepAwake,
682
682
  autoContinueOnRateLimit: !!config.autoContinueOnRateLimit,
683
683
  chatLayout: config.chatLayout || "channel",
684
+ matesEnabled: config.matesEnabled !== false,
684
685
  pinEnabled: !!config.pinHash,
685
686
  platform: process.platform,
686
687
  hostname: os2.hostname(),
@@ -739,6 +740,13 @@ var relay = createServer({
739
740
  console.log("[daemon] Auto-continue on rate limit:", want, "(web)");
740
741
  return { ok: true, autoContinueOnRateLimit: want };
741
742
  },
743
+ onSetMatesEnabled: function (value) {
744
+ var want = !!value;
745
+ config.matesEnabled = want;
746
+ saveConfig(config);
747
+ console.log("[daemon] Mates UI:", want ? "on" : "off", "(web)");
748
+ return { ok: true, matesEnabled: want };
749
+ },
742
750
  onGetToolPalettes: function () {
743
751
  return config.toolPalettes || {};
744
752
  },
package/lib/public/app.js CHANGED
@@ -285,6 +285,7 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
285
285
  cachedMatesList: [],
286
286
  cachedDmFavorites: [],
287
287
  cachedAvailableBuiltins: [],
288
+ matesEnabled: true,
288
289
  returningFromMateDm: false,
289
290
  pendingMateInterview: null,
290
291
  pendingTermCommand: null,
@@ -788,7 +789,13 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
788
789
  get projectList() { return getCachedProjects(); },
789
790
  });
790
791
  fetch("/api/me").then(function (r) { return r.json(); }).then(function (d) {
791
- if (d.multiUser) { store.set({ isMultiUserMode: true }); }
792
+ if (d.multiUser) {
793
+ store.set({ isMultiUserMode: true });
794
+ // Body class lets CSS treat single-user / multi-user differently for
795
+ // the Mates UI gate: single-user collapses the whole mate section,
796
+ // multi-user keeps it visible (still hosts other users' avatars).
797
+ if (document.body) document.body.classList.add('is-multi-user');
798
+ }
792
799
  if (d.user && d.user.id) { store.set({ myUserId: d.user.id }); }
793
800
  if (d.permissions) store.set({ permissions: d.permissions });
794
801
  if (d.mustChangePin) showForceChangePinOverlay();
@@ -3104,3 +3104,106 @@ body.mate-dm-active .activity-inline:not(.mention-activity-bar):not(.mate-pre-ac
3104
3104
  flex: 1;
3105
3105
  }
3106
3106
  }
3107
+
3108
+ /* === Mates UI gate === */
3109
+ /* body.mates-disabled is set when the user flips the Mates toggle off in
3110
+ User Settings → Mates. body.is-multi-user is set when the deploy hosts
3111
+ more than one user. Single-user collapses the whole Mates surface;
3112
+ multi-user keeps Mates-adjacent containers visible (they still host
3113
+ other users) but greys out and disables the Mates-specific bits. */
3114
+
3115
+ /* Always hide pure-Mates UI: rotating switch hint, individual mate
3116
+ avatars in the icon strip, the home-hub mates strip, and the
3117
+ "Ask Mate" button in the chat input. These never contain anything
3118
+ but mates, so they collapse in both modes. */
3119
+ body.mates-disabled .icon-strip-mate,
3120
+ body.mates-disabled #icon-strip-hint-mate,
3121
+ body.mates-disabled #home-hub-mates,
3122
+ body.mates-disabled #ask-mate-btn {
3123
+ display: none !important;
3124
+ }
3125
+
3126
+ /* Single-user only: collapse the whole icon-strip mate wrapper since it
3127
+ shares space with the user-strip but has no other users to display in
3128
+ single-user mode. The DM picker stays visible in both modes so the
3129
+ user always has a way to discover the toggle (single-user can't reach
3130
+ the picker today since the + button lives inside the wrapper, but if
3131
+ that ever changes the same disabled UX applies). */
3132
+ body.mates-disabled:not(.is-multi-user) #icon-strip-mate-section {
3133
+ display: none !important;
3134
+ }
3135
+
3136
+ /* === Mates discovery promo (DM picker, disabled state) === */
3137
+ /* Shown in both modes when Mates is off. Replaces the old "greyed mate
3138
+ list with trash icons" pattern with a softer invitation: section
3139
+ label, an animated avatar marquee that loops left, a value-prop
3140
+ sentence, and a CTA that deep-links into User Settings → Mates. */
3141
+
3142
+ .dm-picker-mates-promo {
3143
+ padding: 0 12px 8px;
3144
+ }
3145
+
3146
+ .dm-picker-mates-promo .dm-user-picker-section {
3147
+ margin-top: 0;
3148
+ }
3149
+
3150
+ .dm-picker-mates-marquee {
3151
+ position: relative;
3152
+ height: 28px;
3153
+ margin: 4px -12px;
3154
+ overflow: hidden;
3155
+ -webkit-mask-image: linear-gradient(to right, transparent, black 12%, black 88%, transparent);
3156
+ mask-image: linear-gradient(to right, transparent, black 12%, black 88%, transparent);
3157
+ }
3158
+
3159
+ .dm-picker-mates-marquee-track {
3160
+ display: flex;
3161
+ align-items: center;
3162
+ gap: 8px;
3163
+ width: max-content;
3164
+ height: 100%;
3165
+ padding: 0 12px;
3166
+ animation: dm-picker-mates-marquee-flow 22s linear infinite;
3167
+ }
3168
+
3169
+ .dm-picker-mates-marquee-avatar {
3170
+ width: 24px;
3171
+ height: 24px;
3172
+ border-radius: 50%;
3173
+ flex-shrink: 0;
3174
+ opacity: 0.85;
3175
+ background: var(--bg-alt, rgba(255, 255, 255, 0.04));
3176
+ }
3177
+
3178
+ @keyframes dm-picker-mates-marquee-flow {
3179
+ from { transform: translateX(0); }
3180
+ to { transform: translateX(-50%); }
3181
+ }
3182
+
3183
+ .dm-picker-mates-promo-text {
3184
+ font-size: 11px;
3185
+ color: var(--text-dimmer, #888);
3186
+ line-height: 1.5;
3187
+ margin: 4px 0 8px;
3188
+ }
3189
+
3190
+ .dm-picker-mates-promo-cta {
3191
+ display: flex;
3192
+ align-items: center;
3193
+ justify-content: center;
3194
+ gap: 6px;
3195
+ width: 100%;
3196
+ padding: 6px 10px;
3197
+ border: 1px solid var(--border, #2a2a2a);
3198
+ background: var(--bg-alt, rgba(255, 255, 255, 0.04));
3199
+ color: var(--text);
3200
+ font-size: 11px;
3201
+ cursor: pointer;
3202
+ border-radius: 6px;
3203
+ transition: background 0.15s, border-color 0.15s;
3204
+ }
3205
+
3206
+ .dm-picker-mates-promo-cta:hover {
3207
+ background: var(--bg-hover, rgba(255, 255, 255, 0.08));
3208
+ border-color: var(--accent, #6366f1);
3209
+ }
@@ -909,12 +909,14 @@
909
909
  <option value="us-account" selected>Account</option>
910
910
  <option value="us-appearance">Appearance</option>
911
911
  <option value="us-chat">Chat</option>
912
+ <option value="us-mates">Mates</option>
912
913
  <option value="us-email">Email</option>
913
914
  </select>
914
915
  <div class="us-modal-nav-items">
915
916
  <button class="us-nav-item active" data-section="us-account"><span>Account</span></button>
916
917
  <button class="us-nav-item" data-section="us-appearance"><span>Appearance</span></button>
917
918
  <button class="us-nav-item" data-section="us-chat"><span>Chat</span></button>
919
+ <button class="us-nav-item" data-section="us-mates"><span>Mates</span></button>
918
920
  <button class="us-nav-item" data-section="us-email"><span>Email</span></button>
919
921
  <div class="us-nav-separator"></div>
920
922
  <button class="us-nav-item us-nav-danger" data-section="us-logout"><span>Log Out</span></button>
@@ -1037,6 +1039,22 @@
1037
1039
  </div>
1038
1040
  </div>
1039
1041
 
1042
+ <!-- Mates section -->
1043
+ <div class="us-section" data-section="us-mates">
1044
+ <h2>Mates</h2>
1045
+ <p class="settings-hint">Specialist AI teammates with assigned roles and long-term memory across sessions. They carry context forward and push back when a session drifts. Use the built-ins or design your own.</p>
1046
+ <div class="settings-card">
1047
+ <label class="settings-toggle-row">
1048
+ <div>
1049
+ <span class="settings-label">Use Mates</span>
1050
+ <div class="settings-hint">Off hides every Mates surface in Clay. Existing Mates and their memory are preserved.</div>
1051
+ </div>
1052
+ <input type="checkbox" id="us-mates-enabled">
1053
+ <span class="toggle-track"><span class="toggle-thumb"></span></span>
1054
+ </label>
1055
+ </div>
1056
+ </div>
1057
+
1040
1058
  <!-- Email section -->
1041
1059
  <div class="us-section" data-section="us-email">
1042
1060
  <h2>Email Accounts</h2>
@@ -356,6 +356,16 @@ export function processMessage(msg) {
356
356
  break;
357
357
 
358
358
  case "model_info": {
359
+ // Drop stale model_info from a vendor that doesn't match the active
360
+ // session's vendor. On high-latency connections, the server's default-
361
+ // adapter model_info can arrive after session_switched has already
362
+ // bound the session to a different vendor. Applying it would replace
363
+ // currentModels with the wrong vendor's list and trigger app-panels
364
+ // to request models for the "wrong" vendor, which feeds back into a
365
+ // ping-pong loop of vendor flapping. See issue #336.
366
+ var _curV = store.get('currentVendor');
367
+ if (msg.vendor && _curV && msg.vendor !== _curV) break;
368
+
359
369
  var _modelVal = msg.model;
360
370
  if (_modelVal && typeof _modelVal === "object") _modelVal = _modelVal.value || _modelVal.displayName || "";
361
371
  var _miUpdate = { currentModels: msg.models || [] };
@@ -530,8 +540,12 @@ export function processMessage(msg) {
530
540
  if (!store.get('vendorSelectionLocked') || msg.hasHistory) {
531
541
  store.set({ currentVendor: msg.vendor });
532
542
  }
543
+ // Sessions with history have their vendor structurally bound to
544
+ // the session: lock so a late-arriving default-adapter model_info
545
+ // can't flip the UI back. Previously this branch unlocked, which
546
+ // is what allowed the feedback loop in issue #336.
533
547
  if (msg.hasHistory) {
534
- store.set({ vendorSelectionLocked: false });
548
+ store.set({ vendorSelectionLocked: true });
535
549
  }
536
550
  } else if (msg.hasHistory) {
537
551
  // Existing session without explicit vendor: reset to claude
@@ -18,6 +18,11 @@ var pendingSkillInstalls = [];
18
18
  var skillInstallCallback = null;
19
19
  var skillInstalling = false;
20
20
  var skillInstallDone = false;
21
+ // True when the modal contains only "outdated" skills (no "missing"). In that
22
+ // case the user is allowed to skip the update and continue with the original
23
+ // action; the dismissal is remembered for the rest of the browser session so
24
+ // we don't re-prompt on every reconnect / DM open.
25
+ var skillInstallSkippable = false;
21
26
 
22
27
  export function initSkillInstall() {
23
28
  skillInstallModal = document.getElementById("skill-install-modal");
@@ -28,8 +33,8 @@ export function initSkillInstall() {
28
33
  skillInstallCancel = document.getElementById("skill-install-cancel");
29
34
  skillInstallStatus = document.getElementById("skill-install-status");
30
35
 
31
- skillInstallCancel.addEventListener("click", hideSkillInstallModal);
32
- skillInstallModal.querySelector(".confirm-backdrop").addEventListener("click", hideSkillInstallModal);
36
+ skillInstallCancel.addEventListener("click", onSkillInstallDismiss);
37
+ skillInstallModal.querySelector(".confirm-backdrop").addEventListener("click", onSkillInstallDismiss);
33
38
 
34
39
  skillInstallOk.addEventListener("click", function () {
35
40
  if (skillInstallDone) {
@@ -106,6 +111,11 @@ function renderSkillInstallDialog(opts, missing) {
106
111
  if (hasMissing && hasOutdated) btnLabel = "Install / Update";
107
112
  skillInstallOk.textContent = btnLabel;
108
113
  skillInstallOk.className = "confirm-btn confirm-delete";
114
+ // When only outdated skills are pending, the feature still works on the
115
+ // current version. Let the user skip and continue; surface that as a
116
+ // distinct cancel label so it's clear this won't abort the action.
117
+ skillInstallSkippable = hasOutdated && !hasMissing;
118
+ skillInstallCancel.textContent = skillInstallSkippable ? "Skip" : "Cancel";
109
119
  skillInstallModal.classList.remove("hidden");
110
120
  }
111
121
 
@@ -115,6 +125,45 @@ function hideSkillInstallModal() {
115
125
  pendingSkillInstalls = [];
116
126
  skillInstalling = false;
117
127
  skillInstallDone = false;
128
+ skillInstallSkippable = false;
129
+ }
130
+
131
+ // Cancel/backdrop click handler. For "missing" skills the feature genuinely
132
+ // cannot run without them, so dismiss = drop the callback (existing behavior).
133
+ // For "outdated"-only prompts the feature still works on the old version, so
134
+ // dismiss = remember the choice for this browser session and proceed with cb.
135
+ function onSkillInstallDismiss() {
136
+ if (skillInstallSkippable) {
137
+ rememberOutdatedDismissal(pendingSkillInstalls);
138
+ var proceedCb = skillInstallCallback;
139
+ skillInstallCallback = null;
140
+ hideSkillInstallModal();
141
+ if (proceedCb) proceedCb();
142
+ return;
143
+ }
144
+ hideSkillInstallModal();
145
+ }
146
+
147
+ function dismissalKey(name, remoteVersion) {
148
+ return "clay-skill-update-dismissed:" + name + ":" + (remoteVersion || "");
149
+ }
150
+
151
+ function rememberOutdatedDismissal(items) {
152
+ try {
153
+ for (var i = 0; i < items.length; i++) {
154
+ var it = items[i];
155
+ if (it.status !== "outdated") continue;
156
+ sessionStorage.setItem(dismissalKey(it.name, it.remoteVersion), "1");
157
+ }
158
+ } catch (e) {}
159
+ }
160
+
161
+ function isOutdatedDismissed(name, remoteVersion) {
162
+ try {
163
+ return sessionStorage.getItem(dismissalKey(name, remoteVersion)) === "1";
164
+ } catch (e) {
165
+ return false;
166
+ }
118
167
  }
119
168
 
120
169
  function updateSkillInstallProgress(done, total) {
@@ -197,29 +246,31 @@ export function requireSkills(opts, cb) {
197
246
  .then(function (res) { return res.json(); })
198
247
  .then(function (data) {
199
248
  var results = data.results || [];
200
- // Only "missing" skills block the feature. "outdated" skills already
201
- // function an available update should not hard-gate with a modal
202
- // every time a user opens a DM or refreshes the page. Callers can
203
- // surface outdated versions elsewhere (e.g. settings / notifications).
249
+ // "missing" skills hard-block: the feature cannot run without them.
250
+ // "outdated" skills are surfaced too because skills look like a single
251
+ // user-facing feature but call vendor-specific tools (codex vs claude)
252
+ // internally version drift breaks that consistency. The modal is
253
+ // skippable for outdated-only cases, and a session-scoped dismissal
254
+ // suppresses re-prompts so reconnect/refresh doesn't re-fire it.
204
255
  var actionable = [];
205
256
  for (var i = 0; i < results.length; i++) {
206
257
  var r = results[i];
207
- if (r.status === "missing") {
208
- var orig = null;
209
- for (var j = 0; j < opts.skills.length; j++) {
210
- if (opts.skills[j].name === r.name) { orig = opts.skills[j]; break; }
211
- }
212
- if (!orig) continue;
213
- actionable.push({
214
- name: r.name,
215
- url: orig.url,
216
- scope: orig.scope || "global",
217
- installed: false,
218
- status: r.status,
219
- installedVersion: r.installedVersion,
220
- remoteVersion: r.remoteVersion,
221
- });
258
+ if (r.status !== "missing" && r.status !== "outdated") continue;
259
+ if (r.status === "outdated" && isOutdatedDismissed(r.name, r.remoteVersion)) continue;
260
+ var orig = null;
261
+ for (var j = 0; j < opts.skills.length; j++) {
262
+ if (opts.skills[j].name === r.name) { orig = opts.skills[j]; break; }
222
263
  }
264
+ if (!orig) continue;
265
+ actionable.push({
266
+ name: r.name,
267
+ url: orig.url,
268
+ scope: orig.scope || "global",
269
+ installed: false,
270
+ status: r.status,
271
+ installedVersion: r.installedVersion,
272
+ remoteVersion: r.remoteVersion,
273
+ });
223
274
  }
224
275
  if (actionable.length === 0) { cb(); return; }
225
276
  pendingSkillInstalls = actionable;
@@ -102,6 +102,13 @@ export function initMateWizard(sendWs, onMateCreated) {
102
102
  }
103
103
 
104
104
  export function openMateWizard() {
105
+ // Defensive: if the user has turned off the Mates UI in User Settings,
106
+ // do not open the wizard even if it gets fired programmatically (e.g.
107
+ // via a stale event handler). The CSS gate hides the entry point but
108
+ // we still short-circuit here so JS calls become no-ops.
109
+ if (typeof document !== "undefined" && document.body && document.body.classList.contains("mates-disabled")) {
110
+ return;
111
+ }
105
112
  mateWizardStep = 0;
106
113
  mateWizardData = {
107
114
  relationship: null,
@@ -54,6 +54,15 @@ export function checkForMention(value, cursorPos) {
54
54
 
55
55
  // --- Autocomplete dropdown ---
56
56
  export function showMentionMenu(query) {
57
+ // Mates UI gate: if the user has turned Mates off in User Settings,
58
+ // the @-mention dropdown is the entry point for sending a message to a
59
+ // mate from the regular session input, so suppress it entirely. Once
60
+ // we surface user-to-user DMs from this dropdown we can scope this
61
+ // check tighter, but for now the dropdown is mate-only.
62
+ if (store.get('matesEnabled') === false) {
63
+ hideMentionMenu();
64
+ return;
65
+ }
57
66
  var mates = ctx.matesList ? ctx.matesList() : [];
58
67
  if (!mates || mates.length === 0) {
59
68
  hideMentionMenu();
@@ -5,6 +5,7 @@
5
5
  import { iconHtml, refreshIcons } from './icons.js';
6
6
  import { setSTTLang } from './stt.js';
7
7
  import { avatarUrl, mateAvatarUrl, AVATAR_STYLES } from './avatar.js';
8
+ import { store } from './store.js';
8
9
 
9
10
  var ctx;
10
11
  var profile = { name: '', lang: 'en-US', avatarStyle: 'thumbs', avatarSeed: '', avatarColor: '#7c3aed', avatarCustom: '' };
@@ -577,6 +578,13 @@ export function initProfile(_ctx) {
577
578
  if (profile.lang) {
578
579
  setSTTLang(profile.lang);
579
580
  }
581
+
582
+ // Apply Mates UI gate at boot so the sidebar avatars / DM picker
583
+ // entry / home-hub strip are hidden before the user opens settings.
584
+ // Default true: only flip the body class off when explicitly false.
585
+ var matesOn = data.matesEnabled !== false;
586
+ store.set({ matesEnabled: matesOn });
587
+ if (document.body) document.body.classList.toggle('mates-disabled', !matesOn);
580
588
  }).catch(function(err) {
581
589
  console.warn('[Profile] Failed to load:', err);
582
590
  });
@@ -11,6 +11,7 @@ import { closeProjectCtxMenu } from './sidebar-projects.js';
11
11
  import { spawnDustParticles } from './sidebar.js';
12
12
  import { openDm } from './app-dm.js';
13
13
  import { openMateWizard } from './mate-wizard.js';
14
+ import { openUserSettings } from './user-settings.js';
14
15
  import { getCachedProjects } from './app-projects.js';
15
16
 
16
17
  function sendWs(msg) {
@@ -376,11 +377,14 @@ export function renderUserStrip(allUsers, onlineUserIds, myUserId, dmFavorites,
376
377
  }
377
378
  }
378
379
 
379
- // Render mates (favorites + unread in multi-user, all in single-user)
380
- var singleUser = allOthers.length === 0;
380
+ // Render mates: only favorited or unread, regardless of mode. Previously
381
+ // single-user mode short-circuited to "show all", which meant users who
382
+ // never engaged with Mates still saw 6 permanent avatars in the rail
383
+ // with no way to thin them out (#341). Applying the same filter as
384
+ // multi-user lets users curate the icon strip via favorites; the full
385
+ // mate list is still reachable from the DM picker.
381
386
  var favoriteMates = cachedMates.filter(function (m) {
382
387
  if (cachedDmRemovedUsers[m.id]) return false;
383
- if (singleUser) return true;
384
388
  if (cachedDmFavorites.indexOf(m.id) !== -1) return true;
385
389
  if (cachedDmUnread[m.id] && cachedDmUnread[m.id] > 0) return true;
386
390
  return false;
@@ -511,11 +515,17 @@ function toggleDmUserPicker(anchorEl) {
511
515
  picker.className = "dm-user-picker";
512
516
  picker.id = "dm-user-picker";
513
517
 
518
+ // Mates enabled flag is consulted up here so the search input can drop
519
+ // mates from its placeholder copy when the section is hidden.
520
+ var matesEnabled = store.get('matesEnabled') !== false;
521
+
514
522
  // Search input
515
523
  var searchInput = document.createElement("input");
516
524
  searchInput.className = "dm-user-picker-search";
517
525
  searchInput.type = "text";
518
- searchInput.placeholder = "Search mates and users...";
526
+ searchInput.placeholder = matesEnabled
527
+ ? "Search mates and users..."
528
+ : "Search users...";
519
529
  picker.appendChild(searchInput);
520
530
 
521
531
  // User list element (appended later, after USERS label)
@@ -575,35 +585,51 @@ function toggleDmUserPicker(anchorEl) {
575
585
  }
576
586
  }
577
587
 
578
- // --- MATES section ---
579
- var matesSectionLabel = document.createElement("div");
580
- matesSectionLabel.className = "dm-user-picker-section";
581
- matesSectionLabel.textContent = "Mates";
582
- picker.appendChild(matesSectionLabel);
583
-
584
- var matesListEl = document.createElement("div");
585
- matesListEl.className = "dm-user-picker-list dm-mates-list";
586
- picker.appendChild(matesListEl);
587
-
588
- // Update scroll gradient hint
589
- function updateMatesScrollHint() {
590
- var isOverflow = matesListEl.scrollHeight > matesListEl.clientHeight + 2;
591
- if (!isOverflow) {
592
- matesListEl.classList.add("no-overflow");
593
- matesListEl.classList.remove("scrolled-bottom");
594
- return;
595
- }
596
- matesListEl.classList.remove("no-overflow");
597
- var atBottom = matesListEl.scrollTop + matesListEl.clientHeight >= matesListEl.scrollHeight - 4;
598
- if (atBottom) {
599
- matesListEl.classList.add("scrolled-bottom");
600
- } else {
601
- matesListEl.classList.remove("scrolled-bottom");
588
+ // --- Layout depends on whether Mates is enabled ---
589
+ // When enabled: Mates section first, then Users. Mates list is fully
590
+ // rendered with delete buttons, create-mate entry, etc.
591
+ // When disabled: Users first, then a Mates discovery promo (animated
592
+ // avatar marquee + value prop + a single CTA that deep-links into
593
+ // User Settings → Mates). We deliberately don't render the actual
594
+ // mate list when off — showing real mates with greyed-out trash icons
595
+ // is visually heavy and contradicts the user's choice to hide them.
596
+ // (matesEnabled is computed earlier near the search input.)
597
+
598
+ // matesListEl + renderMatesList are only used in the enabled layout.
599
+ // Declared up-front so the search handler can reference renderMatesList
600
+ // safely; renderMatesList stays a no-op in disabled mode.
601
+ var matesListEl = null;
602
+ var renderMatesList = function () {};
603
+
604
+ function renderMatesEnabledSection() {
605
+ var matesSectionLabel = document.createElement("div");
606
+ matesSectionLabel.className = "dm-user-picker-section";
607
+ matesSectionLabel.textContent = "Mates";
608
+ picker.appendChild(matesSectionLabel);
609
+
610
+ matesListEl = document.createElement("div");
611
+ matesListEl.className = "dm-user-picker-list dm-mates-list";
612
+ picker.appendChild(matesListEl);
613
+
614
+ // Update scroll gradient hint
615
+ function updateMatesScrollHint() {
616
+ var isOverflow = matesListEl.scrollHeight > matesListEl.clientHeight + 2;
617
+ if (!isOverflow) {
618
+ matesListEl.classList.add("no-overflow");
619
+ matesListEl.classList.remove("scrolled-bottom");
620
+ return;
621
+ }
622
+ matesListEl.classList.remove("no-overflow");
623
+ var atBottom = matesListEl.scrollTop + matesListEl.clientHeight >= matesListEl.scrollHeight - 4;
624
+ if (atBottom) {
625
+ matesListEl.classList.add("scrolled-bottom");
626
+ } else {
627
+ matesListEl.classList.remove("scrolled-bottom");
628
+ }
602
629
  }
603
- }
604
- matesListEl.addEventListener("scroll", updateMatesScrollHint);
630
+ matesListEl.addEventListener("scroll", updateMatesScrollHint);
605
631
 
606
- function renderMatesList(filter) {
632
+ renderMatesList = function (filter) {
607
633
  matesListEl.innerHTML = "";
608
634
  var allMates = cachedMates || [];
609
635
  if (filter) {
@@ -752,42 +778,129 @@ function toggleDmUserPicker(anchorEl) {
752
778
  emptyEl.textContent = "No mates found";
753
779
  matesListEl.appendChild(emptyEl);
754
780
  }
755
- refreshIcons();
756
- requestAnimationFrame(updateMatesScrollHint);
781
+ refreshIcons();
782
+ requestAnimationFrame(updateMatesScrollHint);
783
+ };
784
+
785
+ // Create Mate option
786
+ var createMateEl = document.createElement("div");
787
+ createMateEl.className = "dm-user-picker-create-mate";
788
+ var hasCustomMates = (cachedMates || []).some(function (m) { return !m.builtinKey; });
789
+ var createMateLabel = hasCustomMates ? "Create a Mate" : "Create a Mate for what you're doing";
790
+ createMateEl.innerHTML = iconHtml("bot") + " <span>" + createMateLabel + "</span>";
791
+ createMateEl.addEventListener("click", function () {
792
+ closeDmUserPicker();
793
+ if (openMateWizard) openMateWizard();
794
+ });
795
+ picker.appendChild(createMateEl);
796
+
797
+ // Divider
798
+ var divider = document.createElement("div");
799
+ divider.style.borderTop = "1px solid var(--border, #333)";
800
+ divider.style.margin = "4px 0";
801
+ picker.appendChild(divider);
802
+
803
+ // Users section
804
+ var usersLabelEnabled = document.createElement("div");
805
+ usersLabelEnabled.className = "dm-user-picker-section";
806
+ usersLabelEnabled.textContent = "Users";
807
+ picker.appendChild(usersLabelEnabled);
808
+ picker.appendChild(listEl);
757
809
  }
758
810
 
759
- // Create Mate option
760
- var createMateEl = document.createElement("div");
761
- createMateEl.className = "dm-user-picker-create-mate";
762
- var hasCustomMates = (cachedMates || []).some(function (m) { return !m.builtinKey; });
763
- var createMateLabel = hasCustomMates ? "Create a Mate" : "Create a Mate for what you're doing";
764
- createMateEl.innerHTML = iconHtml("bot") + " <span>" + createMateLabel + "</span>";
765
- createMateEl.addEventListener("click", function () {
766
- closeDmUserPicker();
767
- if (openMateWizard) openMateWizard();
768
- });
769
- picker.appendChild(createMateEl);
770
-
771
- // Divider
772
- var divider = document.createElement("div");
773
- divider.style.borderTop = "1px solid var(--border, #333)";
774
- divider.style.margin = "4px 0";
775
- picker.appendChild(divider);
776
-
777
- // Section label for users
778
- var sectionLabel = document.createElement("div");
779
- sectionLabel.className = "dm-user-picker-section";
780
- sectionLabel.textContent = "Users";
781
- picker.appendChild(sectionLabel);
782
- picker.appendChild(listEl);
783
-
784
- renderMatesList("");
785
- renderPickerList("");
786
- searchInput.addEventListener("input", function () {
787
- var val = searchInput.value;
788
- renderMatesList(val);
789
- renderPickerList(val);
790
- });
811
+ function renderMatesDisabledLayout() {
812
+ // Users section first when Mates is off — they are the only thing
813
+ // a user can actually act on, so they lead.
814
+ var usersLabel = document.createElement("div");
815
+ usersLabel.className = "dm-user-picker-section";
816
+ usersLabel.textContent = "Users";
817
+ picker.appendChild(usersLabel);
818
+ picker.appendChild(listEl);
819
+
820
+ // Divider
821
+ var divider = document.createElement("div");
822
+ divider.style.borderTop = "1px solid var(--border, #333)";
823
+ divider.style.margin = "8px 0 4px";
824
+ picker.appendChild(divider);
825
+
826
+ // Mates discovery promo: animated avatar marquee + value prop + CTA.
827
+ // The marquee uses real cached mate avatars purely as decoration; we
828
+ // never attach click handlers, never show delete buttons, and never
829
+ // render a list view — turning the section into a soft invitation
830
+ // rather than a "list with everything greyed out" tease.
831
+ var promo = document.createElement("div");
832
+ promo.className = "dm-picker-mates-promo";
833
+
834
+ var promoLabel = document.createElement("div");
835
+ promoLabel.className = "dm-user-picker-section";
836
+ promoLabel.textContent = "Mates";
837
+ promo.appendChild(promoLabel);
838
+
839
+ var marqueeWrap = document.createElement("div");
840
+ marqueeWrap.className = "dm-picker-mates-marquee";
841
+ var marqueeTrack = document.createElement("div");
842
+ marqueeTrack.className = "dm-picker-mates-marquee-track";
843
+ var marqueeSource = (cachedMates && cachedMates.length > 0)
844
+ ? cachedMates.slice(0, 8)
845
+ : (store.get('cachedAvailableBuiltins') || []).slice(0, 6);
846
+ // Duplicate the avatar set so the keyframe loop wraps seamlessly.
847
+ for (var copy = 0; copy < 2; copy++) {
848
+ for (var mi = 0; mi < marqueeSource.length; mi++) {
849
+ var src = marqueeSource[mi];
850
+ var avEl = document.createElement("img");
851
+ avEl.className = "dm-picker-mates-marquee-avatar";
852
+ avEl.alt = "";
853
+ avEl.setAttribute("aria-hidden", "true");
854
+ if (src.id) {
855
+ avEl.src = mateAvatarUrl(src, 32);
856
+ } else {
857
+ avEl.src = mateAvatarUrl({
858
+ avatarCustom: src.avatarCustom,
859
+ avatarStyle: src.avatarStyle || "bottts",
860
+ avatarSeed: src.displayName,
861
+ id: src.key,
862
+ }, 32);
863
+ }
864
+ marqueeTrack.appendChild(avEl);
865
+ }
866
+ }
867
+ marqueeWrap.appendChild(marqueeTrack);
868
+ promo.appendChild(marqueeWrap);
869
+
870
+ var promoText = document.createElement("div");
871
+ promoText.className = "dm-picker-mates-promo-text";
872
+ promoText.textContent = "Specialist AI teammates with long-term memory across sessions. Mention with @ for design, engineering, strategy, or marketing, or build your own.";
873
+ promo.appendChild(promoText);
874
+
875
+ var cta = document.createElement("button");
876
+ cta.type = "button";
877
+ cta.className = "dm-picker-mates-promo-cta";
878
+ cta.textContent = "Click here to enable Mates";
879
+ cta.addEventListener("click", function () {
880
+ closeDmUserPicker();
881
+ openUserSettings('us-mates');
882
+ });
883
+ promo.appendChild(cta);
884
+
885
+ picker.appendChild(promo);
886
+ }
887
+
888
+ if (matesEnabled) {
889
+ renderMatesEnabledSection();
890
+ renderMatesList("");
891
+ renderPickerList("");
892
+ searchInput.addEventListener("input", function () {
893
+ var val = searchInput.value;
894
+ renderMatesList(val);
895
+ renderPickerList(val);
896
+ });
897
+ } else {
898
+ renderMatesDisabledLayout();
899
+ renderPickerList("");
900
+ searchInput.addEventListener("input", function () {
901
+ renderPickerList(searchInput.value);
902
+ });
903
+ }
791
904
 
792
905
  // Focus search
793
906
  setTimeout(function () { searchInput.focus(); }, 50);
@@ -7,6 +7,7 @@ import { toggleDarkMode, getCurrentTheme, getChatLayout, setChatLayout } from '.
7
7
  import { showEmailSetupModal, getEmailAccountListCache } from './context-sources.js';
8
8
  import { setSTTLang } from './stt.js';
9
9
  import { userAvatarUrl } from './avatar.js';
10
+ import { store } from './store.js';
10
11
 
11
12
  var ctx = null;
12
13
  var settingsEl = null;
@@ -166,6 +167,26 @@ export function initUserSettings(appCtx) {
166
167
  });
167
168
  }
168
169
 
170
+ // Mates UI toggle. Default-on, so flipping off hides every Mates
171
+ // surface in the app (sidebar avatars, DM picker entry, home-hub strip)
172
+ // via the body.mates-disabled CSS gate. Flipping back on restores the
173
+ // surface; existing Mate data is never deleted.
174
+ var matesEnabledToggle = document.getElementById('us-mates-enabled');
175
+ if (matesEnabledToggle) {
176
+ matesEnabledToggle.addEventListener('change', function () {
177
+ var want = !!this.checked;
178
+ applyMatesEnabledClass(want);
179
+ store.set({ matesEnabled: want });
180
+ fetch('/api/user/mates-enabled', {
181
+ method: 'PUT',
182
+ headers: { 'Content-Type': 'application/json' },
183
+ body: JSON.stringify({ enabled: want }),
184
+ }).then(function (r) { return r.json(); }).then(function (data) {
185
+ if (data.ok) showToast(data.matesEnabled ? 'Mates on' : 'Mates off');
186
+ }).catch(function () {});
187
+ });
188
+ }
189
+
169
190
  // Theme switcher (Light / Dark)
170
191
  var themeSwitcher = document.getElementById('us-theme-switcher');
171
192
  if (themeSwitcher) {
@@ -225,13 +246,16 @@ export function initUserSettings(appCtx) {
225
246
  }
226
247
  }
227
248
 
228
- function openUserSettings() {
249
+ // Optional `initialSection` lets callers deep-link directly into a specific
250
+ // settings tab (e.g. the disabled-Mates picker button passing 'us-mates').
251
+ // Falls back to Account so existing callers and the icon click are unchanged.
252
+ export function openUserSettings(initialSection) {
229
253
  settingsEl.classList.remove('hidden');
230
254
  openBtn.classList.add('active');
231
255
  refreshIcons(settingsEl);
232
256
  populateAccount();
233
257
  hidePinForm();
234
- switchSection('us-account');
258
+ switchSection(initialSection || 'us-account');
235
259
  }
236
260
 
237
261
  export function closeUserSettings() {
@@ -259,6 +283,24 @@ function stopProp(e) {
259
283
  e.stopPropagation();
260
284
  }
261
285
 
286
+ // Mates UI gate. When the toggle is off we add `mates-disabled` to the
287
+ // body so CSS can hide every Mates surface (sidebar avatars, DM picker
288
+ // "Create a Mate" entry, home-hub mates strip) without touching the
289
+ // underlying data. JS code paths that gate on `store.get('matesEnabled')`
290
+ // also short-circuit so we don't, for example, open the mate wizard if
291
+ // someone fires it programmatically.
292
+ function applyMatesEnabledClass(enabled) {
293
+ if (!document.body) return;
294
+ document.body.classList.toggle('mates-disabled', !enabled);
295
+ }
296
+
297
+ export function isMatesEnabled() {
298
+ // Default to true if the store hasn't received profile yet — populating
299
+ // the false state happens after /api/profile resolves, so an undefined
300
+ // value at boot should not hide Mates UI.
301
+ return store.get('matesEnabled') !== false;
302
+ }
303
+
262
304
  function showPinForm() {
263
305
  if (!pinForm) return;
264
306
  pinForm.classList.remove('hidden');
@@ -392,6 +434,12 @@ function populateAccount() {
392
434
  if (data.mateOnboardingShown) {
393
435
  try { localStorage.setItem("clay-mate-onboarding-shown", "1"); } catch (e) {}
394
436
  }
437
+ // Mates UI toggle: default true; treat any missing/non-false value as on
438
+ var matesOn = data.matesEnabled !== false;
439
+ var matesToggle = document.getElementById('us-mates-enabled');
440
+ if (matesToggle) matesToggle.checked = matesOn;
441
+ store.set({ matesEnabled: matesOn });
442
+ applyMatesEnabledClass(matesOn);
395
443
  if (data.chatLayout) {
396
444
  setChatLayout(data.chatLayout); // update local cache + CSS
397
445
  }
@@ -29,6 +29,7 @@ function attachSettings(ctx) {
29
29
  profile.autoContinueOnRateLimit = !!mu.autoContinueOnRateLimit;
30
30
  profile.chatLayout = mu.chatLayout || "channel";
31
31
  profile.mateOnboardingShown = !!mu.mateOnboardingShown;
32
+ profile.matesEnabled = mu.matesEnabled !== false;
32
33
  res.writeHead(200, { "Content-Type": "application/json" });
33
34
  res.end(JSON.stringify(profile));
34
35
  return true;
@@ -50,6 +51,7 @@ function attachSettings(ctx) {
50
51
  profile.autoContinueOnRateLimit = !!dc.autoContinueOnRateLimit;
51
52
  profile.chatLayout = dc.chatLayout || "channel";
52
53
  profile.mateOnboardingShown = !!dc.mateOnboardingShown;
54
+ profile.matesEnabled = dc.matesEnabled !== false;
53
55
  }
54
56
  // Check if custom avatar file exists
55
57
  try {
@@ -401,6 +403,50 @@ function attachSettings(ctx) {
401
403
  return true;
402
404
  }
403
405
 
406
+ // PUT /api/user/mates-enabled
407
+ if (req.method === "PUT" && fullUrl === "/api/user/mates-enabled") {
408
+ var mu = getMultiUserFromReq(req);
409
+ if (!mu) {
410
+ // Single-user: store on daemon config
411
+ var body = "";
412
+ req.on("data", function (chunk) { body += chunk; });
413
+ req.on("end", function () {
414
+ try {
415
+ var data = JSON.parse(body);
416
+ var want = !!data.enabled;
417
+ if (typeof opts.onSetMatesEnabled === "function") {
418
+ opts.onSetMatesEnabled(want);
419
+ }
420
+ res.writeHead(200, { "Content-Type": "application/json" });
421
+ res.end(JSON.stringify({ ok: true, matesEnabled: want }));
422
+ } catch (e) {
423
+ res.writeHead(400, { "Content-Type": "application/json" });
424
+ res.end('{"error":"Invalid request"}');
425
+ }
426
+ });
427
+ return true;
428
+ }
429
+ var body = "";
430
+ req.on("data", function (chunk) { body += chunk; });
431
+ req.on("end", function () {
432
+ try {
433
+ var data = JSON.parse(body);
434
+ var result = users.setMatesEnabled(mu.id, !!data.enabled);
435
+ if (result.error) {
436
+ res.writeHead(400, { "Content-Type": "application/json" });
437
+ res.end(JSON.stringify({ error: result.error }));
438
+ return;
439
+ }
440
+ res.writeHead(200, { "Content-Type": "application/json" });
441
+ res.end(JSON.stringify({ ok: true, matesEnabled: result.matesEnabled }));
442
+ } catch (e) {
443
+ res.writeHead(400, { "Content-Type": "application/json" });
444
+ res.end('{"error":"Invalid request"}');
445
+ }
446
+ });
447
+ return true;
448
+ }
449
+
404
450
  // PUT /api/user/chat-layout
405
451
  if (req.method === "PUT" && fullUrl === "/api/user/chat-layout") {
406
452
  var mu = getMultiUserFromReq(req);
@@ -217,6 +217,36 @@ function attachPreferences(deps) {
217
217
  return { error: "User not found" };
218
218
  }
219
219
 
220
+ // --- Per-user Mates UI toggle ---
221
+ //
222
+ // When false, the entire Mates surface (sidebar avatars, DM "Create a
223
+ // Mate" entry, home-hub mates strip) is hidden for this user. Default
224
+ // is ON: Mates is opt-out, not opt-in. Stored as `matesEnabled`; we
225
+ // treat any value other than the literal `false` as enabled so brand-
226
+ // new users (no field set) get the default-on experience.
227
+
228
+ function getMatesEnabled(userId) {
229
+ var data = loadUsers();
230
+ for (var i = 0; i < data.users.length; i++) {
231
+ if (data.users[i].id === userId) {
232
+ return data.users[i].matesEnabled !== false;
233
+ }
234
+ }
235
+ return true;
236
+ }
237
+
238
+ function setMatesEnabled(userId, enabled) {
239
+ var data = loadUsers();
240
+ for (var i = 0; i < data.users.length; i++) {
241
+ if (data.users[i].id === userId) {
242
+ data.users[i].matesEnabled = !!enabled;
243
+ saveUsers(data);
244
+ return { ok: true, matesEnabled: !!enabled };
245
+ }
246
+ }
247
+ return { error: "User not found" };
248
+ }
249
+
220
250
  // --- Per-user tool palette preferences ---
221
251
  //
222
252
  // Each user can customize the sidebar tool grid by reordering or
@@ -291,6 +321,8 @@ function attachPreferences(deps) {
291
321
  setChatLayout: setChatLayout,
292
322
  getAutoContinue: getAutoContinue,
293
323
  setAutoContinue: setAutoContinue,
324
+ getMatesEnabled: getMatesEnabled,
325
+ setMatesEnabled: setMatesEnabled,
294
326
  getToolPalettes: getToolPalettes,
295
327
  setToolPalette: setToolPalette,
296
328
  setMateOnboarded: setMateOnboarded,
package/lib/users.js CHANGED
@@ -416,6 +416,8 @@ var getChatLayout = preferences.getChatLayout;
416
416
  var setChatLayout = preferences.setChatLayout;
417
417
  var getAutoContinue = preferences.getAutoContinue;
418
418
  var setAutoContinue = preferences.setAutoContinue;
419
+ var getMatesEnabled = preferences.getMatesEnabled;
420
+ var setMatesEnabled = preferences.setMatesEnabled;
419
421
  var getToolPalettes = preferences.getToolPalettes;
420
422
  var setToolPalette = preferences.setToolPalette;
421
423
  var setMateOnboarded = preferences.setMateOnboarded;
@@ -475,6 +477,8 @@ module.exports = {
475
477
  setMateOnboarded: setMateOnboarded,
476
478
  getAutoContinue: getAutoContinue,
477
479
  setAutoContinue: setAutoContinue,
480
+ getMatesEnabled: getMatesEnabled,
481
+ setMatesEnabled: setMatesEnabled,
478
482
  getToolPalettes: getToolPalettes,
479
483
  setToolPalette: setToolPalette,
480
484
  getDeletedBuiltinKeys: getDeletedBuiltinKeys,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clay-server",
3
- "version": "2.34.1-beta.3",
3
+ "version": "2.35.0-beta.1",
4
4
  "description": "Self-hosted Claude Code in your browser. Multi-session, multi-user, push notifications.",
5
5
  "bin": {
6
6
  "clay-server": "./bin/cli.js",