clay-server 2.47.0-beta.6 → 3.0.0-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.
Files changed (46) hide show
  1. package/lib/daemon.js +6 -27
  2. package/lib/project-connection.js +21 -8
  3. package/lib/project-session-pair.js +312 -0
  4. package/lib/project-session-spawn.js +8 -4
  5. package/lib/project-sessions.js +108 -10
  6. package/lib/project-user-message.js +18 -8
  7. package/lib/project.js +33 -0
  8. package/lib/public/app.js +20 -0
  9. package/lib/public/css/menus.css +83 -1
  10. package/lib/public/css/messages.css +14 -0
  11. package/lib/public/css/pane.css +420 -0
  12. package/lib/public/css/sidebar.css +40 -0
  13. package/lib/public/css/sticky-notes.css +2 -0
  14. package/lib/public/index.html +4 -0
  15. package/lib/public/modules/app-connection.js +6 -0
  16. package/lib/public/modules/app-favicon.js +5 -0
  17. package/lib/public/modules/app-header.js +101 -6
  18. package/lib/public/modules/app-messages.js +59 -6
  19. package/lib/public/modules/app-panels.js +49 -27
  20. package/lib/public/modules/app-rendering.js +11 -4
  21. package/lib/public/modules/pane-bridge.js +41 -0
  22. package/lib/public/modules/pane-session.js +18 -0
  23. package/lib/public/modules/sidebar-sessions.js +175 -1
  24. package/lib/public/modules/sidebar.js +2 -0
  25. package/lib/public/modules/split-group-helpers.js +18 -0
  26. package/lib/public/modules/split-pair-ui.js +324 -0
  27. package/lib/public/modules/split-view.js +499 -0
  28. package/lib/public/modules/sticky-notes.js +32 -91
  29. package/lib/public/style.css +1 -0
  30. package/lib/sdk-bridge.js +111 -20
  31. package/lib/sdk-message-processor.js +2 -2
  32. package/lib/server.js +24 -4
  33. package/lib/session-hygiene.js +100 -0
  34. package/lib/session-pair-mcp-server.js +28 -0
  35. package/lib/session-split-groups.js +298 -0
  36. package/lib/sessions.js +163 -34
  37. package/lib/ws-request.js +14 -0
  38. package/lib/ws-schema.js +13 -0
  39. package/lib/yoke/adapters/claude.js +32 -0
  40. package/lib/yoke/adapters/codex.js +100 -77
  41. package/lib/yoke/adapters/kiro.js +34 -35
  42. package/lib/yoke/codex-app-server.js +56 -6
  43. package/lib/yoke/index.js +1 -0
  44. package/lib/yoke/skill-discovery.js +196 -0
  45. package/lib/yoke/vendor-registry.js +34 -0
  46. package/package.json +2 -2
