@oar-sh/oar 0.9.9 → 0.9.11

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 (154) hide show
  1. package/CHANGELOG.md +561 -1
  2. package/README.md +162 -909
  3. package/bin/bat/oar.bat +46 -46
  4. package/package.json +14 -14
  5. package/server/README.md +369 -13
  6. package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
  7. package/server/claude-worker/claude-session-process.mjs +121 -9
  8. package/server/claude-worker/claude-session-worker.mjs +9 -1
  9. package/server/claude-worker/claude-turn-publisher.mjs +3 -0
  10. package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
  11. package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
  12. package/server/copilot-worker/copilot-sdk-session-process.mjs +79 -12
  13. package/server/copilot-worker/copilot-sdk-session-worker.mjs +6 -0
  14. package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
  15. package/server/db-schema.mjs +95 -2
  16. package/server/grok-worker/acp-client.mjs +2 -1
  17. package/server/grok-worker/acp-host-services.mjs +4 -3
  18. package/server/migrations/0009-subagent-timeline.mjs +135 -0
  19. package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
  20. package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
  21. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
  22. package/server/migrations/0013-previews.mjs +130 -0
  23. package/server/migrations/0014-conversation-groups.mjs +89 -0
  24. package/server/migrations/0015-conversation-group-position.mjs +76 -0
  25. package/server/migrations/0016-share-access-log.mjs +110 -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 +187 -0
  30. package/server/public/app/api-client.js +312 -16
  31. package/server/public/app/archive-confirm.mjs +28 -0
  32. package/server/public/app/ask-user-view.js +17 -12
  33. package/server/public/app/attachments-view.js +516 -6
  34. package/server/public/app/background-tasks-view.mjs +18 -26
  35. package/server/public/app/bootstrap.js +336 -59
  36. package/server/public/app/chat-actions-menu-state.mjs +126 -0
  37. package/server/public/app/claude-cloud-conversation-ui.js +10 -22
  38. package/server/public/app/composer-control-state.mjs +16 -0
  39. package/server/public/app/composer-keyboard.mjs +54 -0
  40. package/server/public/app/context-menu.mjs +90 -6
  41. package/server/public/app/conversation-group-drag.mjs +191 -0
  42. package/server/public/app/conversation-group-order.mjs +265 -0
  43. package/server/public/app/conversation-group-state.mjs +100 -0
  44. package/server/public/app/conversation-list-drag.mjs +449 -0
  45. package/server/public/app/conversation-turn-state.mjs +81 -0
  46. package/server/public/app/conversation-view.js +436 -117
  47. package/server/public/app/cwd-picker.js +1 -1
  48. package/server/public/app/explorer-edit-state.mjs +68 -0
  49. package/server/public/app/hidden-messages-view.mjs +312 -0
  50. package/server/public/app/host-suspend-ui.js +13 -4
  51. package/server/public/app/journal-view.js +930 -45
  52. package/server/public/app/no-conversation-state.mjs +58 -0
  53. package/server/public/app/preview-cards.mjs +117 -31
  54. package/server/public/app/preview-command.mjs +10 -1
  55. package/server/public/app/provider-readiness.mjs +120 -0
  56. package/server/public/app/relay-board-view.js +19 -3
  57. package/server/public/app/relay-event-subscriptions-ui.js +184 -0
  58. package/server/public/app/relay-event-view.mjs +234 -0
  59. package/server/public/app/relay-restart-error.mjs +16 -0
  60. package/server/public/app/settings-modal.js +93 -9
  61. package/server/public/app/settings-tabs.js +1 -1
  62. package/server/public/app/share-format.mjs +49 -0
  63. package/server/public/app/share-preview-marks.mjs +167 -0
  64. package/server/public/app/share-state.js +109 -0
  65. package/server/public/app/shared-conversations-settings.js +480 -0
  66. package/server/public/app/socket-handlers.js +137 -40
  67. package/server/public/app/store.js +115 -32
  68. package/server/public/app/subagent-row.mjs +138 -0
  69. package/server/public/app/subagent-timeline-view.mjs +186 -0
  70. package/server/public/app/subagent-viewer-watch.mjs +50 -0
  71. package/server/public/app/subagent-viewer.mjs +558 -0
  72. package/server/public/app/task-row-format.mjs +29 -0
  73. package/server/public/app/transcript-jump-bottom-view.mjs +464 -0
  74. package/server/public/app/transcript-jump-bottom.mjs +210 -0
  75. package/server/public/app/transcript-reload-freshness.mjs +25 -0
  76. package/server/public/app/transcript-viewport-anchor.mjs +140 -0
  77. package/server/public/app/update-ui.js +1 -1
  78. package/server/public/app/viewer-history.mjs +5 -3
  79. package/server/public/index.html +774 -12
  80. package/server/relay-tools.md +2 -1
  81. package/server/relay.mjs +2 -1
  82. package/server/repositories/conversation-group-repository.mjs +444 -0
  83. package/server/repositories/message-repository.mjs +30 -3
  84. package/server/repositories/question-repository.mjs +9 -3
  85. package/server/repositories/remote-relay-repository.mjs +6 -2
  86. package/server/repositories/session-repository.mjs +12 -37
  87. package/server/repositories/share-repository.mjs +322 -0
  88. package/server/repositories/share-visibility-statements.mjs +10 -0
  89. package/server/repositories/subagent-timeline-statements.mjs +170 -0
  90. package/server/routes/about-routes.mjs +15 -0
  91. package/server/routes/ask-user-routes.mjs +3 -0
  92. package/server/routes/cache-routes.mjs +4 -0
  93. package/server/routes/conversation-group-routes.mjs +171 -0
  94. package/server/routes/messages-routes.mjs +698 -114
  95. package/server/routes/preview-routes.mjs +9 -1
  96. package/server/routes/relay-event-routes.mjs +157 -0
  97. package/server/routes/remote-relay-routes.mjs +15 -0
  98. package/server/routes/sessions-routes.mjs +843 -193
  99. package/server/server-runtime.mjs +559 -160
  100. package/server/services/about-service.mjs +461 -0
  101. package/server/services/background-task-store.mjs +128 -0
  102. package/server/services/background-work-view.mjs +228 -0
  103. package/server/services/cache-rebuild-service.mjs +30 -0
  104. package/server/services/delete-archive-service.mjs +4 -1
  105. package/server/services/detached-worker-watch-service.mjs +150 -0
  106. package/server/services/explorer-entry-name.mjs +26 -0
  107. package/server/services/explorer-mutations.mjs +122 -0
  108. package/server/services/hidden-message-list.mjs +21 -0
  109. package/server/services/host-suspend-command.mjs +2 -1
  110. package/server/services/message-pin-list.mjs +1 -1
  111. package/server/services/preview-health-probe.mjs +8 -1
  112. package/server/services/preview-proxy-server.mjs +49 -27
  113. package/server/services/preview-registry-service.mjs +676 -26
  114. package/server/services/preview-static-handler.mjs +40 -7
  115. package/server/services/preview-store.mjs +274 -0
  116. package/server/services/push-dispatch-service.mjs +18 -0
  117. package/server/services/rate-limit-service.mjs +12 -1
  118. package/server/services/relay-auth.mjs +183 -0
  119. package/server/services/relay-cli-launcher-service.mjs +3 -1
  120. package/server/services/relay-event-link-service.mjs +289 -0
  121. package/server/services/relay-event-service.mjs +1952 -0
  122. package/server/services/relay-shutdown-gate.mjs +31 -0
  123. package/server/services/remote-relay-caller-context.mjs +52 -7
  124. package/server/services/remote-relay-dispatcher.mjs +551 -7
  125. package/server/services/remote-relay-registry-service.mjs +11 -1
  126. package/server/services/request-boundary.mjs +183 -0
  127. package/server/services/session-activity-tracker.mjs +695 -0
  128. package/server/services/session-history-refresh-service.mjs +7 -0
  129. package/server/services/session-worker-launch-service.mjs +190 -6
  130. package/server/services/session-worker-process-service.mjs +10 -6
  131. package/server/services/share-access-log-service.mjs +123 -0
  132. package/server/services/share-path-redaction.mjs +424 -0
  133. package/server/services/stale-attempt-log-limiter.mjs +96 -0
  134. package/server/services/stale-recovery-clock-guard.mjs +57 -0
  135. package/server/services/stale-subagent-run-sweep.mjs +109 -0
  136. package/server/services/status-event-service.mjs +7 -3
  137. package/server/services/subagent-record-retention.mjs +164 -0
  138. package/server/services/subagent-timeline-service.mjs +328 -0
  139. package/server/services/subagent-viewer-rooms.mjs +119 -0
  140. package/server/services/tunnel-worker-path-guard.mjs +24 -41
  141. package/server/services/upgrade-socket-guard.mjs +16 -0
  142. package/server/services/windows-autostart-service.mjs +6 -1
  143. package/server/services/windows-boot-autostart-service.mjs +207 -23
  144. package/shared/preview-tool-core.mjs +15 -5
  145. package/shared/relay-events.mjs +371 -0
  146. package/shared/remote-relay-contract.mjs +160 -6
  147. package/shared/share-path-privacy.mjs +30 -0
  148. package/shared/subagent-record-retention.mjs +31 -0
  149. package/shared/subagent-timeline.mjs +79 -0
  150. package/shared/windows-system-tools.mjs +106 -0
  151. package/shared/worker-runtime/background-task-digest.mjs +30 -0
  152. package/shared/worker-runtime/heartbeat.mjs +24 -0
  153. package/shared/worker-runtime/kept-posts.mjs +83 -0
  154. 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
