@oar-sh/oar 0.9.10 → 0.9.12

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 (135) hide show
  1. package/CHANGELOG.md +440 -1
  2. package/README.md +156 -972
  3. package/package.json +13 -13
  4. package/server/README.md +112 -25
  5. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  6. package/server/claude-worker/claude-sdk-adapter.mjs +57 -4
  7. package/server/claude-worker/claude-session-process.mjs +217 -11
  8. package/server/claude-worker/claude-session-worker.mjs +26 -1
  9. package/server/claude-worker/task-output-discovery.mjs +126 -0
  10. package/server/copilot-worker/copilot-sdk-session-process.mjs +235 -14
  11. package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -0
  12. package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
  13. package/server/copilot-worker/copilot-task-settle.mjs +96 -0
  14. package/server/db-schema.mjs +68 -2
  15. package/server/grok-worker/acp-client.mjs +2 -1
  16. package/server/grok-worker/acp-host-services.mjs +46 -6
  17. package/server/grok-worker/grok-background-tasks.mjs +241 -0
  18. package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
  19. package/server/grok-worker/grok-session-worker.mjs +33 -1
  20. package/server/grok-worker/grok-turn-runner.mjs +11 -0
  21. package/server/migrations/0013-previews.mjs +130 -0
  22. package/server/migrations/0014-conversation-groups.mjs +89 -0
  23. package/server/migrations/0015-conversation-group-position.mjs +76 -0
  24. package/server/migrations/0016-share-access-log.mjs +110 -0
  25. package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
  26. package/server/public/app/about-ui.js +73 -0
  27. package/server/public/app/about-view.mjs +234 -0
  28. package/server/public/app/action-confirmations.js +31 -14
  29. package/server/public/app/agent-exchange-fold-view.mjs +40 -101
  30. package/server/public/app/api-client.js +264 -16
  31. package/server/public/app/ask-user-view.js +17 -15
  32. package/server/public/app/background-tasks-view.mjs +285 -16
  33. package/server/public/app/bootstrap.js +253 -37
  34. package/server/public/app/chat-actions-menu-state.mjs +20 -10
  35. package/server/public/app/claude-background-command-limit-options.mjs +21 -0
  36. package/server/public/app/context-menu.mjs +90 -6
  37. package/server/public/app/conversation-group-drag.mjs +191 -0
  38. package/server/public/app/conversation-group-order.mjs +265 -0
  39. package/server/public/app/conversation-group-state.mjs +100 -0
  40. package/server/public/app/conversation-list-drag.mjs +449 -0
  41. package/server/public/app/conversation-payload-cache.mjs +357 -0
  42. package/server/public/app/conversation-turn-state.mjs +81 -0
  43. package/server/public/app/conversation-view.js +567 -94
  44. package/server/public/app/conversation-window-retention.mjs +96 -0
  45. package/server/public/app/cwd-picker.js +1 -1
  46. package/server/public/app/hidden-messages-view.mjs +312 -0
  47. package/server/public/app/host-suspend-ui.js +7 -1
  48. package/server/public/app/journal-view.js +1033 -82
  49. package/server/public/app/preview-cards.mjs +117 -31
  50. package/server/public/app/preview-command.mjs +10 -1
  51. package/server/public/app/relay-board-view.js +19 -6
  52. package/server/public/app/relay-prompt-sanitizer.mjs +11 -4
  53. package/server/public/app/relay-restart-error.mjs +16 -0
  54. package/server/public/app/settings-modal.js +150 -10
  55. package/server/public/app/settings-tabs.js +1 -1
  56. package/server/public/app/share-format.mjs +49 -0
  57. package/server/public/app/share-preview-marks.mjs +167 -0
  58. package/server/public/app/share-state.js +109 -0
  59. package/server/public/app/shared-conversations-settings.js +480 -0
  60. package/server/public/app/socket-handlers.js +138 -20
  61. package/server/public/app/store.js +121 -30
  62. package/server/public/app/subagent-row.mjs +3 -3
  63. package/server/public/app/subagent-viewer.mjs +37 -7
  64. package/server/public/app/task-console-render.mjs +237 -0
  65. package/server/public/app/task-console-watch.mjs +42 -0
  66. package/server/public/app/task-console.mjs +630 -0
  67. package/server/public/app/task-output-text.mjs +234 -0
  68. package/server/public/app/thinking-follow.js +0 -4
  69. package/server/public/app/transcript-island.mjs +73 -0
  70. package/server/public/app/transcript-jump-bottom-view.mjs +230 -26
  71. package/server/public/app/transcript-jump-bottom.mjs +170 -22
  72. package/server/public/app/transcript-reading-anchor.mjs +256 -0
  73. package/server/public/app/transcript-reload-freshness.mjs +17 -1
  74. package/server/public/app/transcript-viewport-anchor.mjs +159 -0
  75. package/server/public/index.html +625 -21
  76. package/server/relay-tools.md +3 -1
  77. package/server/relay.mjs +2 -1
  78. package/server/repositories/conversation-group-repository.mjs +444 -0
  79. package/server/repositories/message-repository.mjs +9 -0
  80. package/server/repositories/question-repository.mjs +4 -0
  81. package/server/repositories/session-repository.mjs +12 -37
  82. package/server/repositories/share-repository.mjs +322 -0
  83. package/server/repositories/share-visibility-statements.mjs +10 -0
  84. package/server/routes/about-routes.mjs +15 -0
  85. package/server/routes/cache-routes.mjs +4 -0
  86. package/server/routes/conversation-group-routes.mjs +171 -0
  87. package/server/routes/messages-routes.mjs +433 -68
  88. package/server/routes/preview-routes.mjs +9 -1
  89. package/server/routes/sessions-routes.mjs +901 -253
  90. package/server/server-runtime.mjs +392 -99
  91. package/server/services/about-service.mjs +461 -0
  92. package/server/services/background-task-store.mjs +365 -0
  93. package/server/services/background-work-view.mjs +228 -0
  94. package/server/services/cache-rebuild-service.mjs +30 -0
  95. package/server/services/cli-process-runner.mjs +4 -21
  96. package/server/services/conversation-history-page.mjs +199 -0
  97. package/server/services/delete-archive-service.mjs +4 -1
  98. package/server/services/detached-worker-watch-service.mjs +150 -0
  99. package/server/services/hidden-message-list.mjs +21 -0
  100. package/server/services/host-suspend-command.mjs +2 -1
  101. package/server/services/message-pin-list.mjs +1 -1
  102. package/server/services/preview-health-probe.mjs +8 -1
  103. package/server/services/preview-proxy-server.mjs +8 -4
  104. package/server/services/preview-registry-service.mjs +676 -26
  105. package/server/services/preview-static-handler.mjs +40 -7
  106. package/server/services/preview-store.mjs +274 -0
  107. package/server/services/rate-limit-service.mjs +12 -1
  108. package/server/services/relay-auth.mjs +183 -0
  109. package/server/services/relay-cli-launcher-service.mjs +3 -1
  110. package/server/services/relay-event-service.mjs +17 -0
  111. package/server/services/relay-prompt-sanitizer.mjs +11 -4
  112. package/server/services/remote-relay-dispatcher.mjs +187 -7
  113. package/server/services/request-boundary.mjs +183 -0
  114. package/server/services/session-activity-tracker.mjs +231 -19
  115. package/server/services/session-worker-launch-service.mjs +190 -6
  116. package/server/services/session-worker-process-service.mjs +10 -6
  117. package/server/services/share-access-log-service.mjs +123 -0
  118. package/server/services/share-path-redaction.mjs +424 -0
  119. package/server/services/status-event-service.mjs +7 -3
  120. package/server/services/task-output-rooms.mjs +259 -0
  121. package/server/services/task-output-service.mjs +562 -0
  122. package/server/services/tunnel-worker-path-guard.mjs +24 -41
  123. package/server/services/windows-autostart-service.mjs +6 -1
  124. package/server/services/windows-boot-autostart-service.mjs +207 -23
  125. package/shared/claude-background-command-limit.mjs +83 -0
  126. package/shared/preview-tool-core.mjs +15 -5
  127. package/shared/remote-relay-contract.mjs +35 -3
  128. package/shared/share-path-privacy.mjs +30 -0
  129. package/shared/task-output-text.mjs +737 -0
  130. package/shared/windows-system-tools.mjs +106 -0
  131. package/shared/worker-runtime/background-task-digest.mjs +30 -0
  132. package/shared/worker-runtime/heartbeat.mjs +24 -0
  133. package/shared/worker-runtime/kept-posts.mjs +83 -0
  134. package/shared/worker-runtime/task-output-spool.mjs +181 -0
  135. package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
