clay-server 2.32.0 → 2.32.1-beta.2

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.
@@ -1,6 +1,7 @@
1
1
  import { closeArchive } from './sticky-notes.js';
2
2
  import { closeScheduler } from './scheduler.js';
3
3
  import { initSidebarSessions } from './sidebar-sessions.js';
4
+ import { focusFileTree } from './filebrowser.js';
4
5
  import { initSidebarProjects, closeProjectCtxMenu } from './sidebar-projects.js';
5
6
  import {
6
7
  initSidebarMates,
@@ -108,15 +109,6 @@ export function initSidebar(_ctx) {
108
109
  });
109
110
  }
110
111
 
111
- // --- Tools section collapse/expand ---
112
- var toolsToggle = ctx.$("sidebar-tools-toggle");
113
- var toolsSection = ctx.$("sidebar-tools");
114
- if (toolsToggle && toolsSection) {
115
- toolsToggle.addEventListener("click", function () {
116
- toolsSection.classList.toggle("collapsed");
117
- });
118
- }
119
-
120
112
  // --- Panel switch (sessions / files / projects) ---
121
113
  var fileBrowserBtn = ctx.$("file-browser-btn");
122
114
  var projectsPanel = ctx.$("sidebar-panel-projects");
@@ -151,6 +143,9 @@ export function initSidebar(_ctx) {
151
143
  filesPanel.classList.add("fb-enter");
152
144
  }
153
145
  if (ctx.onFilesTabOpen) ctx.onFilesTabOpen();
146
+ // Hand focus to the file tree so arrow keys work immediately
147
+ // after the panel opens (no click-to-focus extra step).
148
+ requestAnimationFrame(function () { focusFileTree(); });
154
149
  }
155
150
 
