clay-server 2.11.0 → 2.12.0

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/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, renderUserStrip, setCurrentDmUser, updateDmBadge, updateSessionBadge, updateProjectBadge, closeDmUserPicker, spawnDustParticles } from './modules/sidebar.js';
4
+ import { initSidebar, renderSessionList, handleSearchResults, handleSearchContentResults, updateSessionPresence, updatePageTitle, getActiveSearchQuery, buildSearchTimeline, removeSearchTimeline, onHistoryPrepended, populateCliSessionList, renderIconStrip, renderSidebarPresence, initIconStrip, getEmojiCategories, renderUserStrip, setCurrentDmUser, updateDmBadge, updateSessionBadge, updateProjectBadge, closeDmUserPicker, spawnDustParticles } 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';
@@ -20,6 +20,8 @@ import { initPlaybook, openPlaybook, getPlaybooks, getPlaybookForTip, isComplete
20
20
  import { initSTT } from './modules/stt.js';
21
21
  import { initProfile } from './modules/profile.js';
22
22
  import { initAdmin, checkAdminAccess } from './modules/admin.js';
23
+ import { initSessionSearch, toggleSearch, closeSearch, isSearchOpen, handleFindInSessionResults, onHistoryPrepended as onSessionSearchHistoryPrepended } from './modules/session-search.js';
24
+ import { initTooltips, registerTooltip } from './modules/tooltip.js';
23
25
 
24
26
  // --- Base path for multi-project routing ---
25
27
  var slugMatch = location.pathname.match(/^\/p\/([a-z0-9_-]+)/);
@@ -32,7 +34,8 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
32
34
  var inputEl = $("input");
33
35
  var sendBtn = $("send-btn");
34
36
  function getStatusDot() {
35
- return document.querySelector("#icon-strip-projects .icon-strip-item.active .icon-strip-status");
37
+ return document.querySelector("#icon-strip-projects .icon-strip-item.active .icon-strip-status") ||
38
+ document.querySelector("#icon-strip-projects .icon-strip-wt-item.active .icon-strip-status");
36
39
  }
37
40
  var headerTitleEl = $("header-title");
38
41
  var headerRenameBtn = $("header-rename-btn");
@@ -902,7 +905,18 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
902
905
  function renderProjectList() {
903
906
  // Render icon strip projects
904
907
  var iconStripProjects = cachedProjects.map(function (p) {
905
- return { slug: p.slug, name: p.title || p.project, icon: p.icon || null, isProcessing: p.isProcessing, onlineUsers: p.onlineUsers || [], unread: p.unread || 0 };
908
+ return {
909
+ slug: p.slug,
910
+ name: p.title || p.project,
911
+ icon: p.icon || null,
912
+ isProcessing: p.isProcessing,
913
+ onlineUsers: p.onlineUsers || [],
914
+ unread: p.unread || 0,
915
+ isWorktree: p.isWorktree || false,
916
+ parentSlug: p.parentSlug || null,
917
+ branch: p.branch || null,
918
+ worktreeAccessible: p.worktreeAccessible !== undefined ? p.worktreeAccessible : true,
919
+ };
906
920
  });
907
921
  renderIconStrip(iconStripProjects, currentSlug);
908
922
  // Update title bar project name and icon if it changed
@@ -1214,6 +1228,9 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
1214
1228
  // --- Theme (module) ---
1215
1229
  initTheme();
1216
1230
 
1231
+ // --- Tooltips ---
1232
+ initTooltips();
1233
+
1217
1234
  // --- Sidebar (module) ---
1218
1235
  var sidebarCtx = {
1219
1236
  $: $,
@@ -1244,6 +1261,7 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
1244
1261
  openAddProjectModal: function () { openAddProjectModal(); },
1245
1262
  sendWs: function (msg) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(msg)); },
1246
1263
  onDmRemoveUser: function (userId) { dmRemovedUsers[userId] = true; },
1264
+ getHistoryFrom: function () { return historyFrom; },
1247
1265
  };
1248
1266
  initSidebar(sidebarCtx);
1249
1267
  initIconStrip(sidebarCtx);
@@ -1362,12 +1380,21 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
1362
1380
  // Also blink the active session's processing dot in sidebar
1363
1381
  var sessionDot = document.querySelector(".session-item.active .session-processing");
1364
1382
  if (sessionDot) sessionDot.classList.add("io");
1383
+ // If active project is a worktree, also blink the parent project dot
1384
+ var activeWt = document.querySelector("#icon-strip-projects .icon-strip-wt-item.active");
1385
+ var parentDot = null;
1386
+ if (activeWt) {
1387
+ var group = activeWt.closest(".icon-strip-group");
1388
+ if (group) parentDot = group.querySelector(".folder-header .icon-strip-status");
1389
+ if (parentDot) parentDot.classList.add("io");
1390
+ }
1365
1391
  clearTimeout(ioTimer);