@@ -0,0 +1,449 @@
1
+ // Drag and drop of conversation rows in the sidebar: the pointer side. One
2
+ // implementation for mouse and touch. A mouse drag starts after a few pixels
3
+ // with the primary button held on a row (not on its buttons or its pill); a
4
+ // finger lifts a row with a long press (context-menu.mjs) and drags it once it
5
+ // moves. Where a row lands is decided by conversation-group-drag.mjs; the
6
+ // sidebar does the call and the re-render (`onDrop`).
7
+ //
8
+ // While a row is lifted or dragged the owner holds its re-renders
9
+ // (`onHold(true)`), so the rows under the finger stay the ones measured; only
10
+ // a hover-unfold re-renders (`rerender()`), after which the marks are put
11
+ // back. A folded head hovered for a moment opens for the rest of the drag
12
+ // (`setDragOpen(groupId, true)`), and closes again unless the row went in.
13
+
14
+ import { NO_DROP, autoScrollStep, resolveDropTarget } from './conversation-group-drag.mjs';
15
+
16
+ const MOUSE_DRAG_THRESHOLD_PX = 4;
17
+ const UNFOLD_HOVER_MS = 600;
18
+ const CLICK_GUARD_MS = 400;
19
+ // A held list is let go after the click a release makes, or after this
20
+ // long when none comes (a touch release that was no tap).
21
+ const RELEASE_FALLBACK_MS = 300;
22
+ // The parts of a row that are controls of their own.
23
+ const NOT_A_HANDLE = 'button, a, input, textarea, select, [data-open-waiting], [role="button"]';
24
+ const DROP_CLASSES = ['conv-drop-before', 'conv-drop-after', 'conv-drop-inside', 'conv-drop-group', 'conv-drop-new-group'];
25
+
26
+ function cssEscape(value) {
27
+ const text = String(value ?? '');
28
+ return globalThis.CSS?.escape ? globalThis.CSS.escape(text) : text.replace(/["\\]/g, '\\$&');
29
+ }
30
+
31
+ /** A sidebar row's item key: its conversation id, or `group:<id>` for a synthetic head. */
32
+ export function conversationRowKey(row) {
33
+ const id = String(row?.dataset?.conversationId || '').trim();
34
+ return id || `group:${String(row?.dataset?.groupId || '').trim()}`;
35
+ }
36
+
37
+ /**
38
+ * Binds the drag to `list`. `isEnabled()` says whether rows may move now (not
39
+ * in the Archived view, not under a filter); `describeItems()` gives the
40
+ * rendered list as conversation-group-drag.mjs items. Returns `{ canLift,
41
+ * lift, unlift, beginTouchDrag, cancel, isActive }` for the long press to
42
+ * drive.
43
+ */
44
+ export function bindConversationListDrag(list, {
45
+ isEnabled = () => true,
46
+ describeItems = () => [],
47
+ onHold = () => {},
48
+ rerender = () => {},
49
+ setDragOpen = () => {},
50
+ onDrop = () => {},
51
+ documentRef = document,
52
+ windowRef = window,
53
+ releaseFallbackMs = RELEASE_FALLBACK_MS,
54
+ } = {}) {
55
+ let session = null;
56
+ let mousePress = null;
57
+ // A finger on a row: the list holds still from the touch on, so the row
58
+ // the long press lifts is the one on screen, and its release reaches the
59
+ // list (a re-render would take the touched node out of it).
60
+ let touchPress = null;
61
+ let liftedRow = null;
62
+ let holding = false;
63
+ let swallowClicksUntil = 0;
64
+ // The row a finger holds keeps its own guard against scrolling: a
65
+ // re-render (a hover-unfold) takes it out of the list, and touch events
66
+ // still go to the node the finger first touched, so a guard on the list
67
+ // alone would no longer see them.
68
+ let guardedRow = null;
69
+ const blockTouchMove = (event) => {
70
+ if (event.cancelable) event.preventDefault();
71
+ };
72
+ const guardRow = (row) => {
73
+ if (guardedRow === row) return;
74
+ unguardRow();
75
+ guardedRow = row;
76
+ row?.addEventListener('touchmove', blockTouchMove, { passive: false });
77
+ };
78
+ const unguardRow = () => {
79
+ guardedRow?.removeEventListener('touchmove', blockTouchMove, { passive: false });
80
+ guardedRow = null;
81
+ };
82
+
83
+ // `holding`: what this module wants; `held`: what the owner was told. A
84
+ // release waits for the click it makes, so a tap lands on the row it was
85
+ // on rather than on one a re-render put there; a drop lets go at once.
86
+ let held = false;
87
+ let releaseTimer = null;
88
+ const settleRelease = () => {
89
+ if (releaseTimer !== null) clearTimeout(releaseTimer);
90
+ releaseTimer = null;
91
+ windowRef.removeEventListener('click', onReleaseClick, true);
92
+ if (!holding && held) {
93
+ held = false;
94
+ onHold(false);
95
+ }
96
+ };
97
+ function onReleaseClick() {
98
+ setTimeout(settleRelease, 0);
99
+ }
100
+ const hold = (on, { now = false } = {}) => {
101
+ if (holding !== on) holding = on;
102
+ if (on) {
103
+ if (releaseTimer !== null) clearTimeout(releaseTimer);
104
+ releaseTimer = null;
105
+ windowRef.removeEventListener('click', onReleaseClick, true);
106
+ if (!held) {
107
+ held = true;
108
+ onHold(true);
109
+ }
110
+ return;
111
+ }
112
+ if (!held) return;
113
+ if (now) {
114
+ settleRelease();
115
+ return;
116
+ }
117
+ if (releaseTimer !== null) return;
118
+ windowRef.addEventListener('click', onReleaseClick, true);
119
+ releaseTimer = setTimeout(settleRelease, releaseFallbackMs);
120
+ };
121
+
122
+ const rowFor = (key) => {
123
+ if (!key) return null;
124
+ if (key.startsWith('group:')) return list.querySelector(`.conv-group-synthetic[data-group-id="${cssEscape(key.slice(6))}"]`);
125
+ return list.querySelector(`.conv-item[data-conversation-id="${cssEscape(key)}"]`);
126
+ };
127
+
128
+ const draggableRow = (target) => {
129
+ const row = target?.closest?.('.conv-item');
130
+ if (!row || !list.contains(row) || !row.dataset.conversationId) return null;
131
+ const control = target.closest(NOT_A_HANDLE);
132
+ if (control && control !== row && row.contains(control)) return null;
133
+ return row;
134
+ };
135
+
136
+ // ── Lift (a long press on touch) ──
137
+ /** Whether a long press may lift this row: a row with a conversation, while dragging is on. */
138
+ function canLift(row) {
139
+ return !!row?.dataset?.conversationId && isEnabled();
140
+ }
141
+
142
+ function lift(row) {
143
+ unlift();
144
+ if (!canLift(row)) return false;
145
+ liftedRow = row;
146
+ hold(true);
147
+ guardRow(row);
148
+ row.classList.add('conv-drag-lifted');
149
+ windowRef.navigator?.vibrate?.(10);
150
+ return true;
151
+ }
152
+
153
+ function unlift() {
154
+ if (!liftedRow) return;
155
+ liftedRow.classList.remove('conv-drag-lifted');
156
+ rowFor(conversationRowKey(liftedRow))?.classList.remove('conv-drag-lifted');
157
+ liftedRow = null;
158
+ if (!session) {
159
+ unguardRow();
160
+ if (touchPress === null) hold(false);
161
+ }
162
+ }
163
+
164
+ // ── The drag ──
165
+ function markSource() {
166
+ if (!session) return;
167
+ for (const node of list.querySelectorAll('.conv-drag-source')) {
168
+ if (node.dataset.conversationId !== session.id) node.classList.remove('conv-drag-source');
169
+ }
170
+ rowFor(session.id)?.classList.add('conv-drag-source');
171
+ }
172
+
173
+ function clearMarks() {
174
+ for (const name of DROP_CLASSES) {
175
+ for (const node of list.querySelectorAll(`.${name}`)) node.classList.remove(name);
176
+ }
177
+ }
178
+
179
+ function paint() {
180
+ clearMarks();
181
+ const result = session?.result || NO_DROP;
182
+ list.dataset.dropAction = result.action;
183
+ if (result.action === 'none' || !result.indicator) return;
184
+ const row = rowFor(result.indicator.itemId);
185
+ if (result.groupId) {
186
+ for (const node of list.querySelectorAll(`.conv-item[data-group-id="${cssEscape(result.groupId)}"]`)) node.classList.add('conv-drop-group');
187
+ }
188
+ if (!row) return;
189
+ if (result.action === 'new-group') row.classList.add('conv-drop-new-group');
190
+ else if (result.indicator.edge === 'before') row.classList.add('conv-drop-before');
191
+ else if (result.indicator.edge === 'after') row.classList.add('conv-drop-after');
192
+ else row.classList.add('conv-drop-inside');
193
+ }
194
+
195
+ /** What the pointer is over: `{ key, y }` on a row, `{ below: true }` under the last one, or null. */
196
+ function hoverAt(x, y) {
197
+ const box = list.getBoundingClientRect();
198
+ if (x < box.left || x > box.right || y < box.top || y > box.bottom) return null;
199
+ const rows = [...list.querySelectorAll('.conv-item')];
200
+ if (!rows.length) return null;
201
+ for (const row of rows) {
202
+ const rect = row.getBoundingClientRect();
203
+ // The gap under a row (its margin) counts as that row's bottom.
204
+ if (y < rect.top) return { key: conversationRowKey(row), y: 0 };
205
+ if (y <= rect.bottom + 2) return { key: conversationRowKey(row), y: rect.height > 0 ? (y - rect.top) / rect.height : 0.5 };
206
+ }
207
+ return { below: true };
208
+ }
209
+
210
+ function update(x, y) {
211
+ if (!session) return;
212
+ session.x = x;
213
+ session.y = y;
214
+ session.ghost.style.transform = `translate(${Math.round(x - session.offsetX)}px, ${Math.round(y - session.offsetY)}px)`;
215
+ const hover = hoverAt(x, y);
216
+ if (!hover && session.pointerType !== 'mouse') {
217
+ // A finger that leaves the list gives the row back.
218
+ end(false);
219
+ return;
220
+ }
221
+ const items = describeItems();
222
+ session.result = resolveDropTarget({ items, draggedId: session.id, hover });
223
+ paint();
224
+ const hovered = hover?.key ? items.find((item) => item.key === hover.key) : null;
225
+ const foldedGroupId = hovered?.kind === 'head' && hovered.open === false ? hovered.groupId : null;
226
+ if (foldedGroupId === session.unfoldGroupId) return;
227
+ if (session.unfoldTimer) clearTimeout(session.unfoldTimer);
228
+ session.unfoldTimer = null;
229
+ session.unfoldGroupId = foldedGroupId;
230
+ if (!foldedGroupId) return;
231
+ session.unfoldTimer = setTimeout(() => {
232
+ if (!session) return;
233
+ session.unfoldTimer = null;
234
+ session.unfolded.add(foldedGroupId);
235
+ setDragOpen(foldedGroupId, true);
236
+ rerender();
237
+ markSource();
238
+ session.unfoldGroupId = null;
239
+ update(session.x, session.y);
240
+ }, UNFOLD_HOVER_MS);
241
+ }
242
+
243
+ function frame() {
244
+ if (!session) return;
245
+ const box = list.getBoundingClientRect();
246
+ const step = autoScrollStep(session.y, box.top, box.bottom);
247
+ if (step) {
248
+ const before = list.scrollTop;
249
+ list.scrollTop = before + step;
250
+ if (list.scrollTop !== before) update(session.x, session.y);
251
+ }
252
+ if (session) session.frame = windowRef.requestAnimationFrame(frame);
253
+ }
254
+
255
+ function makeGhost(row, rect) {
256
+ const ghost = row.cloneNode(true);
257
+ ghost.removeAttribute('onclick');
258
+ ghost.removeAttribute('id');
259
+ for (const node of ghost.querySelectorAll('button, [onclick], [id]')) {
260
+ if (node.matches('button')) node.remove();
261
+ else {
262
+ node.removeAttribute('onclick');
263
+ node.removeAttribute('id');
264
+ }
265
+ }
266
+ ghost.classList.remove('conv-drag-lifted', 'conv-drag-source', ...DROP_CLASSES);
267
+ ghost.classList.add('conv-drag-ghost');
268
+ ghost.setAttribute('aria-hidden', 'true');
269
+ ghost.removeAttribute('data-conversation-id');
270
+ ghost.removeAttribute('data-group-id');
271
+ ghost.style.width = `${Math.round(rect.width)}px`;
272
+ return ghost;
273
+ }
274
+
275
+ const onMove = (event) => {
276
+ if (!session || event.pointerId !== session.pointerId) return;
277
+ update(event.clientX, event.clientY);
278
+ };
279
+ const onUp = (event) => {
280
+ if (!session || event.pointerId !== session.pointerId) return;
281
+ end(true);
282
+ };
283
+ const onCancel = (event) => {
284
+ if (!session || event.pointerId !== session.pointerId) return;
285
+ end(false);
286
+ };
287
+ const onKey = (event) => {
288
+ if (!session || event.key !== 'Escape') return;
289
+ event.preventDefault();
290
+ event.stopPropagation();
291
+ end(false);
292
+ };
293
+ const onBlur = () => end(false);
294
+
295
+ function begin(row, startX, startY, event) {
296
+ if (session || !isEnabled()) return false;
297
+ const id = String(row?.dataset?.conversationId || '').trim();
298
+ if (!id || !describeItems().some((item) => item.conversationId === id)) return false;
299
+ // The row on screen now: a mouse press may have outlived a re-render.
300
+ const shown = rowFor(id) || row;
301
+ const rect = shown.getBoundingClientRect();
302
+ const ghost = makeGhost(shown, rect);
303
+ ghost.style.left = '0px';
304
+ ghost.style.top = '0px';
305
+ documentRef.body.append(ghost);
306
+ session = {
307
+ id,
308
+ title: row.querySelector('.conv-title')?.textContent?.trim() || '',
309
+ pointerId: event.pointerId,
310
+ pointerType: event.pointerType || 'mouse',
311
+ ghost,
312
+ offsetX: startX - rect.left,
313
+ offsetY: startY - rect.top,
314
+ x: startX,
315
+ y: startY,
316
+ result: NO_DROP,
317
+ unfoldTimer: null,
318
+ unfoldGroupId: null,
319
+ unfolded: new Set(),
320
+ frame: 0,
321
+ };
322
+ hold(true);
323
+ if (session.pointerType !== 'mouse') guardRow(row);
324
+ if (liftedRow) {
325
+ liftedRow.classList.remove('conv-drag-lifted');
326
+ liftedRow = null;
327
+ }
328
+ markSource();
329
+ list.classList.add('conv-list-dragging');
330
+ // No page scroll, pull-to-refresh or drawer gesture under the finger.
331
+ list.style.touchAction = 'none';
332
+ try { list.setPointerCapture?.(event.pointerId); } catch {}
333
+ documentRef.addEventListener('pointermove', onMove, true);
334
+ documentRef.addEventListener('pointerup', onUp, true);
335
+ documentRef.addEventListener('pointercancel', onCancel, true);
336
+ documentRef.addEventListener('keydown', onKey, true);
337
+ windowRef.addEventListener('blur', onBlur);
338
+ update(event.clientX, event.clientY);
339
+ if (session) session.frame = windowRef.requestAnimationFrame(frame);
340
+ return true;
341
+ }
342
+
343
+ function end(commit) {
344
+ const ended = session;
345
+ if (!ended) return;
346
+ session = null;
347
+ if (ended.frame) windowRef.cancelAnimationFrame(ended.frame);
348
+ if (ended.unfoldTimer) clearTimeout(ended.unfoldTimer);
349
+ documentRef.removeEventListener('pointermove', onMove, true);
350
+ documentRef.removeEventListener('pointerup', onUp, true);
351
+ documentRef.removeEventListener('pointercancel', onCancel, true);
352
+ documentRef.removeEventListener('keydown', onKey, true);
353
+ windowRef.removeEventListener('blur', onBlur);
354
+ try { list.releasePointerCapture?.(ended.pointerId); } catch {}
355
+ ended.ghost.remove();
356
+ clearMarks();
357
+ delete list.dataset.dropAction;
358
+ for (const node of list.querySelectorAll('.conv-drag-source, .conv-drag-lifted')) node.classList.remove('conv-drag-source', 'conv-drag-lifted');
359
+ list.classList.remove('conv-list-dragging');
360
+ list.style.touchAction = '';
361
+ unguardRow();
362
+ swallowClicksUntil = Date.now() + CLICK_GUARD_MS;
363
+ const result = commit ? ended.result : NO_DROP;
364
+ const landed = result.action !== 'none' ? result.groupId : null;
365
+ for (const groupId of ended.unfolded) {
366
+ if (groupId !== landed) setDragOpen(groupId, false);
367
+ }
368
+ hold(false, { now: true });
369
+ if (result.action !== 'none') onDrop(result, ended.id);
370
+ }
371
+
372
+ // Mouse: a press on a row becomes a drag after a few pixels.
373
+ list.addEventListener('pointerdown', (event) => {
374
+ if (session) return;
375
+ if (event.pointerType !== 'mouse') {
376
+ const row = event.target?.closest?.('.conv-item');
377
+ if (!row || !list.contains(row)) return;
378
+ touchPress = event.pointerId;
379
+ hold(true);
380
+ return;
381
+ }
382
+ if (event.button !== 0) return;
383
+ const row = draggableRow(event.target);
384
+ mousePress = row && isEnabled() ? { row, x: event.clientX, y: event.clientY, pointerId: event.pointerId } : null;
385
+ });
386
+ const endTouchPress = (event) => {
387
+ if (touchPress === null || (event && event.pointerId !== touchPress)) return;
388
+ touchPress = null;
389
+ if (!session && !liftedRow) hold(false);
390
+ };
391
+ // Captured on the document, ahead of the long press's own release.
392
+ documentRef.addEventListener('pointerup', endTouchPress, true);
393
+ documentRef.addEventListener('pointercancel', endTouchPress, true);
394
+ windowRef.addEventListener('blur', () => endTouchPress(null));
395
+ list.addEventListener('pointermove', (event) => {
396
+ if (session || !mousePress || event.pointerId !== mousePress.pointerId) return;
397
+ if ((event.buttons & 1) === 0) {
398
+ mousePress = null;
399
+ return;
400
+ }
401
+ if (Math.hypot(event.clientX - mousePress.x, event.clientY - mousePress.y) <= MOUSE_DRAG_THRESHOLD_PX) return;
402
+ const press = mousePress;
403
+ mousePress = null;
404
+ if (begin(press.row, press.x, press.y, event)) event.preventDefault();
405
+ });
406
+ for (const name of ['pointerup', 'pointercancel']) {
407
+ list.addEventListener(name, () => { mousePress = null; });
408
+ }
409
+ // The click that ends a drag opens nothing; the next press is the user's again.
410
+ windowRef.addEventListener('pointerdown', () => { swallowClicksUntil = 0; }, true);
411
+ windowRef.addEventListener('click', (event) => {
412
+ // A click from the keyboard (Enter, Space) is never the drag's.
413
+ if (event.detail === 0 || Date.now() >= swallowClicksUntil) return;
414
+ swallowClicksUntil = 0;
415
+ event.preventDefault();
416
+ event.stopPropagation();
417
+ }, true);
418
+ // A lifted or dragged row keeps the finger: the list does not scroll under it.
419
+ list.addEventListener('touchmove', (event) => {
420
+ if ((session || liftedRow) && event.cancelable) event.preventDefault();
421
+ }, { passive: false });
422
+
423
+ // A page sent to the background gives the row back: a drag ends as a
424
+ // cancel, and a finger's hold ends without a menu.
425
+ documentRef.addEventListener('visibilitychange', () => {
426
+ if (!documentRef.hidden) return;
427
+ end(false);
428
+ touchPress = null;
429
+ if (liftedRow) {
430
+ liftedRow.classList.remove('conv-drag-lifted');
431
+ liftedRow = null;
432
+ }
433
+ unguardRow();
434
+ hold(false, { now: true });
435
+ });
436
+
437
+ return {
438
+ canLift,
439
+ lift,
440
+ unlift,
441
+ /** A lifted row moved: start the drag from where the press began. */
442
+ beginTouchDrag(row, startX, startY, event) {
443
+ if (!begin(row, startX, startY, event)) return false;
444
+ return true;
445
+ },
446
+ cancel() { end(false); },
447
+ isActive() { return !!session; },
448
+ };
449
+ }