clay-server 2.11.0 → 2.12.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,440 @@
1
+ // --- In-session search (Cmd+F / Ctrl+F) ---
2
+ import { escapeHtml } from './utils.js';
3
+ import { iconHtml, refreshIcons } from './icons.js';
4
+
5
+ var ctx = null;
6
+ var searchBarEl = null;
7
+ var searchInputEl = null;
8
+ var matchCountEl = null;
9
+ var currentQuery = "";
10
+ var matches = []; // DOM highlight marks (for loaded messages)
11
+ var currentMatchIndex = -1;
12
+ var highlightClass = "session-search-highlight";
13
+ var activeHighlightClass = "session-search-highlight-active";
14
+
15
+ // Timeline state
16
+ var timelineEl = null;
17
+ var timelineScrollHandler = null;
18
+ var serverHits = []; // full history hits from server
19
+ var serverTotal = 0;
20
+ var pendingScrollTarget = null;
21
+
22
+ function initSessionSearch(context) {
23
+ ctx = context;
24
+ createSearchBar();
25
+ document.addEventListener("keydown", function (e) {
26
+ var isMod = e.metaKey || e.ctrlKey;
27
+ if (isMod && e.key === "f") {
28
+ e.preventDefault();
29
+ openSearch();
30
+ }
31
+ if (e.key === "Escape" && isSearchOpen()) {
32
+ e.preventDefault();
33
+ closeSearch();
34
+ }
35
+ });
36
+ }
37
+
38
+ function createSearchBar() {
39
+ searchBarEl = document.createElement("div");
40
+ searchBarEl.id = "session-search-bar";
41
+ searchBarEl.className = "session-search-bar hidden";
42
+ searchBarEl.innerHTML =
43
+ '<div class="session-search-inner">' +
44
+ '<input type="text" id="find-in-session-input" placeholder="Search in this session..." autocomplete="off" spellcheck="false">' +
45
+ '<span class="session-search-count" id="find-in-session-count"></span>' +
46
+ '<button class="session-search-btn" id="find-in-session-prev" title="Previous (Shift+Enter)" type="button"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg></button>' +
47
+ '<button class="session-search-btn" id="find-in-session-next" title="Next (Enter)" type="button"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button>' +
48
+ '<button class="session-search-btn" id="find-in-session-close" title="Close (Esc)" type="button"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>' +
49
+ '</div>';
50
+
51
+ // Insert at top of #app
52
+ var appEl = ctx.messagesEl.parentElement;
53
+ appEl.insertBefore(searchBarEl, appEl.firstChild);
54
+
55
+ searchInputEl = document.getElementById("find-in-session-input");
56
+ matchCountEl = document.getElementById("find-in-session-count");
57
+
58
+ var debounceTimer = null;
59
+ searchInputEl.addEventListener("input", function () {
60
+ clearTimeout(debounceTimer);
61
+ debounceTimer = setTimeout(function () {
62
+ performSearch(searchInputEl.value);
63
+ }, 250);
64
+ });
65
+
66
+ searchInputEl.addEventListener("keydown", function (e) {
67
+ if (e.key === "Enter") {
68
+ e.preventDefault();
69
+ if (e.shiftKey) {
70
+ goToPrevMatch();
71
+ } else {
72
+ goToNextMatch();
73
+ }
74
+ }
75
+ if (e.key === "Escape") {
76
+ e.preventDefault();
77
+ closeSearch();
78
+ }
79
+ });
80
+
81
+ document.getElementById("find-in-session-prev").addEventListener("click", function () {
82
+ goToPrevMatch();
83
+ });
84
+ document.getElementById("find-in-session-next").addEventListener("click", function () {
85
+ goToNextMatch();
86
+ });
87
+ document.getElementById("find-in-session-close").addEventListener("click", function () {
88
+ closeSearch();
89
+ });
90
+ }
91
+
92
+ function isSearchOpen() {
93
+ return searchBarEl && !searchBarEl.classList.contains("hidden");
94
+ }
95
+
96
+ function openSearch() {
97
+ if (!searchBarEl) return;
98
+ searchBarEl.classList.remove("hidden");
99
+ var btn = document.getElementById("find-in-session-btn");
100
+ if (btn) btn.classList.add("active");
101
+ searchInputEl.focus();
102
+ searchInputEl.select();
103
+ if (searchInputEl.value) {
104
+ performSearch(searchInputEl.value);
105
+ }
106
+ }
107
+
108
+ function closeSearch() {
109
+ if (!searchBarEl) return;
110
+ searchBarEl.classList.add("hidden");
111
+ var btn = document.getElementById("find-in-session-btn");
112
+ if (btn) btn.classList.remove("active");
113
+ clearHighlights();
114
+ removeTimeline();
115
+ currentQuery = "";
116
+ matches = [];
117
+ currentMatchIndex = -1;
118
+ serverHits = [];
119
+ serverTotal = 0;
120
+ matchCountEl.textContent = "";
121
+ }
122
+
123
+ function toggleSearch() {
124
+ if (isSearchOpen()) {
125
+ closeSearch();
126
+ } else {
127
+ openSearch();
128
+ }
129
+ }
130
+
131
+ function performSearch(query) {
132
+ clearHighlights();
133
+ removeTimeline();
134
+ matches = [];
135
+ currentMatchIndex = -1;
136
+ serverHits = [];
137
+ serverTotal = 0;
138
+ currentQuery = query.trim();
139
+
140
+ if (!currentQuery) {
141
+ matchCountEl.textContent = "";
142
+ return;
143
+ }
144
+
145
+ // Highlight in currently loaded DOM
146
+ highlightLoadedMessages();
147
+
148
+ // Request full-history search from server
149
+ if (ctx.ws && ctx.ws.readyState === 1) {
150
+ ctx.ws.send(JSON.stringify({ type: "search_session_content", query: currentQuery, source: "find_in_session" }));
151
+ }
152
+ }
153
+
154
+ function highlightLoadedMessages() {
155
+ var messagesEl = ctx.messagesEl;
156
+ var msgEls = messagesEl.querySelectorAll(".msg-user, .msg-assistant");
157
+ var queryLower = currentQuery.toLowerCase();
158
+
159
+ for (var i = 0; i < msgEls.length; i++) {
160
+ var contentEl = msgEls[i].querySelector(".bubble") || msgEls[i].querySelector(".md-content");
161
+ if (!contentEl) continue;
162
+ highlightInElement(contentEl, queryLower);
163
+ }
164
+
165
+ matches = Array.from(messagesEl.querySelectorAll("." + highlightClass));
166
+
167
+ if (matches.length > 0) {
168
+ currentMatchIndex = 0;
169
+ setActiveMatch(0);
170
+ matchCountEl.textContent = "1 / " + matches.length;
171
+ } else {
172
+ matchCountEl.textContent = "Searching...";
173
+ }
174
+ }
175
+
176
+ // Handle server response with full-history search results
177
+ function handleFindInSessionResults(msg) {
178
+ if (!isSearchOpen()) return;
179
+ if (msg.query !== currentQuery) return; // stale
180
+
181
+ serverHits = msg.hits || [];
182
+ serverTotal = msg.total || 0;
183
+
184
+ // Update count to reflect total server hits
185
+ if (serverHits.length > 0) {
186
+ // Re-highlight loaded messages to get accurate DOM match count
187
+ clearHighlights();
188
+ highlightLoadedMessages();
189
+ if (matches.length > 0) {
190
+ matchCountEl.textContent = "1 / " + matches.length + " (" + serverHits.length + " total)";
191
+ } else {
192
+ matchCountEl.textContent = serverHits.length + " matches";
193
+ }
194
+ buildTimeline();
195
+ } else {
196
+ matchCountEl.textContent = "No results";
197
+ }
198
+ }
199
+
200
+ // Called after older history is prepended to DOM
201
+ function onHistoryPrepended() {
202
+ if (!pendingScrollTarget) return;
203
+ var target = pendingScrollTarget;
204
+ pendingScrollTarget = null;
205
+ // Re-highlight with current query
206
+ if (currentQuery && isSearchOpen()) {
207
+ clearHighlights();
208
+ highlightLoadedMessages();
209
+ }
210
+ requestAnimationFrame(function() {
211
+ findAndScrollToMatch(target.snippet, target.query);
212
+ });
213
+ }
214
+
215
+ // --- Timeline (scroll map) ---
216
+ function buildTimeline() {
217
+ removeTimeline();
218
+ if (serverHits.length === 0) return;
219
+
220
+ var messagesEl = ctx.messagesEl;
221
+ var appEl = messagesEl.parentElement;
222
+
223
+ timelineEl = document.createElement("div");
224
+ timelineEl.className = "find-in-session-timeline";
225
+ timelineEl.id = "find-in-session-timeline";
226
+
227
+ var track = document.createElement("div");
228
+ track.className = "rewind-timeline-track";
229
+ track.dataset.historyTotal = serverTotal;
230
+
231
+ var viewport = document.createElement("div");
232
+ viewport.className = "rewind-timeline-viewport";
233
+ track.appendChild(viewport);
234
+
235
+ for (var i = 0; i < serverHits.length; i++) {
236
+ var hit = serverHits[i];
237
+ var pct = serverTotal <= 1 ? 50 : 6 + (hit.historyIndex / (serverTotal - 1)) * 88;
238
+
239
+ var snippetText = hit.snippet;
240
+ if (snippetText.length > 24) snippetText = snippetText.substring(0, 24) + "\u2026";
241
+
242
+ var marker = document.createElement("div");
243
+ marker.className = "rewind-timeline-marker search-hit-marker";
244
+ marker.innerHTML = iconHtml("search") + '<span class="marker-text">' + escapeHtml(snippetText) + '</span>';
245
+ marker.style.top = pct + "%";
246
+ marker.dataset.historyIndex = hit.historyIndex;
247
+
248
+ (function(hitData, markerEl) {
249
+ markerEl.addEventListener("click", function() {
250
+ scrollToSearchHit(hitData.historyIndex, hitData.snippet, currentQuery);
251
+ });
252
+ })(hit, marker);
253
+
254
+ track.appendChild(marker);
255
+ }
256
+
257
+ timelineEl.appendChild(track);
258
+
259
+ // Position timeline to align with messages area
260
+ var titleBarEl = document.querySelector(".title-bar-content");
261
+ var inputAreaEl = document.getElementById("input-area");
262
+ var appRect = appEl.getBoundingClientRect();
263
+ var titleBarRect = titleBarEl ? titleBarEl.getBoundingClientRect() : { bottom: appRect.top };
264
+ var inputRect = inputAreaEl.getBoundingClientRect();
265
+
266
+ timelineEl.style.top = (titleBarRect.bottom - appRect.top + 4) + "px";
267
+ timelineEl.style.bottom = (appRect.bottom - inputRect.top + 4) + "px";
268
+
269
+ appEl.appendChild(timelineEl);
270
+ refreshIcons();
271
+
272
+ timelineScrollHandler = function() { updateTimelineViewport(track, viewport); };
273
+ messagesEl.addEventListener("scroll", timelineScrollHandler);
274
+ updateTimelineViewport(track, viewport);
275
+ }
276
+
277
+ function removeTimeline() {
278
+ if (timelineEl) {
279
+ timelineEl.remove();
280
+ timelineEl = null;
281
+ }
282
+ if (timelineScrollHandler && ctx.messagesEl) {
283
+ ctx.messagesEl.removeEventListener("scroll", timelineScrollHandler);
284
+ timelineScrollHandler = null;
285
+ }
286
+ }
287
+
288
+ function updateTimelineViewport(track, viewport) {
289
+ if (!track) return;
290
+ var messagesEl = ctx.messagesEl;
291
+ var scrollH = messagesEl.scrollHeight;
292
+ var viewH = messagesEl.clientHeight;
293
+
294
+ var historyFrom = ctx.getHistoryFrom ? ctx.getHistoryFrom() : 0;
295
+ var total = parseInt(track.dataset.historyTotal || "0", 10) || 1;
296
+ var timelineStart = 6 + (historyFrom / (total - 1 || 1)) * 88;
297
+ var timelineEnd = 94;
298
+ var timelineRange = timelineEnd - timelineStart;
299
+
300
+ if (scrollH <= viewH) {
301
+ viewport.style.top = timelineStart + "%";
302
+ viewport.style.height = timelineRange + "%";
303
+ } else {
304
+ var scrollFrac = messagesEl.scrollTop / scrollH;
305
+ var viewFrac = viewH / scrollH;
306
+ viewport.style.top = (timelineStart + scrollFrac * timelineRange) + "%";
307
+ viewport.style.height = (viewFrac * timelineRange) + "%";
308
+ }
309
+ }
310
+
311
+ function scrollToSearchHit(historyIndex, snippet, query) {
312
+ var historyFrom = ctx.getHistoryFrom ? ctx.getHistoryFrom() : 0;
313
+ if (historyIndex < historyFrom) {
314
+ // Need to load older history first
315
+ pendingScrollTarget = { historyIndex: historyIndex, snippet: snippet, query: query };
316
+ if (ctx.ws && ctx.ws.readyState === 1) {
317
+ ctx.ws.send(JSON.stringify({ type: "load_more_history", before: historyFrom, target: historyIndex }));
318
+ }
319
+ return;
320
+ }
321
+ findAndScrollToMatch(snippet, query);
322
+ }
323
+
324
+ function findAndScrollToMatch(snippet, query) {
325
+ var messagesEl = ctx.messagesEl;
326
+ var q = query.toLowerCase();
327
+ var allMsgs = messagesEl.querySelectorAll(".msg-user, .msg-assistant");
328
+ var cleanSnippet = snippet.replace(/^\u2026/, "").replace(/\u2026$/, "");
329
+
330
+ for (var i = 0; i < allMsgs.length; i++) {
331
+ var msgEl = allMsgs[i];
332
+ var textEl = msgEl.querySelector(".bubble") || msgEl.querySelector(".md-content");
333
+ if (!textEl) continue;
334
+ var text = textEl.textContent || "";
335
+ if (text.indexOf(cleanSnippet) !== -1) {
336
+ msgEl.scrollIntoView({ behavior: "smooth", block: "center" });
337
+ msgEl.classList.remove("search-blink");
338
+ void msgEl.offsetWidth;
339
+ msgEl.classList.add("search-blink");
340
+ return;
341
+ }
342
+ }
343
+ // Fallback: any element containing the query
344
+ for (var j = 0; j < allMsgs.length; j++) {
345
+ var el = allMsgs[j];
346
+ var tEl = el.querySelector(".bubble") || el.querySelector(".md-content");
347
+ if (!tEl) continue;
348
+ if ((tEl.textContent || "").toLowerCase().indexOf(q) !== -1) {
349
+ el.scrollIntoView({ behavior: "smooth", block: "center" });
350
+ el.classList.remove("search-blink");
351
+ void el.offsetWidth;
352
+ el.classList.add("search-blink");
353
+ return;
354
+ }
355
+ }
356
+ }
357
+
358
+ // --- DOM highlighting ---
359
+ function highlightInElement(el, queryLower) {
360
+ var walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null, false);
361
+ var textNodes = [];
362
+ var node;
363
+ while ((node = walker.nextNode())) {
364
+ textNodes.push(node);
365
+ }
366
+
367
+ for (var i = 0; i < textNodes.length; i++) {
368
+ var textNode = textNodes[i];
369
+ var parent = textNode.parentNode;
370
+ if (!parent || parent.classList && parent.classList.contains(highlightClass)) continue;
371
+ if (parent.tagName === "BUTTON" || parent.tagName === "SCRIPT" || parent.tagName === "STYLE") continue;
372
+
373
+ var text = textNode.nodeValue;
374
+ var textLower = text.toLowerCase();
375
+ var idx = textLower.indexOf(queryLower);
376
+ if (idx === -1) continue;
377
+
378
+ var frag = document.createDocumentFragment();
379
+ var lastIdx = 0;
380
+
381
+ while (idx !== -1) {
382
+ if (idx > lastIdx) {
383
+ frag.appendChild(document.createTextNode(text.substring(lastIdx, idx)));
384
+ }
385
+ var mark = document.createElement("mark");
386
+ mark.className = highlightClass;
387
+ mark.textContent = text.substring(idx, idx + currentQuery.length);
388
+ frag.appendChild(mark);
389
+ lastIdx = idx + currentQuery.length;
390
+ idx = textLower.indexOf(queryLower, lastIdx);
391
+ }
392
+
393
+ if (lastIdx < text.length) {
394
+ frag.appendChild(document.createTextNode(text.substring(lastIdx)));
395
+ }
396
+
397
+ parent.replaceChild(frag, textNode);
398
+ }
399
+ }
400
+
401
+ function clearHighlights() {
402
+ var messagesEl = ctx.messagesEl;
403
+ var marks = messagesEl.querySelectorAll("mark." + highlightClass);
404
+ for (var i = 0; i < marks.length; i++) {
405
+ var mark = marks[i];
406
+ var parent = mark.parentNode;
407
+ parent.replaceChild(document.createTextNode(mark.textContent), mark);
408
+ parent.normalize();
409
+ }
410
+ matches = [];
411
+ currentMatchIndex = -1;
412
+ }
413
+
414
+ function setActiveMatch(index) {
415
+ var prev = ctx.messagesEl.querySelector("." + activeHighlightClass);
416
+ if (prev) prev.classList.remove(activeHighlightClass);
417
+
418
+ if (index >= 0 && index < matches.length) {
419
+ matches[index].classList.add(activeHighlightClass);
420
+ matches[index].scrollIntoView({ behavior: "smooth", block: "center" });
421
+ }
422
+ }
423
+
424
+ function goToNextMatch() {
425
+ if (matches.length === 0) return;
426
+ currentMatchIndex = (currentMatchIndex + 1) % matches.length;
427
+ setActiveMatch(currentMatchIndex);
428
+ matchCountEl.textContent = (currentMatchIndex + 1) + " / " + matches.length +
429
+ (serverHits.length > matches.length ? " (" + serverHits.length + " total)" : "");
430
+ }
431
+
432
+ function goToPrevMatch() {
433
+ if (matches.length === 0) return;
434
+ currentMatchIndex = (currentMatchIndex - 1 + matches.length) % matches.length;
435
+ setActiveMatch(currentMatchIndex);
436
+ matchCountEl.textContent = (currentMatchIndex + 1) + " / " + matches.length +
437
+ (serverHits.length > matches.length ? " (" + serverHits.length + " total)" : "");
438
+ }
439
+
440
+ export { initSessionSearch, openSearch, closeSearch, toggleSearch, isSearchOpen, handleFindInSessionResults, onHistoryPrepended };