156
151
  function hideFilesPanel(cb) {
@@ -167,7 +162,16 @@ export function initSidebar(_ctx) {
167
162
  }
168
163
 
169
164
  if (fileBrowserBtn) {
170
- fileBrowserBtn.addEventListener("click", showFilesPanel);
165
+ // Clicking the tile toggles: open if closed, close (back to sessions)
166
+ // if it's already the visible panel. Same behavior via the Cmd+O
167
+ // hotkey since that calls .click().
168
+ fileBrowserBtn.addEventListener("click", function () {
169
+ if (filesPanel && !filesPanel.classList.contains("hidden")) {
170
+ hideFilesPanel(function () { showSessionsPanel(); });
171
+ } else {
172
+ showFilesPanel();
173
+ }
174
+ });
171
175
  }
172
176
  if (filePanelClose) {
173
177
  filePanelClose.addEventListener("click", function() {
@@ -164,12 +164,17 @@ export function initTerminal(_ctx) {
164
164
  });
165
165
  }
166
166
 
167
- // Sidebar terminal button
167
+ // Sidebar terminal button — toggles open/close. Second click (or the
168
+ // Cmd+O hotkey on the same tile) dismisses the terminal panel.
168
169
  var sidebarTermBtn = document.getElementById("terminal-sidebar-btn");
169
170
  if (sidebarTermBtn) {
170
171
  sidebarTermBtn.addEventListener("click", function () {
171
- closeSidebar();
172
- openTerminal();
172
+ if (isOpen && !ctx.terminalContainerEl.classList.contains("hidden")) {
173
+ closeTerminal();
174
+ } else {
175
+ closeSidebar();
176
+ openTerminal();
177
+ }
173
178
  });
174
179
  }
175
180
 
@@ -0,0 +1,561 @@
1
+ // tool-palette.js — Customizable sidebar tool palette
2
+ //
3
+ // Renders the per-session and per-mate tool grids from a data registry,
4
+ // so users can reorder tools and hide ones they don't use. Preserves the
5
+ // original button IDs so existing click handlers (attached by sidebar.js,
6
+ // terminal.js, mcp-ui.js, etc.) keep working on the rendered nodes.
7
+ //
8
+ // Design notes (see issue #325 for the broader context):
9
+ // - Buttons are created once on init and never destroyed; hide/reorder
10
+ // moves existing DOM nodes, so event listeners bound elsewhere survive.
11
+ // - Edit mode is a state on the palette container (class="edit-mode").
12
+ // CSS shows the × remove affordance and enables drag reordering.
13
+ // - Preferences persist per-user via the /api/user/tool-palettes endpoint.
14
+ // An in-flight save is debounced so rapid drag reorders don't spam.
15
+
16
+ import { refreshIcons } from './icons.js';
17
+
18
+ // Registry order = default order for users who haven't customized. Users
19
+ // with saved preferences keep their own order (applyPreferences uses
20
+ // saved order first, then appends any registry entries the user's saved
21
+ // list doesn't mention). So changes here only affect fresh palettes.
22
+ var SESSION_TOOLS = [
23
+ { id: "file-browser-btn", icon: "folder-tree", label: "File browser" },
24
+ { id: "terminal-sidebar-btn", icon: "square-terminal", label: "Terminal", countId: "terminal-sidebar-count" },
25
+ { id: "sticky-notes-sidebar-btn", icon: "sticky-note", label: "Sticky Notes", countId: "sticky-notes-sidebar-count" },
26
+ { id: "scheduler-btn", icon: "calendar-clock", label: "Scheduled Tasks" },
27
+ { id: "email-sidebar-btn", icon: "mail", label: "Email" },
28
+ { id: "mcp-btn", icon: "cable", label: "MCP Servers", countId: "mcp-sidebar-count" },
29
+ { id: "skills-btn", icon: "puzzle", label: "Skills" },
30
+ ];
31
+
32
+ var MATE_TOOLS = [
33
+ { id: "mate-memory-btn", icon: "brain", label: "Memory", countId: "mate-memory-count" },
34
+ { id: "mate-knowledge-btn", icon: "book-open", label: "Knowledge", countId: "mate-knowledge-count" },
35
+ { id: "mate-sticky-notes-btn", icon: "sticky-note", label: "Sticky Notes" },
36
+ { id: "mate-email-btn", icon: "mail", label: "Email" },
37
+ { id: "mate-mcp-btn", icon: "cable", label: "MCP Servers", countId: "mate-mcp-sidebar-count" },
38
+ { id: "mate-skills-btn", icon: "puzzle", label: "Skills" },
39
+ { id: "mate-scheduler-btn", icon: "calendar-clock", label: "Scheduled Tasks" },
40
+ ];
41
+
42
+ var PALETTES = {
43
+ session: {
44
+ tools: SESSION_TOOLS,
45
+ activeContainerId: "session-actions",
46
+ hiddenSectionId: "session-actions-hidden",
47
+ },
48
+ mate: {
49
+ tools: MATE_TOOLS,
50
+ activeContainerId: "mate-sidebar-tools",
51
+ hiddenSectionId: "mate-sidebar-tools-hidden",
52
+ },
53
+ };
54
+
55
+ var _saveTimers = {};
56
+ var _draggingEl = null;
57
+ var _draggingPaletteName = null;
58
+
59
+ export function initToolPalettes() {
60
+ for (var name in PALETTES) {
61
+ buildPalette(name);
62
+ }
63
+
64
+ // Edit pills toggle edit mode on their associated palette.
65
+ var editBtns = document.querySelectorAll('.tool-palette-edit-btn');
66
+ for (var i = 0; i < editBtns.length; i++) {
67
+ (function (btn) {
68
+ btn.addEventListener('click', function () {
69
+ toggleEditMode(btn.dataset.palette);
70
+ });
71
+ })(editBtns[i]);
72
+ }
73
+
74
+ // Keyboard-hotkey hint pill. Matches the icon-strip shortcut pills
75
+ // in shape — just the chord, no prose. Clicking the pill opens the
76
+ // hotkey overlay, same as pressing the shortcut.
77
+ var isMac = /Mac|iPhone|iPod|iPad/i.test(navigator.platform);
78
+ var hintLabel = isMac ? '\u2318O' : 'Ctrl+O';
79
+ var hints = document.querySelectorAll('.sidebar-tools-hint');
80
+ for (var h = 0; h < hints.length; h++) {
81
+ hints[h].textContent = hintLabel;
82
+ hints[h].title = 'Show keyboard hotkeys (' + hintLabel + ')';
83
+ hints[h].addEventListener('click', function (e) {
84
+ // Stop the click from bubbling to the document-level dismiss
85
+ // handler below, which would immediately tear down the pick-mode
86
+ // we just entered.
87
+ e.stopPropagation();
88
+ if (_pickActive) exitToolPickMode();
89
+ else enterToolPickMode();
90
+ });
91
+ }
92
+
93
+ // Load saved preferences and apply to both palettes once buttons exist.
94
+ loadPreferences();
95
+
96
+ refreshIcons();
97
+ }
98
+
99
+ function buildPalette(name) {
100
+ var palette = PALETTES[name];
101
+ var active = document.getElementById(palette.activeContainerId);
102
+ if (!active) return;
103
+
104
+ // Create buttons with stable IDs and append in registry order (default).
105
+ for (var i = 0; i < palette.tools.length; i++) {
106
+ active.appendChild(buildToolButton(palette.tools[i], name));
107
+ }
108
+
109
+ // Scaffold the hidden section (its grid fills in when items are hidden).
110
+ var hiddenSection = document.getElementById(palette.hiddenSectionId);
111
+ if (hiddenSection) {
112
+ hiddenSection.innerHTML = '';
113
+ var labelEl = document.createElement('div');
114
+ labelEl.className = 'tool-palette-hidden-label';
115
+ labelEl.textContent = 'Add back';
116
+ var gridEl = document.createElement('div');
117
+ gridEl.className = 'tool-palette-hidden-grid';
118
+ hiddenSection.appendChild(labelEl);
119
+ hiddenSection.appendChild(gridEl);
120
+ }
121
+
122
+ // Drag-reorder within the active container. Works in both normal and
123
+ // edit mode so users can rearrange without entering a separate mode
124
+ // (matches the macOS dock pattern). HTML5 drag has a small movement
125
+ // threshold, so quick clicks still activate the tool normally.
126
+ active.addEventListener('dragover', function (e) {
127
+ if (_draggingPaletteName !== name || !_draggingEl) return;
128
+ e.preventDefault();
129
+ if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
130
+ var after = getDragAfterElement(active, e.clientX, e.clientY);
131
+ if (after == null) {
132
+ if (_draggingEl.parentNode !== active || _draggingEl.nextSibling) {
133
+ active.appendChild(_draggingEl);
134
+ }
135
+ } else if (after !== _draggingEl) {
136
+ active.insertBefore(_draggingEl, after);
137
+ }
138
+ });
139
+ active.addEventListener('drop', function (e) {
140
+ if (_draggingPaletteName !== name) return;
141
+ e.preventDefault();
142
+ });
143
+ }
144
+
145
+ function buildToolButton(tool, paletteName) {
146
+ var btn = document.createElement('button');
147
+ btn.id = tool.id;
148
+ btn.type = 'button';
149
+ btn.className = 'palette-tile';
150
+ btn.title = tool.label;
151
+ btn.setAttribute('aria-label', tool.label);
152
+ btn.dataset.toolId = tool.id;
153
+ btn.dataset.palette = paletteName;
154
+ // Draggable at all times so users can reorder without entering edit
155
+ // mode. Drag is gated to the active grid in the handlers below; tiles
156
+ // in the hidden grid get draggable=false set on move.
157
+ btn.draggable = true;
158
+
159
+ var icon = document.createElement('i');
160
+ icon.setAttribute('data-lucide', tool.icon);
161
+ btn.appendChild(icon);
162
+
163
+ var labelEl = document.createElement('span');
164
+ labelEl.className = 'tool-btn-label';
165
+ labelEl.textContent = tool.label;
166
+ btn.appendChild(labelEl);
167
+
168
+ if (tool.countId) {
169
+ var count = document.createElement('span');
170
+ count.id = tool.countId;
171
+ count.className = 'sidebar-badge hidden';
172
+ btn.appendChild(count);
173
+ }
174
+
175
+ // × affordance — shown only in edit mode via CSS.
176
+ var remove = document.createElement('span');
177
+ remove.className = 'tool-palette-remove';
178
+ remove.setAttribute('role', 'button');
179
+ remove.setAttribute('aria-label', 'Remove ' + tool.label + ' from palette');
180
+ remove.textContent = '\u00D7';
181
+ btn.appendChild(remove);
182
+
183
+ // Click interceptor, registered before any other module attaches to
184
+ // the button by ID. stopImmediatePropagation prevents the tool's own
185
+ // handler from running in three cases: clicking the × in edit mode,
186
+ // clicking a tile in the hidden-grid (which should restore it), and
187
+ // clicking any tile while edit mode is active (tiles are inert then).
188
+ btn.addEventListener('click', function (e) {
189
+ if (e.target.closest('.tool-palette-remove')) {
190
+ e.preventDefault();
191
+ e.stopImmediatePropagation();
192
+ moveToHidden(paletteName, tool.id);
193
+ return;
194
+ }
195
+ var container = btn.parentNode;
196
+ if (!container) return;
197
+ if (container.classList.contains('tool-palette-hidden-grid')) {
198
+ e.preventDefault();
199
+ e.stopImmediatePropagation();
200
+ moveToActive(paletteName, tool.id);
201
+ return;
202
+ }
203
+ if (container.classList.contains('edit-mode')) {
204
+ e.preventDefault();
205
+ e.stopImmediatePropagation();
206
+ }
207
+ });
208
+
209
+ btn.addEventListener('contextmenu', function (e) {
210
+ e.preventDefault();
211
+ e.stopPropagation();
212
+ var container = btn.parentNode;
213
+ var inHidden = container && container.classList.contains('tool-palette-hidden-grid');
214
+ openPaletteContextMenu(e.clientX, e.clientY, inHidden ? [
215
+ { label: 'Add back to palette', action: function () { moveToActive(paletteName, tool.id); } },
216
+ ] : [
217
+ { label: 'Remove from palette', action: function () { moveToHidden(paletteName, tool.id); } },
218
+ { label: 'Edit palette\u2026', action: function () {
219
+ if (!document.getElementById(PALETTES[paletteName].activeContainerId).classList.contains('edit-mode')) {
220
+ toggleEditMode(paletteName);
221
+ }
222
+ } },
223
+ ]);
224
+ });
225
+
226
+ btn.addEventListener('dragstart', function (e) {
227
+ var container = btn.parentNode;
228
+ // Only allow drag from the active grid; hidden tiles are added back
229
+ // via click, not drag.
230
+ if (!container || container.id !== PALETTES[paletteName].activeContainerId) {
231
+ e.preventDefault();
232
+ return;
233
+ }
234
+ _draggingEl = btn;
235
+ _draggingPaletteName = paletteName;
236
+ e.dataTransfer.effectAllowed = 'move';
237
+ // Firefox requires data to be set for drag to start.
238
+ try { e.dataTransfer.setData('text/plain', tool.id); } catch (err) { /* ignore */ }
239
+ btn.classList.add('dragging');
240
+ });
241
+ btn.addEventListener('dragend', function () {
242
+ btn.classList.remove('dragging');
243
+ if (_draggingPaletteName) queueSave(_draggingPaletteName);
244
+ _draggingEl = null;
245
+ _draggingPaletteName = null;
246
+ });
247
+
248
+ return btn;
249
+ }
250
+
251
+ function toggleEditMode(name) {
252
+ var palette = PALETTES[name];
253
+ if (!palette) return;
254
+ var active = document.getElementById(palette.activeContainerId);
255
+ var hidden = document.getElementById(palette.hiddenSectionId);
256
+ var editBtn = document.querySelector('.tool-palette-edit-btn[data-palette="' + name + '"]');
257
+ if (!active || !editBtn) return;
258
+
259
+ var entering = !active.classList.contains('edit-mode');
260
+ active.classList.toggle('edit-mode', entering);
261
+ if (hidden) {
262
+ // Show hidden-section in edit mode if any items exist there (or to
263
+ // allow adding back even when empty, keep visible so the state is
264
+ // legible). We show whenever edit mode is on.
265
+ hidden.classList.toggle('hidden', !entering);
266
+ }
267
+ // Pencil icon stays; the .active class provides visual feedback by
268
+ // swapping to the accent background. No label text swap needed now.
269
+ editBtn.classList.toggle('active', entering);
270
+
271
+ if (!entering) queueSave(name);
272
+ }
273
+
274
+ function moveToHidden(name, toolId) {
275
+ var palette = PALETTES[name];
276
+ if (!palette) return;
277
+ var btn = document.getElementById(toolId);
278
+ var hiddenSection = document.getElementById(palette.hiddenSectionId);
279
+ if (!btn || !hiddenSection) return;
280
+ var grid = hiddenSection.querySelector('.tool-palette-hidden-grid');
281
+ if (!grid) return;
282
+ // Hidden tiles stay draggable=false so users don't drag them around
283
+ // before adding them back. The active-grid dragstart gate also
284
+ // enforces this.
285
+ btn.draggable = false;
286
+ grid.appendChild(btn);
287
+ queueSave(name);
288
+ }
289
+
290
+ function moveToActive(name, toolId) {
291
+ var palette = PALETTES[name];
292
+ if (!palette) return;
293
+ var btn = document.getElementById(toolId);
294
+ var active = document.getElementById(palette.activeContainerId);
295
+ if (!btn || !active) return;
296
+ active.appendChild(btn);
297
+ btn.draggable = true;
298
+ queueSave(name);
299
+ }
300
+
301
+ // (Hidden-tile clicks are handled in each button's own click listener,
302
+ // attached in buildToolButton before any external module attaches
303
+ // handlers by ID. See the stopImmediatePropagation logic there.)
304
+
305
+ // --- Right-click context menu ---
306
+
307
+ var _openMenu = null;
308
+
309
+ function openPaletteContextMenu(x, y, items) {
310
+ closePaletteContextMenu();
311
+ var menu = document.createElement('div');
312
+ menu.className = 'tool-palette-ctx-menu';
313
+ for (var i = 0; i < items.length; i++) {
314
+ (function (item) {
315
+ var btn = document.createElement('button');
316
+ btn.type = 'button';
317
+ btn.className = 'tool-palette-ctx-item';
318
+ btn.textContent = item.label;
319
+ btn.addEventListener('click', function (e) {
320
+ e.stopPropagation();
321
+ closePaletteContextMenu();
322
+ item.action();
323
+ });
324
+ menu.appendChild(btn);
325
+ })(items[i]);
326
+ }
327
+ document.body.appendChild(menu);
328
+ // Clamp to viewport.
329
+ var rect = menu.getBoundingClientRect();
330
+ var px = x, py = y;
331
+ if (px + rect.width > window.innerWidth - 4) px = window.innerWidth - rect.width - 4;
332
+ if (py + rect.height > window.innerHeight - 4) py = window.innerHeight - rect.height - 4;
333
+ menu.style.left = px + 'px';
334
+ menu.style.top = py + 'px';
335
+ _openMenu = menu;
336
+ }
337
+
338
+ function closePaletteContextMenu() {
339
+ if (_openMenu && _openMenu.parentNode) _openMenu.parentNode.removeChild(_openMenu);
340
+ _openMenu = null;
341
+ }
342
+
343
+ document.addEventListener('click', function () { closePaletteContextMenu(); });
344
+ document.addEventListener('keydown', function (e) {
345
+ if (e.key === 'Escape') closePaletteContextMenu();
346
+ });
347
+ window.addEventListener('blur', function () { closePaletteContextMenu(); });
348
+ window.addEventListener('resize', function () { closePaletteContextMenu(); });
349
+
350
+ // --- Hotkey overlay (Cmd/Ctrl + O) ---
351
+ //
352
+ // Press Cmd/Ctrl + O to reveal a hotkey badge on each visible palette
353
+ // tile. Then press the shown digit (1..9, 0) or letter (a..z) to
354
+ // activate that tool. Escape or clicking away dismisses. Inspired by
355
+ // Vimium / Superhuman-style "pick a tile with one keystroke" flows.
356
+
357
+ var _pickActive = false;
358
+ var HOTKEYS = (function () {
359
+ var k = [];
360
+ for (var i = 1; i <= 9; i++) k.push(String(i));
361
+ k.push('0');
362
+ for (var c = 97; c <= 122; c++) k.push(String.fromCharCode(c));
363
+ return k;
364
+ })();
365
+
366
+ function getVisiblePaletteName() {
367
+ // offsetParent is null when the element (or an ancestor) is hidden.
368
+ var mateActive = document.getElementById('mate-sidebar-tools');
369
+ if (mateActive && mateActive.offsetParent !== null) return 'mate';
370
+ var sessionActive = document.getElementById('session-actions');
371
+ if (sessionActive && sessionActive.offsetParent !== null) return 'session';
372
+ return null;
373
+ }
374
+
375
+ function enterToolPickMode() {
376
+ if (_pickActive) return;
377
+ var name = getVisiblePaletteName();
378
+ if (!name) return;
379
+ var active = document.getElementById(PALETTES[name].activeContainerId);
380
+ if (!active) return;
381
+ var tiles = active.querySelectorAll('[data-tool-id]');
382
+ if (tiles.length === 0) return;
383
+ for (var i = 0; i < tiles.length && i < HOTKEYS.length; i++) {
384
+ var key = HOTKEYS[i];
385
+ tiles[i].dataset.hotkey = key;
386
+ var badge = document.createElement('span');
387
+ badge.className = 'tool-palette-hotkey-badge';
388
+ badge.textContent = key.toUpperCase();
389
+ tiles[i].appendChild(badge);
390
+ }
391
+ _pickActive = true;
392
+ }
393
+
394
+ function exitToolPickMode() {
395
+ if (!_pickActive) return;
396
+ _pickActive = false;
397
+ var badges = document.querySelectorAll('.tool-palette-hotkey-badge');
398
+ for (var i = 0; i < badges.length; i++) {
399
+ if (badges[i].parentNode) badges[i].parentNode.removeChild(badges[i]);
400
+ }
401
+ var marked = document.querySelectorAll('[data-hotkey]');
402
+ for (var j = 0; j < marked.length; j++) {
403
+ delete marked[j].dataset.hotkey;
404
+ }
405
+ }
406
+
407
+ // Capture phase so we reliably preempt typing and browser shortcuts
408
+ // when focus is in an input.
409
+ document.addEventListener('keydown', function (e) {
410
+ // Pick mode: consume the next printable key.
411
+ if (_pickActive) {
412
+ if (e.key === 'Escape') {
413
+ e.preventDefault();
414
+ exitToolPickMode();
415
+ return;
416
+ }
417
+ // Ignore modifier-only keys; react to single-character keys.
418
+ if (e.key.length !== 1) return;
419
+ if (e.ctrlKey || e.metaKey || e.altKey) return;
420
+ var key = e.key.toLowerCase();
421
+ var tile = document.querySelector('[data-hotkey="' + key + '"]');
422
+ if (tile) {
423
+ e.preventDefault();
424
+ exitToolPickMode();
425
+ tile.click();
426
+ } else {
427
+ // Unknown key exits pick mode without triggering a tile.
428
+ exitToolPickMode();
429
+ }
430
+ return;
431
+ }
432
+
433
+ // Enter pick mode on Cmd/Ctrl + O (letter O). Intercepted globally
434
+ // including inside text inputs — the browser's native Cmd+O is a
435
+ // file-picker dialog Clay never wants, so taking it over costs the
436
+ // user nothing and is especially useful when jumping to a tool from
437
+ // the session search box.
438
+ if ((e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey && e.key && e.key.toLowerCase() === 'o') {
439
+ if (!getVisiblePaletteName()) return;
440
+ e.preventDefault();
441
+ enterToolPickMode();
442
+ }
443
+ }, true);
444
+
445
+ // Dismiss the overlay on click-away, blur, or viewport changes so it
446
+ // never lingers invisibly.
447
+ document.addEventListener('click', function () { if (_pickActive) exitToolPickMode(); });
448
+ window.addEventListener('blur', function () { if (_pickActive) exitToolPickMode(); });
449
+ window.addEventListener('resize', function () { if (_pickActive) exitToolPickMode(); });
450
+
451
+ function getDragAfterElement(container, x, y) {
452
+ var tiles = Array.prototype.slice.call(
453
+ container.querySelectorAll('[data-tool-id]:not(.dragging)')
454
+ );
455
+ var closest = null;
456
+ var closestDist = Number.POSITIVE_INFINITY;
457
+ for (var i = 0; i < tiles.length; i++) {
458
+ var rect = tiles[i].getBoundingClientRect();
459
+ // Pointer is "before" the tile if it's left of the tile's horizontal
460
+ // midpoint on the same row (or on a higher row).
461
+ var rowDelta = y - (rect.top + rect.height / 2);
462
+ var colDelta = x - (rect.left + rect.width / 2);
463
+ // Weight row heavier than column so flowing between rows works.
464
+ var dist = Math.abs(rowDelta) * 2 + Math.abs(colDelta);
465
+ var before = rowDelta < -rect.height / 2
466
+ || (Math.abs(rowDelta) <= rect.height / 2 && colDelta < 0);
467
+ if (before && dist < closestDist) {
468
+ closestDist = dist;
469
+ closest = tiles[i];
470
+ }
471
+ }
472
+ return closest;
473
+ }
474
+
475
+ // --- Persistence ---
476
+
477
+ function loadPreferences() {
478
+ fetch('/api/user/tool-palettes', { credentials: 'same-origin' })
479
+ .then(function (res) { return res.ok ? res.json() : null; })
480
+ .then(function (data) {
481
+ if (!data) return;
482
+ applyPreferences('session', data.session || null);
483
+ applyPreferences('mate', data.mate || null);
484
+ })
485
+ .catch(function () { /* first-time users have no saved prefs; ignore */ });
486
+ }
487
+
488
+ function applyPreferences(name, prefs) {
489
+ if (!prefs) return;
490
+ var palette = PALETTES[name];
491
+ if (!palette) return;
492
+ var active = document.getElementById(palette.activeContainerId);
493
+ var hiddenSection = document.getElementById(palette.hiddenSectionId);
494
+ var hiddenGrid = hiddenSection ? hiddenSection.querySelector('.tool-palette-hidden-grid') : null;
495
+ if (!active || !hiddenGrid) return;
496
+
497
+ var hiddenSet = {};
498
+ var hiddenList = prefs.hidden || [];
499
+ for (var i = 0; i < hiddenList.length; i++) hiddenSet[hiddenList[i]] = true;
500
+
501
+ var orderList = prefs.order || [];
502
+ // Any tools not in the stored order are appended in registry order so
503
+ // newly-added tools surface automatically for existing users.
504
+ var placed = {};
505
+ for (var j = 0; j < orderList.length; j++) {
506
+ var id = orderList[j];
507
+ if (hiddenSet[id]) continue;
508
+ var btn = document.getElementById(id);
509
+ if (btn) {
510
+ active.appendChild(btn);
511
+ placed[id] = true;
512
+ }
513
+ }
514
+ for (var k = 0; k < palette.tools.length; k++) {
515
+ var tid = palette.tools[k].id;
516
+ if (placed[tid] || hiddenSet[tid]) continue;
517
+ var tbtn = document.getElementById(tid);
518
+ if (tbtn) active.appendChild(tbtn);
519
+ }
520
+ for (var h = 0; h < hiddenList.length; h++) {
521
+ var hbtn = document.getElementById(hiddenList[h]);
522
+ if (hbtn) {
523
+ hbtn.draggable = false;
524
+ hiddenGrid.appendChild(hbtn);
525
+ }
526
+ }
527
+ }
528
+
529
+ function queueSave(name) {
530
+ if (_saveTimers[name]) clearTimeout(_saveTimers[name]);
531
+ _saveTimers[name] = setTimeout(function () {
532
+ _saveTimers[name] = null;
533
+ savePreferences(name);
534
+ }, 250);
535
+ }
536
+
537
+ function savePreferences(name) {
538
+ var palette = PALETTES[name];
539
+ if (!palette) return;
540
+ var active = document.getElementById(palette.activeContainerId);
541
+ var hiddenSection = document.getElementById(palette.hiddenSectionId);
542
+ var hiddenGrid = hiddenSection ? hiddenSection.querySelector('.tool-palette-hidden-grid') : null;
543
+ if (!active) return;
544
+
545
+ var order = [];
546
+ var activeTiles = active.querySelectorAll('[data-tool-id]');
547
+ for (var i = 0; i < activeTiles.length; i++) order.push(activeTiles[i].dataset.toolId);
548
+
549
+ var hidden = [];
550
+ if (hiddenGrid) {
551
+ var hiddenTiles = hiddenGrid.querySelectorAll('[data-tool-id]');
552
+ for (var j = 0; j < hiddenTiles.length; j++) hidden.push(hiddenTiles[j].dataset.toolId);
553
+ }
554
+
555
+ fetch('/api/user/tool-palettes', {
556
+ method: 'PUT',
557
+ credentials: 'same-origin',
558
+ headers: { 'Content-Type': 'application/json' },
559
+ body: JSON.stringify({ palette: name, order: order, hidden: hidden }),
560
+ }).catch(function () { /* save best-effort */ });
561
+ }