1366
1392
  ioTimer = setTimeout(function () {
1367
1393
  var d = getStatusDot();
1368
1394
  if (d) d.classList.remove("io");
1369
1395
  var sd = document.querySelector(".session-item.active .session-processing.io");
1370
1396
  if (sd) sd.classList.remove("io");
1397
+ if (parentDot) parentDot.classList.remove("io");
1371
1398
  }, 80);
1372
1399
  }
1373
1400
 
@@ -1389,7 +1416,7 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
1389
1416
  function updateCrossProjectBlink() {
1390
1417
  if (crossProjectBlinkTimer) { clearTimeout(crossProjectBlinkTimer); crossProjectBlinkTimer = null; }
1391
1418
  function doBlink() {
1392
- var dots = document.querySelectorAll("#icon-strip-projects .icon-strip-item:not(.active) .icon-strip-status.processing");
1419
+ var dots = document.querySelectorAll("#icon-strip-projects .icon-strip-item:not(.active) .icon-strip-status.processing, #icon-strip-projects .icon-strip-wt-item:not(.active) .icon-strip-status.processing");
1393
1420
  if (dots.length === 0) { crossProjectBlinkTimer = null; return; }
1394
1421
  for (var i = 0; i < dots.length; i++) { dots[i].classList.add("io"); }
1395
1422
  setTimeout(function () {
@@ -2522,6 +2549,16 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
2522
2549
  sessionHint.textContent = "After logging in, start a new session to continue.";
2523
2550
  div.appendChild(sessionHint);
2524
2551
 
2552
+ var loginBtn = document.createElement("button");
2553
+ loginBtn.className = "auth-required-btn";
2554
+ loginBtn.textContent = "Open terminal & log in";
2555
+ loginBtn.addEventListener("click", function () {
2556
+ pendingTermCommand = "claude\n";
2557
+ ws.send(JSON.stringify({ type: "term_create", cols: 80, rows: 24 }));
2558
+ openTerminal();
2559
+ });
2560
+ div.appendChild(loginBtn);
2561
+
2525
2562
  addToMessages(div);
2526
2563
  scrollToBottom();
2527
2564
 
@@ -2734,6 +2771,7 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
2734
2771
  }
2735
2772
 
2736
2773
  function resetClientState() {
2774
+ if (isSearchOpen()) closeSearch();
2737
2775
  messagesEl.innerHTML = "";
2738
2776
  currentMsgEl = null;
2739
2777
  currentFullText = "";
@@ -3164,6 +3202,18 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
3164
3202
  updateSessionPresence(msg.presence || {});
3165
3203
  break;
3166
3204
 
3205
+ case "cursor_move":
3206
+ handleRemoteCursorMove(msg);
3207
+ break;
3208
+
3209
+ case "cursor_leave":
3210
+ handleRemoteCursorLeave(msg);
3211
+ break;
3212
+
3213
+ case "text_select":
3214
+ handleRemoteSelection(msg);
3215
+ break;
3216
+
3167
3217
  case "session_io":
3168
3218
  blinkSessionDot(msg.id);
3169
3219
  break;
@@ -3176,6 +3226,14 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
3176
3226
  handleSearchResults(msg);
3177
3227
  break;
3178
3228
 
3229
+ case "search_content_results":
3230
+ if (msg.source === "find_in_session") {
3231
+ handleFindInSessionResults(msg);
3232
+ } else {
3233
+ handleSearchContentResults(msg);
3234
+ }
3235
+ break;
3236
+
3179
3237
  case "cli_session_list":
3180
3238
  populateCliSessionList(msg.sessions || []);
3181
3239
  break;
@@ -3190,9 +3248,13 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
3190
3248
  }
3191
3249
  activeSessionId = msg.id;
3192
3250
  cliSessionId = msg.cliSessionId || null;
3251
+ clearRemoteCursors();
3193
3252
  resetClientState();
3194
3253
  updateRalphBars();
3195
3254
  updateLoopInputVisibility(msg.loop);
3255
+ // Restore input area visibility (may have been hidden by auth_required)
3256
+ var inputAreaSw = document.getElementById("input-area");
3257
+ if (inputAreaSw) inputAreaSw.classList.remove("hidden");
3196
3258
  // Restore draft for incoming session
3197
3259
  var draft = sessionDrafts[activeSessionId] || "";
3198
3260
  inputEl.value = draft;
@@ -3965,6 +4027,10 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
3965
4027
  } else {
3966
4028
  updateHistorySentinel();
3967
4029
  }
4030
+
4031
+ // Notify sidebar search that history was prepended (for pending scroll targets)
4032
+ onHistoryPrepended();
4033
+ onSessionSearchHistoryPrepended();
3968
4034
  }
3969
4035
 
3970
4036
  function scheduleReconnect() {
@@ -4133,6 +4199,7 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
4133
4199
  if (d.multiUser) isMultiUserMode = true;
4134
4200
  if (d.user && d.user.id) myUserId = d.user.id;
4135
4201
  if (d.mustChangePin) showForceChangePinOverlay();
4202
+ initCursorToggle();
4136
4203
  }).catch(function () {});