@@ -0,0 +1,499 @@
1
+ // Two-pane iframe shell for the split-view spike.
2
+
3
+ import { store } from './store.js';
4
+ import { getWs } from './ws-ref.js';
5
+ import { getCachedSessions } from './sidebar-sessions.js';
6
+ import { iconHtml, refreshIcons } from './icons.js';
7
+ import { detachTuiView } from './session-tui-view.js';
8
+ import { formatTokens } from './app-panels.js';
9
+ import { VENDOR_AVATARS, VENDOR_NAMES } from './app-rendering.js';
10
+ import { groupedSessionIds, findSplitGroup } from './split-group-helpers.js';
11
+ import { showConfirm } from './app-misc.js';
12
+ import { syncPairChrome } from './split-pair-ui.js';
13
+
14
+ var host = null;
15
+ var nativeApp = null;
16
+ var mainPanelsEl = null;
17
+ var dropOverlay = null;
18
+ var ghostTitleEl = null;
19
+ var draggedSessionId = null;
20
+ var stickyNotesContainer = null;
21
+ var stickyNotesHome = null;
22
+ var stickyNotesAnchor = null;
23
+ function placeStickyNotesOverlay(splitActive) {
24
+ if (!stickyNotesContainer || !stickyNotesHome || !mainPanelsEl) return;
25
+ if (splitActive) {
26
+ // Notes are project-wide, so split view owns one canvas above both panes.
27
+ // Pane-mode CSS suppresses the duplicate canvas inside each iframe.
28
+ mainPanelsEl.appendChild(stickyNotesContainer);
29
+ return;
30
+ }
31
+ if (stickyNotesAnchor && stickyNotesAnchor.parentNode === stickyNotesHome) {
32
+ stickyNotesHome.insertBefore(stickyNotesContainer, stickyNotesAnchor);
33
+ } else {
34
+ stickyNotesHome.appendChild(stickyNotesContainer);
35
+ }
36
+ }
37
+
38
+ function sessionById(sessionId) {
39
+ var sessions = getCachedSessions() || [];
40
+ for (var i = 0; i < sessions.length; i++) {
41
+ if (sessions[i].id === sessionId) return sessions[i];
42
+ }
43
+ return null;
44
+ }
45
+
46
+ function paneForSession(sessionId) {
47
+ var session = sessionById(sessionId);
48
+ return {
49
+ slug: store.get('currentSlug'),
50
+ sessionId: sessionId,
51
+ title: (session && session.title) || ("Session " + sessionId),
52
+ };
53
+ }
54
+
55
+ function paneUrl(pane) {
56
+ return "/p/" + encodeURIComponent(pane.slug) + "/?pane=1&session=" + encodeURIComponent(pane.sessionId);
57
+ }
58
+
59
+ // Arc-style drop preview: hovering one half folds the live app into the
60
+ // other half and shows a ghost pane where the dragged session will land.
61
+ function setPreviewSide(side) {
62
+ if (!mainPanelsEl) return;
63
+ mainPanelsEl.classList.toggle("split-preview-left", side === "left");
64
+ mainPanelsEl.classList.toggle("split-preview-right", side === "right");
65
+ }
66
+
67
+ function hideDropOverlay() {
68
+ if (dropOverlay) dropOverlay.classList.remove("visible");
69
+ setPreviewSide(null);
70
+ if (mainPanelsEl) mainPanelsEl.classList.remove("split-drag-active");
71
+ draggedSessionId = null;
72
+ }
73
+
74
+ function showDropOverlay() {
75
+ if (!dropOverlay || store.get('splitPanes')) return;
76
+ var grouped = groupedSessionIds(store.get('splitGroups'));
77
+ if (grouped.has(store.get('activeSessionId'))) return;
78
+ if (ghostTitleEl) {
79
+ var session = sessionById(draggedSessionId);
80
+ ghostTitleEl.textContent = (session && session.title) || ("Session " + draggedSessionId);
81
+ }
82
+ if (mainPanelsEl) mainPanelsEl.classList.add("split-drag-active");
83
+ dropOverlay.classList.add("visible");
84
+ }
85
+
86
+ function switchNativeSession(sessionId) {
87
+ store.set({ splitPanes: null });
88
+ var ws = getWs();
89
+ if (ws && ws.readyState === 1) {
90
+ ws.send(JSON.stringify({ type: "switch_session", id: sessionId }));
91
+ }
92
+ }
93
+
94
+ function closePane(index) {
95
+ var split = store.get('splitPanes');
96
+ if (!split || !split.panes || split.panes.length !== 2) return;
97
+ if (split.groupId && getWs() && getWs().readyState === 1) {
98
+ getWs().send(JSON.stringify({ type: "split_group_dissolve", id: split.groupId }));
99
+ }
100
+ switchNativeSession(split.panes[index === 0 ? 1 : 0].sessionId);
101
+ }
102
+
103
+ function startPaneRename(header, titleEl, pane) {
104
+ if (header.querySelector(".split-pane-rename-input")) return;
105
+ var input = document.createElement("input");
106
+ input.type = "text";
107
+ input.className = "split-pane-rename-input";
108
+ input.value = pane.title;
109
+ titleEl.style.display = "none";
110
+ header.insertBefore(input, titleEl);
111
+ input.focus();
112
+ input.select();
113
+
114
+ var done = false;
115
+ function finish(commit) {
116
+ if (done) return;
117
+ done = true;
118
+ var newTitle = input.value.trim();
119
+ input.remove();
120
+ titleEl.style.display = "";
121
+ if (!commit || !newTitle || newTitle === pane.title) return;
122
+ pane.title = newTitle;
123
+ titleEl.textContent = newTitle;
124
+ var ws = getWs();
125
+ if (ws && ws.readyState === 1) {
126
+ ws.send(JSON.stringify({ type: "rename_session", id: pane.sessionId, title: newTitle }));
127
+ }
128
+ }
129
+
130
+ input.addEventListener("keydown", function (event) {
131
+ if (event.key === "Enter") { event.preventDefault(); finish(true); }
132
+ if (event.key === "Escape") { event.preventDefault(); finish(false); }
133
+ });
134
+ input.addEventListener("blur", function () { finish(true); });
135
+ input.addEventListener("click", function (event) { event.stopPropagation(); });
136
+ }
137
+
138
+ // Called on session_list so pane headers follow renames made elsewhere
139
+ // (sidebar, in-pane app). Panes being renamed inline are left alone.
140
+ export function syncPaneTitles() {
141
+ var split = store.get('splitPanes');
142
+ if (!split || !split.panes || !host) return;
143
+ var titleEls = host.querySelectorAll(".split-pane-title");
144
+ for (var i = 0; i < split.panes.length && i < titleEls.length; i++) {
145
+ var session = sessionById(split.panes[i].sessionId);
146
+ if (!session || !session.title) continue;
147
+ split.panes[i].title = session.title;
148
+ if (titleEls[i].style.display !== "none") titleEls[i].textContent = session.title;
149
+ }
150
+ var accessBtns = host.querySelectorAll(".split-pane-full-access");
151
+ for (var ai = 0; ai < split.panes.length && ai < accessBtns.length; ai++) {
152
+ updatePaneFullAccessButton(accessBtns[ai], sessionById(split.panes[ai].sessionId));
153
+ }
154
+ syncPairChrome(host, split);
155
+ }
156
+
157
+ function updatePaneFullAccessButton(button, session) {
158
+ if (!button) return;
159
+ var mode = session && (session.runtimeMode || session.mode || "gui");
160
+ var visible = !!session && !store.get('skipPermsEnabled') && mode === "gui";
161
+ var enabled = visible && session.permissionMode === "bypassPermissions";
162
+ button.classList.toggle("hidden", !visible);
163
+ button.classList.toggle("active", enabled);
164
+ button.setAttribute("aria-checked", enabled ? "true" : "false");
165
+ button.title = enabled ? "Skip Permissions is on — click to restore permission prompts" : "Skip Permissions is off — permission prompts are on";
166
+ }
167
+
168
+ function setPaneFullAccess(sessionId, enabled) {
169
+ var ws = getWs();
170
+ if (ws && ws.readyState === 1) {
171
+ ws.send(JSON.stringify({ type: "set_session_full_access", id: sessionId, enabled: enabled }));
172
+ }
173
+ }
174
+
175
+ function createPane(pane, index) {
176
+ var paneEl = document.createElement("section");
177
+ paneEl.className = "split-pane";
178
+
179
+ var header = document.createElement("header");
180
+ header.className = "split-pane-header";
181
+
182
+ var session = sessionById(pane.sessionId);
183
+ var vendor = (session && session.vendor) || "claude";
184
+ var vendorIcon = document.createElement("img");
185
+ vendorIcon.className = "split-pane-vendor";
186
+ vendorIcon.src = VENDOR_AVATARS[vendor] || VENDOR_AVATARS.claude;
187
+ vendorIcon.alt = "";
188
+ vendorIcon.title = VENDOR_NAMES[vendor] || vendor;
189
+ header.appendChild(vendorIcon);
190
+
191
+ var title = document.createElement("span");
192
+ title.className = "split-pane-title";
193
+ title.textContent = pane.title;
194
+ title.title = "Rename session";
195
+ title.addEventListener("click", function () { startPaneRename(header, title, pane); });
196
+ header.appendChild(title);
197
+
198
+ var frame = document.createElement("iframe");
199
+ frame.className = "split-pane-frame";
200
+ frame.src = paneUrl(pane);
201
+ frame.title = pane.title;
202
+
203
+ // Context-usage chip, fed by clay-pane-context messages from the pane
204
+ // iframe. Hidden until the pane reports a non-zero context percentage.
205
+ var ctxChip = document.createElement("button");
206
+ ctxChip.type = "button";
207
+ ctxChip.className = "split-pane-context";
208
+ ctxChip.title = "Context usage";
209
+ ctxChip.innerHTML = '<span class="split-pane-context-bar"><span class="split-pane-context-fill"></span></span><span class="split-pane-context-label"></span>';
210
+ ctxChip.addEventListener("click", function () {
211
+ if (frame.contentWindow) {
212
+ frame.contentWindow.postMessage({ type: "clay-pane-toggle-context" }, window.location.origin);
213
+ }
214
+ });
215
+ header.appendChild(ctxChip);
216
+
217
+ var fullAccess = document.createElement("button");
218
+ fullAccess.type = "button";
219
+ fullAccess.className = "session-full-access split-pane-full-access hidden";
220
+ fullAccess.setAttribute("role", "switch");
221
+ fullAccess.innerHTML = '<span class="session-full-access-label">Skip Permissions</span><span class="session-full-access-track" aria-hidden="true"><span></span></span>';
222
+ updatePaneFullAccessButton(fullAccess, session);
223
+ fullAccess.addEventListener("click", function () {
224
+ var current = sessionById(pane.sessionId);
225
+ if (current && current.permissionMode === "bypassPermissions") {
226
+ setPaneFullAccess(pane.sessionId, false);
227
+ return;
228
+ }
229
+ showConfirm("Skip permission prompts for this session? Tool requests will be approved automatically.", function () {
230
+ setPaneFullAccess(pane.sessionId, true);
231
+ }, "Skip Permissions", true);
232
+ });
233
+ header.appendChild(fullAccess);
234
+
235
+ var close = document.createElement("button");
236
+ close.type = "button";
237
+ close.className = "split-pane-close";
238
+ close.title = "Close pane";
239
+ close.setAttribute("aria-label", "Close pane");
240
+ close.innerHTML = iconHtml("x");
241
+ close.addEventListener("click", function () { closePane(index); });
242
+ header.appendChild(close);
243
+ paneEl.appendChild(header);
244
+ paneEl.appendChild(frame);
245
+ return paneEl;
246
+ }
247
+
248
+ function updatePaneContextChip(paneEl, msg) {
249
+ if (!paneEl) return;
250
+ var chip = paneEl.querySelector(".split-pane-context");
251
+ if (!chip) return;
252
+ var pct = msg.pct || 0;
253
+ if (pct <= 0) return;
254
+ chip.classList.add("has-data");
255
+ var fill = chip.querySelector(".split-pane-context-fill");
256
+ var label = chip.querySelector(".split-pane-context-label");
257
+ fill.style.width = Math.min(100, pct).toFixed(1) + "%";
258
+ fill.className = "split-pane-context-fill" + (msg.cls || "");
259
+ label.textContent = pct.toFixed(0) + "%";
260
+ var tip = "Context " + pct.toFixed(0) + "% (" + formatTokens(msg.used || 0) + " / " + formatTokens(msg.win || 0) + " tokens)";
261
+ if (msg.cost) tip += " · $" + msg.cost.toFixed(4);
262
+ if (msg.model && msg.model !== "-") tip += " · " + msg.model;
263
+ chip.title = tip;
264
+ }
265
+
266
+ function handlePaneMessage(event) {
267
+ if (event.origin !== window.location.origin) return;
268
+ var msg = event.data;
269
+ if (!msg || msg.type !== "clay-pane-context" || !host) return;
270
+ var frames = host.querySelectorAll(".split-pane-frame");
271
+ for (var i = 0; i < frames.length; i++) {
272
+ if (frames[i].contentWindow === event.source) {
273
+ updatePaneContextChip(frames[i].closest(".split-pane"), msg);
274
+ return;
275
+ }
276
+ }
277
+ }
278
+
279
+ var renderedPaneKey = null;
280
+
281
+ function paneKey(panes) {
282
+ return panes.map(function (pane) { return pane.slug + "#" + pane.sessionId; }).join("|");
283
+ }
284
+
285
+ function renderSplit(split) {
286
+ if (!host || !nativeApp) return;
287
+ var panes = split && split.panes;
288
+ if (!panes || panes.length !== 2) {
289
+ placeStickyNotesOverlay(false);
290
+ host.innerHTML = "";
291
+ renderedPaneKey = null;
292
+ host.classList.remove("visible");
293
+ nativeApp.classList.remove("split-native-hidden");
294
+ return;
295
+ }
296
+ // splitPanes is replaced (same panes, new object) when the server confirms
297
+ // the groupId. Rebuilding then would reload both iframes, so skip DOM work
298
+ // when the rendered pane set is unchanged.
299
+ var key = paneKey(panes);
300
+ placeStickyNotesOverlay(true);
301
+ if (key === renderedPaneKey && host.classList.contains("visible")) return;
302
+ host.innerHTML = "";
303
+ renderedPaneKey = key;
304
+ nativeApp.classList.add("split-native-hidden");
305
+ host.classList.add("visible");
306
+ for (var i = 0; i < panes.length; i++) host.appendChild(createPane(panes[i], i));
307
+ syncPairChrome(host, split);
308
+ refreshIcons();
309
+ }
310
+
311
+ function openSplit(side, draggedId) {
312
+ var currentId = store.get('activeSessionId');
313
+ var grouped = groupedSessionIds(store.get('splitGroups'));
314
+ if (!currentId || !draggedId || currentId === draggedId || store.get('splitPanes')) {
315
+ hideDropOverlay();
316
+ return;
317
+ }
318
+ if (grouped.has(currentId) || grouped.has(draggedId)) {
319
+ hideDropOverlay();
320
+ return;
321
+ }
322
+ var current = paneForSession(currentId);
323
+ var dragged = paneForSession(draggedId);
324
+ var panes = side === "left" ? [dragged, current] : [current, dragged];
325
+ hideDropOverlay();
326
+ // The TUI host is position:fixed on document.body, so hiding #app does not
327
+ // hide it. Detach before showing panes; exiting the split re-attaches via
328
+ // the switch_session -> session_switched path.
329
+ detachTuiView();
330
+ store.set({ splitPanes: { groupId: null, panes: panes } });
331
+ var ws = getWs();
332
+ if (ws && ws.readyState === 1) {
333
+ ws.send(JSON.stringify({ type: "split_group_create", members: [panes[0].sessionId, panes[1].sessionId] }));
334
+ }
335
+ }
336
+
337
+ export function openGroup(group) {
338
+ if (!group || !Array.isArray(group.members) || group.members.length !== 2) return false;
339
+ if (!sessionById(group.members[0]) || !sessionById(group.members[1])) return false;
340
+ detachTuiView();
341
+ store.set({
342
+ splitPanes: {
343
+ groupId: group.id,
344
+ panes: [paneForSession(group.members[0]), paneForSession(group.members[1])],
345
+ },
346
+ });
347
+ // Anchor the parent's active session (and server-side presence) to a
348
+ // member while the split is open, so a hard refresh restores into this
349
+ // group instead of whatever was viewed before it.
350
+ var activeId = store.get('activeSessionId');
351
+ if (activeId !== group.members[0] && activeId !== group.members[1]) {
352
+ var ws = getWs();
353
+ if (ws && ws.readyState === 1) {
354
+ ws.send(JSON.stringify({ type: "switch_session", id: group.members[0] }));
355
+ }
356
+ }
357
+ dismissSplitOverlays();
358
+ return true;
359
+ }
360
+
361
+ // Reload survival: when the restored active session turns out to be a split
362
+ // group member, reopen that group. Safe to call often -- no-ops unless a
363
+ // group member is active natively with no split open.
364
+ export function maybeRestoreSplitGroup() {
365
+ if (store.get('paneMode') || store.get('splitPanes')) return;
366
+ var activeId = store.get('activeSessionId');
367
+ if (!activeId) return;
368
+ var groups = store.get('splitGroups') || [];
369
+ for (var i = 0; i < groups.length; i++) {
370
+ var members = groups[i].members || [];
371
+ if (members.indexOf(activeId) !== -1) {
372
+ openGroup(groups[i]);
373
+ return;
374
+ }
375
+ }
376
+ }
377
+
378
+ export function separateGroup(group) {
379
+ if (!group || !Array.isArray(group.members) || group.members.length !== 2) return;
380
+ var ws = getWs();
381
+ if (ws && ws.readyState === 1) {
382
+ ws.send(JSON.stringify({ type: "split_group_dissolve", id: group.id }));
383
+ }
384
+ var split = store.get('splitPanes');
385
+ if (split && split.groupId === group.id) switchNativeSession(group.members[0]);
386
+ }
387
+
388
+ function dismissSplitOverlays() {
389
+ hideDropOverlay();
390
+ }
391
+
392
+ function overlaySide(event) {
393
+ var rect = dropOverlay.getBoundingClientRect();
394
+ return (event.clientX - rect.left) < rect.width / 2 ? "left" : "right";
395
+ }
396
+
397
+ function createDropOverlay(mainPanels) {
398
+ var overlay = document.createElement("div");
399
+ overlay.className = "split-drop-overlay";
400
+
401
+ var ghost = document.createElement("div");
402
+ ghost.className = "split-drop-ghost";
403
+ ghostTitleEl = document.createElement("span");
404
+ ghostTitleEl.className = "split-drop-ghost-title";
405
+ ghost.appendChild(ghostTitleEl);
406
+ overlay.appendChild(ghost);
407
+
408
+ overlay.addEventListener("dragover", function (event) {
409
+ event.preventDefault();
410
+ event.dataTransfer.dropEffect = "move";
411
+ setPreviewSide(overlaySide(event));
412
+ });
413
+ overlay.addEventListener("dragleave", function (event) {
414
+ if (!event.relatedTarget || !overlay.contains(event.relatedTarget)) setPreviewSide(null);
415
+ });
416
+ overlay.addEventListener("drop", function (event) {
417
+ event.preventDefault();
418
+ event.stopPropagation();
419
+ var side = overlaySide(event);
420
+ var droppedId = draggedSessionId || parseInt(event.dataTransfer.getData("text/plain"), 10);
421
+ openSplit(side, droppedId);
422
+ });
423
+ mainPanels.appendChild(overlay);
424
+ return overlay;
425
+ }
426
+
427
+ function handleSessionDragStart(event) {
428
+ if (store.get('splitPanes')) return;
429
+ var item = event.target.closest("[data-session-id][draggable='true']");
430
+ if (!item) return;
431
+ draggedSessionId = parseInt(item.dataset.sessionId, 10);
432
+ if (groupedSessionIds(store.get('splitGroups')).has(draggedSessionId)) {
433
+ draggedSessionId = null;
434
+ return;
435
+ }
436
+ if (draggedSessionId) showDropOverlay();
437
+ }
438
+
439
+ function handleSidebarSessionClick(event) {
440
+ if (!store.get('splitPanes') || event.button !== 0) return;
441
+ if (event.target.closest(".session-close-btn, .session-more-btn")) return;
442
+ var item = event.target.closest(".session-item[data-session-id], .session-loop-child[data-session-id]");
443
+ if (!item) return;
444
+ var sessionId = parseInt(item.dataset.sessionId, 10);
445
+ if (!sessionId) return;
446
+ event.preventDefault();
447
+ event.stopImmediatePropagation();
448
+ switchNativeSession(sessionId);
449
+ }
450
+
451
+ export function initSplitView() {
452
+ if (store.get('paneMode')) return;
453
+ var mainPanels = document.getElementById("main-panels");
454
+ nativeApp = document.getElementById("app");
455
+ if (!mainPanels || !nativeApp) return;
456
+ mainPanelsEl = mainPanels;
457
+ stickyNotesContainer = document.getElementById("sticky-notes-container");
458
+ if (stickyNotesContainer) {
459
+ stickyNotesHome = stickyNotesContainer.parentNode;
460
+ stickyNotesAnchor = stickyNotesContainer.nextSibling;
461
+ }
462
+
463
+ host = document.createElement("div");
464
+ host.id = "split-host";
465
+ mainPanels.insertBefore(host, nativeApp.nextSibling);
466
+ dropOverlay = createDropOverlay(mainPanels);
467
+
468
+ window.addEventListener("message", handlePaneMessage);
469
+ document.addEventListener("dragstart", handleSessionDragStart);
470
+ document.addEventListener("dragend", hideDropOverlay);
471
+ document.addEventListener("drop", function (event) {
472
+ if (dropOverlay && !dropOverlay.contains(event.target)) hideDropOverlay();
473
+ });
474
+ document.addEventListener("click", handleSidebarSessionClick, true);
475
+ store.subscribe(function (state, prev) {
476
+ if (state.splitPanes !== prev.splitPanes) renderSplit(state.splitPanes);
477
+ // Switching to a session outside the open split (new_session, palette,
478
+ // notification click) closes the split UI; the group itself persists.
479
+ if (state.activeSessionId !== prev.activeSessionId && state.splitPanes && state.splitPanes.panes) {
480
+ var sp = state.splitPanes.panes;
481
+ if (state.activeSessionId !== sp[0].sessionId && state.activeSessionId !== sp[1].sessionId) {
482
+ store.set({ splitPanes: null });
483
+ }
484
+ }
485
+ if (state.splitGroups !== prev.splitGroups) {
486
+ var split = state.splitPanes;
487
+ if (!split || !split.panes) return;
488
+ if (split.groupId) {
489
+ var stillExists = state.splitGroups.some(function (group) { return group.id === split.groupId; });
490
+ if (!stillExists) switchNativeSession(split.panes[0].sessionId);
491
+ return;
492
+ }
493
+ var ids = [split.panes[0].sessionId, split.panes[1].sessionId];
494
+ var confirmed = findSplitGroup(state.splitGroups, ids);
495
+ if (confirmed) store.set({ splitPanes: { groupId: confirmed.id, panes: split.panes } });
496
+ }
497
+ });
498
+ renderSplit(store.get('splitPanes'));
499
+ }
@@ -374,23 +374,25 @@ function renderNote(data) {
374
374
 
375
375
  function setupDrag(noteEl, spacerEl, noteId) {
376
376
  var dragging = false;
377
+ var pointerId = null;
377
378
  var startX, startY, origX, origY;
378
379
 
379
- spacerEl.addEventListener("mousedown", function (e) {
380
- if (e.button !== 0) return;
380
+ spacerEl.addEventListener("pointerdown", function (e) {
381
+ if (e.pointerType === "mouse" && e.button !== 0) return;
381
382
  e.preventDefault();
382
383
  dragging = true;
384
+ pointerId = e.pointerId;
383
385
  startX = e.clientX;
384
386
  startY = e.clientY;
385
387
  origX = parseInt(noteEl.style.left) || 0;
386
388
  origY = parseInt(noteEl.style.top) || 0;
387
389
  noteEl.classList.add("dragging");
388
- document.addEventListener("mousemove", onMove);
389
- document.addEventListener("mouseup", onUp);
390
+ document.body.classList.add("sticky-note-interacting");
391
+ spacerEl.setPointerCapture(pointerId);
390
392
  });
391
393
 
392
394
  function onMove(e) {
393
- if (!dragging) return;
395
+ if (!dragging || e.pointerId !== pointerId) return;
394
396
  var dx = e.clientX - startX;
395
397
  var dy = e.clientY - startY;
396
398
  var c = clampPos(origX + dx, origY + dy, noteEl.offsetWidth, noteEl.offsetHeight);
@@ -398,80 +400,51 @@ function setupDrag(noteEl, spacerEl, noteId) {
398
400
  noteEl.style.top = c.y + "px";
399
401
  }
400
402
 
401
- function onUp() {
402
- if (!dragging) return;
403
+ function onUp(e) {
404
+ if (!dragging || (e && e.pointerId !== pointerId)) return;
403
405
  dragging = false;
404
406
  noteEl.classList.remove("dragging");
405
- document.removeEventListener("mousemove", onMove);
406
- document.removeEventListener("mouseup", onUp);
407
+ document.body.classList.remove("sticky-note-interacting");
408
+ if (pointerId !== null && spacerEl.hasPointerCapture(pointerId)) spacerEl.releasePointerCapture(pointerId);
409
+ pointerId = null;
407
410
  debouncedUpdate(noteId, {
408
411
  x: parseInt(noteEl.style.left),
409
412
  y: parseInt(noteEl.style.top),
410
413
  }, 200);
411
414
  }
412
415
 
413
- // Touch support
414
- spacerEl.addEventListener("touchstart", function (e) {
415
- if (e.touches.length !== 1) return;
416
- var touch = e.touches[0];
417
- dragging = true;
418
- startX = touch.clientX;
419
- startY = touch.clientY;
420
- origX = parseInt(noteEl.style.left) || 0;
421
- origY = parseInt(noteEl.style.top) || 0;
422
- noteEl.classList.add("dragging");
423
- document.addEventListener("touchmove", onTouchMove, { passive: false });
424
- document.addEventListener("touchend", onTouchEnd);
425
- }, { passive: true });
426
-
427
- function onTouchMove(e) {
428
- if (!dragging) return;
429
- e.preventDefault();
430
- var touch = e.touches[0];
431
- var dx = touch.clientX - startX;
432
- var dy = touch.clientY - startY;
433
- var c = clampPos(origX + dx, origY + dy, noteEl.offsetWidth, noteEl.offsetHeight);
434
- noteEl.style.left = c.x + "px";
435
- noteEl.style.top = c.y + "px";
436
- }
437
-
438
- function onTouchEnd() {
439
- if (!dragging) return;
440
- dragging = false;
441
- noteEl.classList.remove("dragging");
442
- document.removeEventListener("touchmove", onTouchMove);
443
- document.removeEventListener("touchend", onTouchEnd);
444
- debouncedUpdate(noteId, {
445
- x: parseInt(noteEl.style.left),
446
- y: parseInt(noteEl.style.top),
447
- }, 200);
448
- }
416
+ spacerEl.addEventListener("pointermove", onMove);
417
+ spacerEl.addEventListener("pointerup", onUp);
418
+ spacerEl.addEventListener("pointercancel", onUp);
419
+ spacerEl.addEventListener("lostpointercapture", onUp);
449
420
  }
450
421
 
451
422
  // --- Resize ---
452
423
 
453
424
  function setupResize(noteEl, handle, noteId) {
454
425
  var resizing = false;
426
+ var pointerId = null;
455
427
  var startX, startY, origW, origH;
456
428
  var MIN_W = 160;
457
429
  var MIN_H = 80;
458
430
 
459
- handle.addEventListener("mousedown", function (e) {
460
- if (e.button !== 0) return;
431
+ handle.addEventListener("pointerdown", function (e) {
432
+ if (e.pointerType === "mouse" && e.button !== 0) return;
461
433
  e.preventDefault();
462
434
  e.stopPropagation();
463
435
  resizing = true;
436
+ pointerId = e.pointerId;
464
437
  startX = e.clientX;
465
438
  startY = e.clientY;
466
439
  origW = noteEl.offsetWidth;
467
440
  origH = noteEl.offsetHeight;
468
441
  noteEl.classList.add("resizing");
469
- document.addEventListener("mousemove", onMove);
470
- document.addEventListener("mouseup", onUp);
442
+ document.body.classList.add("sticky-note-interacting");
443
+ handle.setPointerCapture(pointerId);
471
444
  });
472
445
 
473
446
  function onMove(e) {
474
- if (!resizing) return;
447
+ if (!resizing || e.pointerId !== pointerId) return;
475
448
  var rawW = Math.max(MIN_W, origW + (e.clientX - startX));
476
449
  var rawH = Math.max(MIN_H, origH + (e.clientY - startY));
477
450
  var cs = clampSize(parseInt(noteEl.style.left) || 0, parseInt(noteEl.style.top) || 0, rawW, rawH);
@@ -479,55 +452,23 @@ function setupResize(noteEl, handle, noteId) {
479
452
  noteEl.style.height = Math.max(MIN_H, cs.h) + "px";
480
453
  }
481
454
 
482
- function onUp() {
483
- if (!resizing) return;
455
+ function onUp(e) {
456
+ if (!resizing || (e && e.pointerId !== pointerId)) return;
484
457
  resizing = false;
485
458
  noteEl.classList.remove("resizing");
486
- document.removeEventListener("mousemove", onMove);
487
- document.removeEventListener("mouseup", onUp);
459
+ document.body.classList.remove("sticky-note-interacting");
460
+ if (pointerId !== null && handle.hasPointerCapture(pointerId)) handle.releasePointerCapture(pointerId);
461
+ pointerId = null;
488
462
  debouncedUpdate(noteId, {
489
463
  w: noteEl.offsetWidth,
490
464
  h: noteEl.offsetHeight,
491
465
  }, 200);
492
466
  }
493
467
 
494
- // Touch resize
495
- handle.addEventListener("touchstart", function (e) {
496
- if (e.touches.length !== 1) return;
497
- e.stopPropagation();
498
- var touch = e.touches[0];
499
- resizing = true;
500
- startX = touch.clientX;
501
- startY = touch.clientY;
502
- origW = noteEl.offsetWidth;
503
- origH = noteEl.offsetHeight;
504
- noteEl.classList.add("resizing");
505
- document.addEventListener("touchmove", onTouchMove, { passive: false });
506
- document.addEventListener("touchend", onTouchEnd);
507
- }, { passive: true });
508
-
509
- function onTouchMove(e) {
510
- if (!resizing) return;
511
- e.preventDefault();
512
- var touch = e.touches[0];
513
- var rawW = Math.max(MIN_W, origW + (touch.clientX - startX));
514
- var rawH = Math.max(MIN_H, origH + (touch.clientY - startY));
515
- var cs = clampSize(parseInt(noteEl.style.left) || 0, parseInt(noteEl.style.top) || 0, rawW, rawH);
516
- noteEl.style.width = Math.max(MIN_W, cs.w) + "px";
517
- noteEl.style.height = Math.max(MIN_H, cs.h) + "px";
518
- }
519
-
520
- function onTouchEnd() {
521
- if (!resizing) return;
522
- resizing = false;
523
- noteEl.classList.remove("resizing");
524
- document.removeEventListener("touchmove", onTouchMove);
525
- document.removeEventListener("touchend", onTouchEnd);
526
- debouncedUpdate(noteId, {
527
- w: noteEl.offsetWidth,
528
- h: noteEl.offsetHeight,
529
- }, 200);
530
- }
468
+ handle.addEventListener("pointermove", onMove);
469
+ handle.addEventListener("pointerup", onUp);
470
+ handle.addEventListener("pointercancel", onUp);
471
+ handle.addEventListener("lostpointercapture", onUp);
531
472
  }
532
473
 
533
474
  // --- Text edit (contenteditable) ---