+ }
@@ -0,0 +1,81 @@
1
+ // A conversation record's turn fields (`activeTurn`, `sessionState`,
2
+ // `backgroundTaskCount`) kept in step with the live events, for every row
3
+ // and not only the open one. The list fetch sets them; between fetches the
4
+ // `message_status` and `background_tasks` events are the only word on what
5
+ // a session is doing, and the sidebar's dots, a group head's pill and its
6
+ // fold read these fields. DOM-free.
7
+
8
+ const TERMINAL_STATUSES = new Set(['done', 'failed', 'dropped', 'cancelled']);
9
+ const QUEUED_STATUSES = new Set(['pending', 'parked']);
10
+
11
+ function runningTaskCount(tasks) {
12
+ return (Array.isArray(tasks) ? tasks : []).filter((task) => {
13
+ const status = String(task?.status || 'running').trim().toLowerCase();
14
+ return !status || status === 'running';
15
+ }).length;
16
+ }
17
+
18
+ function settleState(conversation) {
19
+ conversation.sessionState = Number(conversation.backgroundTaskCount || 0) > 0 ? 'background_only' : 'idle';
20
+ }
21
+
22
+ /**
23
+ * A `message_status` for the conversation. Processing: a turn runs. Pending
24
+ * or parked: a turn is queued (the relay counts it as active). A terminal
25
+ * status: the turn is over; background work, if any was reported, carries
26
+ * the state. Another message still queued is learnt from its own status or
27
+ * the next list fetch. Returns true when a field changed.
28
+ */
29
+ export function applyTurnStatusToConversation(conversation, status) {
30
+ if (!conversation || typeof conversation !== 'object') return false;
31
+ const normalized = String(status || '').trim().toLowerCase();
32
+ const before = `${conversation.activeTurn}|${conversation.sessionState}`;
33
+ if (normalized === 'processing') {
34
+ conversation.activeTurn = true;
35
+ conversation.sessionState = 'running_turn';
36
+ } else if (QUEUED_STATUSES.has(normalized)) {
37
+ conversation.activeTurn = true;
38
+ if (conversation.sessionState !== 'running_turn') settleState(conversation);
39
+ } else if (TERMINAL_STATUSES.has(normalized)) {
40
+ conversation.activeTurn = false;
41
+ settleState(conversation);
42
+ } else {
43
+ return false;
44
+ }
45
+ return before !== `${conversation.activeTurn}|${conversation.sessionState}`;
46
+ }
47
+
48
+ /**
49
+ * What a fetched payload says (a list row, or the single conversation): the
50
+ * relay's own view of the session is the truth whenever one arrives; the
51
+ * events only move it forward until the next. `sessionState` names the
52
+ * state, `sessionTasks` (list) or `backgroundTasks` (single) the live set;
53
+ * a `background_only` state without a set still counts as one task.
54
+ * Returns true when a field changed.
55
+ */
56
+ export function applyFetchedTurnStateToConversation(conversation, payload) {
57
+ if (!conversation || typeof conversation !== 'object' || !payload || typeof payload !== 'object') return false;
58
+ const before = `${conversation.activeTurn}|${conversation.sessionState}|${conversation.backgroundTaskCount}`;
59
+ if (typeof payload.activeTurn === 'boolean') conversation.activeTurn = payload.activeTurn;
60
+ const state = String(payload.sessionState || '').trim().toLowerCase();
61
+ if (state) {
62
+ conversation.sessionState = state;
63
+ const tasks = Array.isArray(payload.sessionTasks) ? payload.sessionTasks
64
+ : (Array.isArray(payload.backgroundTasks) ? payload.backgroundTasks : null);
65
+ conversation.backgroundTaskCount = tasks ? runningTaskCount(tasks) : (state === 'background_only' ? 1 : 0);
66
+ } else if (Array.isArray(payload.backgroundTasks)) {
67
+ conversation.backgroundTaskCount = runningTaskCount(payload.backgroundTasks);
68
+ if (conversation.sessionState !== 'running_turn' && conversation.activeTurn !== true) settleState(conversation);
69
+ }
70
+ return before !== `${conversation.activeTurn}|${conversation.sessionState}|${conversation.backgroundTaskCount}`;
71
+ }
72
+
73
+ /** A `background_tasks` set for the conversation (replace semantics). Returns true when a field changed. */
74
+ export function applyBackgroundTasksToConversation(conversation, tasks) {
75
+ if (!conversation || typeof conversation !== 'object') return false;
76
+ const before = `${conversation.backgroundTaskCount}|${conversation.sessionState}`;
77
+ conversation.backgroundTaskCount = runningTaskCount(tasks);
78
+ if (conversation.sessionState !== 'running_turn' && conversation.activeTurn !== true) settleState(conversation);
79
+ else if (conversation.sessionState !== 'running_turn') settleState(conversation);
80
+ return before !== `${conversation.backgroundTaskCount}|${conversation.sessionState}`;
81
+ }