4137
4204
  // Hide server settings and update controls for non-admin users in multi-user mode
4138
4205
  checkAdminAccess().then(function (isAdmin) {
@@ -4213,6 +4280,19 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
4213
4280
  // --- Playbook Engine ---
4214
4281
  initPlaybook();
4215
4282
 
4283
+ // --- In-session search (Cmd+F / Ctrl+F) ---
4284
+ initSessionSearch({
4285
+ messagesEl: messagesEl,
4286
+ get ws() { return ws; },
4287
+ getHistoryFrom: function () { return historyFrom; },
4288
+ });
4289
+ var findInSessionBtn = $("find-in-session-btn");
4290
+ if (findInSessionBtn) {
4291
+ findInSessionBtn.addEventListener("click", function () {
4292
+ toggleSearch();
4293
+ });
4294
+ }
4295
+
4216
4296
  // --- Sticky Notes ---
4217
4297
  initStickyNotes({
4218
4298
  get ws() { return ws; },
@@ -5090,7 +5170,11 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
5090
5170
  showRemoveProjectTaskDialog(slug, name, msg.count);
5091
5171
  } else {
5092
5172
  // No tasks — confirm then particle burst + remove
5093
- showConfirm('Remove "' + name + '"? You can re-add it later.', function () {
5173
+ var isWt = slug.indexOf("--") !== -1;
5174
+ var confirmMsg = isWt
5175
+ ? 'Delete worktree "' + name + '"? The branch and working directory will be removed from disk.'
5176
+ : 'Remove "' + name + '"? You can re-add it later.';
5177
+ showConfirm(confirmMsg, function () {
5094
5178
  // Find the icon strip item to anchor the particle burst
5095
5179
  var iconEl = document.querySelector('.icon-strip-item[data-slug="' + slug + '"]');
5096
5180
  if (iconEl) {
@@ -5166,26 +5250,33 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
5166
5250
 
5167
5251
  function handleRemoveProjectResult(msg) {
5168
5252
  if (msg.ok) {
5169
- showToast("Project removed", "success");
5170
- // If we removed the current project, go to home hub without full reload
5253
+ // If we removed the current project, navigate away
5171
5254
  if (msg.slug === currentSlug) {
5255
+ // Check if this is a worktree: navigate to parent project instead of home hub
5256
+ var isWorktree = msg.slug.indexOf("--") !== -1;
5257
+ var parentSlug = isWorktree ? msg.slug.split("--")[0] : null;
5258
+
5259
+ showToast(isWorktree ? "Worktree removed" : "Project removed", "success");
5260
+
5172
5261
  // Suppress disconnect overlay and reconnect by detaching the WS
5173
5262
  if (ws) { ws.onclose = null; ws.onerror = null; ws.close(); ws = null; }
5174
5263
  if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
5175
5264
  connected = false;
5176
5265
  connectOverlay.classList.add("hidden");
5177
- // Add to cached removed projects for re-add UI
5178
- var removedProj = null;
5179
- for (var ri = 0; ri < cachedProjects.length; ri++) {
5180
- if (cachedProjects[ri].slug === msg.slug) { removedProj = cachedProjects[ri]; break; }
5181
- }
5182
- if (removedProj) {
5183
- cachedRemovedProjects.push({
5184
- path: removedProj.path || "",
5185
- title: removedProj.title || null,
5186
- icon: removedProj.icon || null,
5187
- removedAt: Date.now(),
5188
- });
5266
+ if (!isWorktree) {
5267
+ // Add to cached removed projects for re-add UI
5268
+ var removedProj = null;
5269
+ for (var ri = 0; ri < cachedProjects.length; ri++) {
5270
+ if (cachedProjects[ri].slug === msg.slug) { removedProj = cachedProjects[ri]; break; }
5271
+ }
5272
+ if (removedProj) {
5273
+ cachedRemovedProjects.push({
5274
+ path: removedProj.path || "",
5275
+ title: removedProj.title || null,
5276
+ icon: removedProj.icon || null,
5277
+ removedAt: Date.now(),
5278
+ });
5279
+ }
5189
5280
  }
5190
5281
  // Remove from cached projects and re-render icon strip
5191
5282
  cachedProjects = cachedProjects.filter(function (p) { return p.slug !== msg.slug; });
@@ -5193,7 +5284,14 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
5193
5284
  currentSlug = null;
5194
5285
  renderProjectList();
5195
5286
  resetClientState();
5196
- showHomeHub();
5287
+
5288
+ if (parentSlug && switchProject) {
5289
+ switchProject(parentSlug);
5290
+ } else {
5291
+ showHomeHub();
5292
+ }
5293
+ } else {
5294
+ showToast(msg.slug.indexOf("--") !== -1 ? "Worktree removed" : "Project removed", "success");
5197
5295
  }
5198
5296
  } else {
5199
5297
  showToast(msg.error || "Failed to remove project", "error");
@@ -5596,6 +5694,455 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
5596
5694
  });
5597
5695
  })();
5598
5696
 
5697
+ // --- Remote Cursor Presence ---
5698
+ var cursorSharingEnabled = localStorage.getItem("cursorSharing") !== "off";
5699
+ var remoteCursors = {}; // userId -> { el, timer }
5700
+ var cursorThrottleTimer = null;
5701
+ var CURSOR_THROTTLE_MS = 30;
5702
+ var CURSOR_HIDE_TIMEOUT = 5000;
5703
+
5704
+ // Cursor sharing toggle button in user island (multi-user only)
5705
+ function initCursorToggle() {
5706
+ if (!isMultiUserMode) return;
5707
+ var actionsEl = document.querySelector(".user-island-actions");
5708
+ if (!actionsEl) return;
5709
+ if (document.getElementById("cursor-share-toggle")) return;
5710
+
5711
+ var btn = document.createElement("button");
5712
+ btn.id = "cursor-share-toggle";
5713
+ btn.className = "cursor-share-btn";
5714
+ btn.innerHTML = '<i data-lucide="mouse-pointer-2"></i>';
5715
+ actionsEl.appendChild(btn);
5716
+
5717
+ function updateToggleStyle() {
5718
+ if (cursorSharingEnabled) {
5719
+ btn.classList.remove("off");
5720
+ btn.classList.add("on");
5721
+ registerTooltip(btn, "Cursor sharing on");
5722
+ } else {
5723
+ btn.classList.remove("on");
5724
+ btn.classList.add("off");
5725
+ registerTooltip(btn, "Cursor sharing off");
5726
+ }
5727
+ }
5728
+
5729
+ updateToggleStyle();
5730
+ lucide.createIcons({ nodes: [btn] });
5731
+
5732
+ btn.addEventListener("click", function () {
5733
+ cursorSharingEnabled = !cursorSharingEnabled;
5734
+ localStorage.setItem("cursorSharing", cursorSharingEnabled ? "on" : "off");
5735
+ updateToggleStyle();
5736
+ if (!cursorSharingEnabled && ws && ws.readyState === 1) {
5737
+ ws.send(JSON.stringify({ type: "cursor_leave" }));
5738
+ ws.send(JSON.stringify({ type: "text_select", ranges: [] }));
5739
+ }
5740
+ });
5741
+ }
5742
+
5743
+ // Unique colors for remote cursors (Figma-style)
5744
+ var cursorColors = [
5745
+ "#F24822", "#FF7262", "#A259FF", "#1ABCFE",
5746
+ "#0ACF83", "#FF6D00", "#E84393", "#6C5CE7",
5747
+ "#00B894", "#FDCB6E", "#E17055", "#74B9FF",
5748
+ ];
5749
+ var userColorMap = {};
5750
+ var nextColorIdx = 0;
5751
+
5752
+ function getCursorColor(userId) {
5753
+ if (!userColorMap[userId]) {
5754
+ userColorMap[userId] = cursorColors[nextColorIdx % cursorColors.length];
5755
+ nextColorIdx++;
5756
+ }
5757
+ return userColorMap[userId];
5758
+ }
5759
+
5760
+ function createCursorElement(userId, displayName, color, avatarStyle, avatarSeed) {
5761
+ var wrapper = document.createElement("div");
5762
+ wrapper.className = "remote-cursor";
5763
+ wrapper.dataset.userId = userId;
5764
+ wrapper.style.position = "absolute";
5765
+ wrapper.style.zIndex = "9999";
5766
+ wrapper.style.pointerEvents = "none";
5767
+ wrapper.style.display = "none";
5768
+ wrapper.style.transition = "left 30ms linear, top 30ms linear";
5769
+ wrapper.style.willChange = "left, top";
5770
+
5771
+ // SVG cursor arrow
5772
+ var svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
5773
+ svg.setAttribute("width", "16");
5774
+ svg.setAttribute("height", "20");
5775
+ svg.setAttribute("viewBox", "0 0 16 20");
5776
+ svg.style.display = "block";
5777
+ svg.style.filter = "drop-shadow(0 1px 2px rgba(0,0,0,0.3))";
5778
+ var path = document.createElementNS("http://www.w3.org/2000/svg", "path");
5779
+ path.setAttribute("d", "M0 0 L0 16 L4.5 12 L8 19 L10.5 18 L7 11 L13 11 Z");
5780
+ path.setAttribute("fill", color);
5781
+ path.setAttribute("stroke", "#fff");
5782
+ path.setAttribute("stroke-width", "1");
5783
+ svg.appendChild(path);
5784
+ wrapper.appendChild(svg);
5785
+
5786
+ // Tag: avatar + name label together
5787
+ var tag = document.createElement("div");
5788
+ tag.className = "remote-cursor-tag";
5789
+ tag.style.cssText = "position:absolute;left:14px;top:14px;display:flex;align-items:center;" +
5790
+ "gap:3px;background:" + color + ";padding:1px 6px 1px 2px;border-radius:10px;" +
5791
+ "pointer-events:none;white-space:nowrap;";
5792
+
5793
+ // Avatar
5794
+ var avatarImg = document.createElement("img");
5795
+ avatarImg.className = "remote-cursor-avatar";
5796
+ var style = avatarStyle || "thumbs";
5797
+ var seed = avatarSeed || userId;
5798
+ avatarImg.src = "https://api.dicebear.com/9.x/" + style + "/svg?seed=" + encodeURIComponent(seed) + "&size=16";
5799
+ avatarImg.style.cssText = "width:14px;height:14px;border-radius:50%;background:#fff;flex-shrink:0;";
5800
+ tag.appendChild(avatarImg);
5801
+
5802
+ // Name label
5803
+ var label = document.createElement("span");
5804
+ label.className = "remote-cursor-label";
5805
+ label.textContent = displayName;
5806
+ label.style.cssText = "color:#fff;font-size:11px;font-weight:500;line-height:16px;font-family:inherit;";
5807
+ tag.appendChild(label);
5808
+
5809
+ wrapper.appendChild(tag);
5810
+
5811
+ return wrapper;
5812
+ }
5813
+
5814
+
5815
+ // Compute cumulative character offset within a container element
5816
+ function getCharOffset(container, targetNode, targetOffset) {
5817
+ var walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, null, false);
5818
+ var offset = 0;
5819
+ var node;
5820
+ while ((node = walker.nextNode())) {
5821
+ if (node === targetNode) {
5822
+ return offset + targetOffset;
5823
+ }
5824
+ offset += node.textContent.length;
5825
+ }
5826
+ return offset;
5827
+ }
5828
+
5829
+ // Find text node + local offset for a given cumulative character offset
5830
+ function getNodeAtCharOffset(container, charOffset) {
5831
+ var walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, null, false);
5832
+ var consumed = 0;
5833
+ var node;
5834
+ var lastNode = null;
5835
+ while ((node = walker.nextNode())) {
5836
+ lastNode = node;
5837
+ var len = node.textContent.length;
5838
+ if (consumed + len >= charOffset) {
5839
+ return { node: node, offset: Math.min(charOffset - consumed, len) };
5840
+ }
5841
+ consumed += len;
5842
+ }
5843
+ if (lastNode) {
5844
+ return { node: lastNode, offset: lastNode.textContent.length };
5845
+ }
5846
+ return null;
5847
+ }
5848
+
5849
+ // Find parent [data-turn] element from a DOM node
5850
+ function findParentTurn(node) {
5851
+ var el = node.nodeType === 3 ? node.parentElement : node;
5852
+ while (el && el !== messagesEl) {
5853
+ if (el.dataset && el.dataset.turn != null) return el;
5854
+ el = el.parentElement;
5855
+ }
5856
+ return null;
5857
+ }
5858
+
5859
+ // --- Remote Selection Highlight ---
5860
+ var remoteSelections = {}; // userId -> { els: [], timer }
5861
+
5862
+ function clearRemoteSelection(userId) {
5863
+ var sel = remoteSelections[userId];
5864
+ if (!sel) return;
5865
+ for (var i = 0; i < sel.els.length; i++) {
5866
+ if (sel.els[i].parentNode) sel.els[i].parentNode.removeChild(sel.els[i]);
5867
+ }
5868
+ sel.els = [];
5869
+ }
5870
+
5871
+ function handleRemoteSelection(msg) {
5872
+ var userId = msg.userId;
5873
+ var color = getCursorColor(userId);
5874
+
5875
+ if (!remoteSelections[userId]) {
5876
+ remoteSelections[userId] = { els: [], timer: null };
5877
+ }
5878
+
5879
+ // Clear previous highlight
5880
+ clearRemoteSelection(userId);
5881
+
5882
+ // If selection cleared, just remove
5883
+ if (!msg.ranges || msg.ranges.length === 0) return;
5884
+
5885
+ var containerRect = messagesEl.getBoundingClientRect();
5886
+
5887
+ for (var r = 0; r < msg.ranges.length; r++) {
5888
+ var sel = msg.ranges[r];
5889
+ var startTurnEl = messagesEl.querySelector('[data-turn="' + sel.startTurn + '"]');
5890
+ var endTurnEl = messagesEl.querySelector('[data-turn="' + sel.endTurn + '"]');
5891
+ if (!startTurnEl || !endTurnEl) continue;
5892
+
5893
+ var startResult = getNodeAtCharOffset(startTurnEl, sel.startCh);
5894
+ var endResult = getNodeAtCharOffset(endTurnEl, sel.endCh);
5895
+ if (!startResult || !endResult) continue;
5896
+
5897
+ try {
5898
+ var range = document.createRange();
5899
+ range.setStart(startResult.node, startResult.offset);
5900
+ range.setEnd(endResult.node, endResult.offset);
5901
+ var rects = range.getClientRects();
5902
+
5903
+ for (var i = 0; i < rects.length; i++) {
5904
+ var rect = rects[i];
5905
+ if (rect.width === 0 && rect.height === 0) continue;
5906
+ var highlight = document.createElement("div");
5907
+ highlight.className = "remote-selection";
5908
+ highlight.dataset.userId = userId;
5909
+ highlight.style.cssText =
5910
+ "position:absolute;pointer-events:none;z-index:9998;" +
5911
+ "background:" + color + ";" +
5912
+ "opacity:0.2;" +
5913
+ "border-radius:2px;" +
5914
+ "left:" + (rect.left - containerRect.left + messagesEl.scrollLeft) + "px;" +
5915
+ "top:" + (rect.top - containerRect.top + messagesEl.scrollTop) + "px;" +
5916
+ "width:" + rect.width + "px;" +
5917
+ "height:" + rect.height + "px;";
5918
+ messagesEl.appendChild(highlight);
5919
+ remoteSelections[userId].els.push(highlight);
5920
+ }
5921
+ } catch (e) {}
5922
+ }
5923
+
5924
+ // Auto-hide after timeout
5925
+ if (remoteSelections[userId].timer) clearTimeout(remoteSelections[userId].timer);
5926
+ remoteSelections[userId].timer = setTimeout(function () {
5927
+ clearRemoteSelection(userId);
5928
+ }, 10000);
5929
+ }
5930
+
5931
+ function createOffscreenIndicator(userId, displayName, color) {
5932
+ var btn = document.createElement("button");
5933
+ btn.className = "remote-cursor-offscreen";
5934
+ btn.dataset.userId = userId;
5935
+ btn.style.cssText =
5936
+ "position:absolute;left:50%;transform:translateX(-50%);" +
5937
+ "z-index:10000;display:none;cursor:pointer;border:none;outline:none;" +
5938
+ "background:" + color + ";color:#fff;font-size:11px;font-weight:500;" +
5939
+ "padding:3px 10px 3px 8px;border-radius:12px;white-space:nowrap;" +
5940
+ "font-family:inherit;line-height:16px;opacity:0.9;" +
5941
+ "box-shadow:0 2px 8px rgba(0,0,0,0.2);pointer-events:auto;" +
5942
+ "transition:opacity 0.15s;";
5943
+ btn.addEventListener("mouseenter", function () { btn.style.opacity = "1"; });
5944
+ btn.addEventListener("mouseleave", function () { btn.style.opacity = "0.9"; });
5945
+ return btn;
5946
+ }
5947
+
5948
+ function updateCursorVisibility(entry) {
5949
+ var visibleTop = messagesEl.scrollTop;
5950
+ var visibleBottom = visibleTop + messagesEl.clientHeight;
5951
+ var y = entry.lastY || 0;
5952
+
5953
+ if (y < visibleTop) {
5954
+ entry.indicator.style.top = (visibleTop + 6) + "px";
5955
+ entry.indicator.style.display = "";
5956
+ } else if (y > visibleBottom) {
5957
+ entry.indicator.style.top = (visibleBottom - 28) + "px";
5958
+ entry.indicator.style.display = "";
5959
+ } else {
5960
+ entry.indicator.style.display = "none";
5961
+ }
5962
+ }
5963
+
5964
+ function handleRemoteCursorMove(msg) {
5965
+ var userId = msg.userId;
5966
+
5967
+ var entry = remoteCursors[userId];
5968
+ if (!entry) {
5969
+ var color = getCursorColor(userId);
5970
+ var el = createCursorElement(userId, msg.displayName, color, msg.avatarStyle, msg.avatarSeed);
5971
+ messagesEl.appendChild(el);
5972
+ var indicator = createOffscreenIndicator(userId, msg.displayName, color);
5973
+ messagesEl.appendChild(indicator);
5974
+ entry = { el: el, indicator: indicator, timer: null, lastY: 0, active: false };
5975
+ remoteCursors[userId] = entry;
5976
+
5977
+ indicator.addEventListener("click", function () {
5978
+ messagesEl.scrollTo({ top: entry.lastY - messagesEl.clientHeight / 2, behavior: "smooth" });
5979
+ });
5980
+ }
5981
+
5982
+ // Find the same turn element on this screen
5983
+ var anchorEl = null;
5984
+ if (msg.turn != null) {
5985
+ anchorEl = messagesEl.querySelector('[data-turn="' + msg.turn + '"]');
5986
+ }
5987
+
5988
+ if (anchorEl && msg.rx != null && msg.ry != null) {
5989
+ var x = anchorEl.offsetLeft + msg.rx * anchorEl.offsetWidth;
5990
+ var y = anchorEl.offsetTop + msg.ry * anchorEl.offsetHeight;
5991
+ entry.lastY = y;
5992
+ entry.active = true;
5993
+
5994
+ // Update indicator label (direction set by updateCursorVisibility)
5995
+ entry.indicator.textContent = (y < messagesEl.scrollTop ? "▲ " : "▼ ") + (msg.displayName || userId);
5996
+
5997
+ entry.el.style.left = x + "px";
5998
+ entry.el.style.top = y + "px";
5999
+ entry.el.style.display = "";
6000
+
6001
+ updateCursorVisibility(entry);
6002
+ }
6003
+
6004
+ // Reset hide timer
6005
+ if (entry.timer) clearTimeout(entry.timer);
6006
+ entry.timer = setTimeout(function () {
6007
+ entry.el.style.display = "none";
6008
+ entry.indicator.style.display = "none";
6009
+ entry.active = false;
6010
+ }, CURSOR_HIDE_TIMEOUT);
6011
+ }
6012
+
6013
+ function handleRemoteCursorLeave(msg) {
6014
+ var entry = remoteCursors[msg.userId];
6015
+ if (entry) {
6016
+ entry.el.style.display = "none";
6017
+ entry.indicator.style.display = "none";
6018
+ entry.active = false;
6019
+ if (entry.timer) clearTimeout(entry.timer);
6020
+ }
6021
+ }
6022
+
6023
+ // Find the closest [data-turn] element to a given clientY
6024
+ function findClosestTurn(clientY) {
6025
+ var turns = messagesEl.querySelectorAll("[data-turn]");
6026
+ if (!turns.length) return null;
6027
+ // First: exact hit
6028
+ for (var i = 0; i < turns.length; i++) {
6029
+ var r = turns[i].getBoundingClientRect();
6030
+ if (clientY >= r.top && clientY <= r.bottom) return turns[i];
6031
+ }
6032
+ // Second: closest by distance
6033
+ var closest = null;
6034
+ var closestDist = Infinity;
6035
+ for (var j = 0; j < turns.length; j++) {
6036
+ var rect = turns[j].getBoundingClientRect();
6037
+ var mid = (rect.top + rect.bottom) / 2;
6038
+ var dist = Math.abs(clientY - mid);
6039
+ if (dist < closestDist) { closestDist = dist; closest = turns[j]; }
6040
+ }
6041
+ return closest;
6042
+ }
6043
+
6044
+
6045
+ // Track local cursor and send to server
6046
+ messagesEl.addEventListener("mousemove", function (e) {
6047
+ if (!cursorSharingEnabled) return;
6048
+ if (!ws || ws.readyState !== 1) return;
6049
+ if (cursorThrottleTimer) return;
6050
+ cursorThrottleTimer = setTimeout(function () { cursorThrottleTimer = null; }, CURSOR_THROTTLE_MS);
6051
+
6052
+ // Find which turn element the cursor is over
6053
+ var turnEl = findClosestTurn(e.clientY);
6054
+ if (!turnEl) return;
6055
+
6056
+ // Calculate ratio within the turn element
6057
+ var turnRect = turnEl.getBoundingClientRect();
6058
+ var rx = turnRect.width > 0 ? (e.clientX - turnRect.left) / turnRect.width : 0;
6059
+ var ry = turnRect.height > 0 ? (e.clientY - turnRect.top) / turnRect.height : 0;
6060
+
6061
+ ws.send(JSON.stringify({
6062
+ type: "cursor_move",
6063
+ turn: parseInt(turnEl.dataset.turn, 10),
6064
+ rx: Math.max(0, Math.min(1, rx)),
6065
+ ry: Math.max(0, Math.min(1, ry))
6066
+ }));
6067
+ });
6068
+
6069
+ messagesEl.addEventListener("mouseleave", function () {
6070
+ if (!cursorSharingEnabled) return;
6071
+ if (!ws || ws.readyState !== 1) return;
6072
+ ws.send(JSON.stringify({ type: "cursor_leave" }));
6073
+ });
6074
+
6075
+ // Update offscreen indicators on scroll
6076
+ messagesEl.addEventListener("scroll", function () {
6077
+ for (var uid in remoteCursors) {
6078
+ var entry = remoteCursors[uid];
6079
+ if (!entry.active) continue;
6080
+ updateCursorVisibility(entry);
6081
+ }
6082
+ });
6083
+
6084
+ // Track local text selection and send to server
6085
+ var selectionThrottleTimer = null;
6086
+ var lastSelectionKey = "";
6087
+ document.addEventListener("selectionchange", function () {
6088
+ if (!cursorSharingEnabled) return;
6089
+ if (!ws || ws.readyState !== 1) return;
6090
+ if (selectionThrottleTimer) return;
6091
+ selectionThrottleTimer = setTimeout(function () { selectionThrottleTimer = null; }, 100);
6092
+
6093
+ var sel = window.getSelection();
6094
+ if (!sel || sel.rangeCount === 0 || sel.isCollapsed) {
6095
+ // Selection cleared
6096
+ if (lastSelectionKey !== "") {
6097
+ lastSelectionKey = "";
6098
+ ws.send(JSON.stringify({ type: "text_select", ranges: [] }));
6099
+ }
6100
+ return;
6101
+ }
6102
+
6103
+ var ranges = [];
6104
+ for (var i = 0; i < sel.rangeCount; i++) {
6105
+ var range = sel.getRangeAt(i);
6106
+ var startTurn = findParentTurn(range.startContainer);
6107
+ var endTurn = findParentTurn(range.endContainer);
6108
+ if (!startTurn || !endTurn) continue;
6109
+ // Both must be inside messagesEl
6110
+ if (!messagesEl.contains(startTurn)) continue;
6111
+
6112
+ var startCh = getCharOffset(startTurn, range.startContainer, range.startOffset);
6113
+ var endCh = getCharOffset(endTurn, range.endContainer, range.endOffset);
6114
+
6115
+ ranges.push({
6116
+ startTurn: parseInt(startTurn.dataset.turn, 10),
6117
+ startCh: startCh,
6118
+ endTurn: parseInt(endTurn.dataset.turn, 10),
6119
+ endCh: endCh
6120
+ });
6121
+ }
6122
+
6123
+ var key = JSON.stringify(ranges);
6124
+ if (key === lastSelectionKey) return;
6125
+ lastSelectionKey = key;
6126
+
6127
+ ws.send(JSON.stringify({ type: "text_select", ranges: ranges }));
6128
+ });
6129
+
6130
+ // Clean up remote cursors and selections when switching sessions
6131
+ function clearRemoteCursors() {
6132
+ for (var uid in remoteCursors) {
6133
+ var entry = remoteCursors[uid];
6134
+ if (entry.timer) clearTimeout(entry.timer);
6135
+ if (entry.el.parentNode) entry.el.parentNode.removeChild(entry.el);
6136
+ if (entry.indicator && entry.indicator.parentNode) entry.indicator.parentNode.removeChild(entry.indicator);
6137
+ }
6138
+ remoteCursors = {};
6139
+ for (var uid2 in remoteSelections) {
6140
+ clearRemoteSelection(uid2);
6141
+ if (remoteSelections[uid2].timer) clearTimeout(remoteSelections[uid2].timer);
6142
+ }
6143
+ remoteSelections = {};
6144
+ }
6145
+
5599
6146
  // --- Init ---
5600
6147
  lucide.createIcons();
5601
6148
  connect();