@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,126 @@
1
+ // The header's ⋯ menu: one decision for its trigger and for every entry, with
2
+ // or without a conversation open. No DOM here; bootstrap.js applies the result.
3
+ //
4
+ // The menu holds two kinds of entries. Relay-wide ones (Settings, About OAR,
5
+ // Restart web relay, Select Models, Check Usage, the queue, Day/Night, the PWA
6
+ // version) are shown whenever the app is signed in, so Settings can be reached
7
+ // on a fresh install, where no conversation exists yet. Per-conversation ones
8
+ // (Edit title, Share, Stop sharing, Preview as shared, Archive, Inspect tmux,
9
+ // Change CWD, Git changes, Kill session, Open on claude.ai) are hidden, not
10
+ // greyed, while nothing is open: an empty screen offers what can be done there
11
+ // and nothing else.
12
+ //
13
+ // The shared read-only view is its own branch: Day/Night only, whatever the
14
+ // conversation state, and the trigger stays usable even when the shared
15
+ // conversation failed to load.
16
+
17
+ export const CHAT_ACTIONS_MENU_TRIGGER_ID = 'chat-actions-menu-btn';
18
+
19
+ /** Entries that are text, not actions: `hidden` is applied, nothing else. */
20
+ export const CHAT_ACTIONS_MENU_META_IDS = Object.freeze([
21
+ 'chat-menu-pwa-version',
22
+ 'chat-menu-queue-status',
23
+ ]);
24
+
25
+ /** Every entry the resolver decides, in menu order. */
26
+ export const CHAT_ACTIONS_MENU_ENTRY_IDS = Object.freeze([
27
+ 'chat-menu-pwa-version',
28
+ 'chat-menu-queue-status',
29
+ 'chat-menu-edit-title',
30
+ 'chat-menu-theme-toggle',
31
+ 'chat-menu-usage',
32
+ 'chat-menu-compact',
33
+ 'chat-menu-share-conversation',
34
+ 'chat-menu-stop-sharing',
35
+ 'chat-menu-preview-shared',
36
+ 'chat-menu-archive',
37
+ 'chat-menu-group',
38
+ 'chat-menu-refresh-history',
39
+ 'chat-menu-inspect-tmux',
40
+ 'chat-menu-select-models',
41
+ 'chat-menu-change-cwd',
42
+ 'chat-menu-git-changes',
43
+ 'chat-menu-open-cloud-session',
44
+ 'chat-menu-settings',
45
+ 'chat-menu-about',
46
+ 'chat-menu-restart-relay',
47
+ 'chat-menu-suspend-host',
48
+ 'chat-menu-kill-session',
49
+ ]);
50
+
51
+ /** Entries that are never shown: retired actions kept in the markup. */
52
+ const ALWAYS_HIDDEN_IDS = new Set(['chat-menu-compact', 'chat-menu-refresh-history']);
53
+
54
+ const shown = (disabled = false) => ({ hidden: false, disabled: !!disabled });
55
+ const hidden = () => ({ hidden: true, disabled: true });
56
+
57
+ /**
58
+ * @param {object} input
59
+ * @param {boolean} [input.sharedMode] the shared read-only view
60
+ * @param {string|null} [input.conversationId] the open conversation, if any
61
+ * @param {object|null} [input.conversation] its record, if known
62
+ * @param {string|null} [input.sdkSessionId] its bound session, if any
63
+ * @param {boolean} [input.isCloud] it runs on Claude Cloud
64
+ * @param {string|null} [input.cloudSessionUrl] its claude.ai session link, if reported
65
+ * @param {boolean} [input.editing] its title is being edited
66
+ * @param {boolean} [input.suspendVisible] the "show Suspend host" setting
67
+ * @param {boolean} [input.hasActiveShare] it has a public share link
68
+ * @returns {{ trigger: { hidden: boolean, disabled: boolean }, entries: Record<string, { hidden: boolean, disabled: boolean }> }}
69
+ */
70
+ export function resolveChatActionsMenuState({
71
+ sharedMode = false,
72
+ conversationId = null,
73
+ conversation = null,
74
+ sdkSessionId = null,
75
+ isCloud = false,
76
+ cloudSessionUrl = null,
77
+ editing = false,
78
+ suspendVisible = false,
79
+ hasActiveShare = false,
80
+ } = {}) {
81
+ const entries = {};
82
+ if (sharedMode) {
83
+ for (const id of CHAT_ACTIONS_MENU_ENTRY_IDS) entries[id] = hidden();
84
+ entries['chat-menu-theme-toggle'] = shown();
85
+ return { trigger: { hidden: false, disabled: false }, entries };
86
+ }
87
+
88
+ const convId = String(conversationId || '').trim();
89
+ const hasConversation = !!convId;
90
+ const sessionId = String(sdkSessionId || conversation?.sdkSessionId || '').trim();
91
+ const cloud = hasConversation && !!isCloud;
92
+ const sessionUrl = String(cloudSessionUrl || '').trim();
93
+ const perConversation = () => (hasConversation ? shown() : hidden());
94
+
95
+ for (const id of CHAT_ACTIONS_MENU_ENTRY_IDS) entries[id] = shown();
96
+ for (const id of ALWAYS_HIDDEN_IDS) entries[id] = hidden();
97
+ entries['chat-menu-edit-title'] = perConversation();
98
+ entries['chat-menu-share-conversation'] = perConversation();
99
+ // Only a conversation with a live link has something to stop.
100
+ entries['chat-menu-stop-sharing'] = hasConversation && hasActiveShare ? shown() : hidden();
101
+ entries['chat-menu-preview-shared'] = perConversation();
102
+ entries['chat-menu-archive'] = perConversation();
103
+ entries['chat-menu-group'] = perConversation();
104
+ entries['chat-menu-inspect-tmux'] = perConversation();
105
+ entries['chat-menu-git-changes'] = perConversation();
106
+ // A cloud session has no working directory on the relay host to change.
107
+ entries['chat-menu-change-cwd'] = hasConversation && !cloud ? shown() : hidden();
108
+ entries['chat-menu-open-cloud-session'] = cloud && sessionUrl ? shown() : hidden();
109
+ entries['chat-menu-suspend-host'] = suspendVisible ? shown() : hidden();
110
+ // Kill needs a bound session; the entry stays visible so the user sees why
111
+ // it is not available (today's rule).
112
+ entries['chat-menu-kill-session'] = hasConversation ? shown(!sessionId) : hidden();
113
+
114
+ return {
115
+ // Only a title edit in progress holds the trigger; with nothing open there
116
+ // is no title, so the trigger can never be stuck disabled.
117
+ trigger: { hidden: false, disabled: hasConversation && !!editing },
118
+ entries,
119
+ };
120
+ }
121
+
122
+ /** The ids of the entries a state shows, in menu order. */
123
+ export function visibleChatActionsMenuEntryIds(state) {
124
+ const entries = state?.entries || {};
125
+ return CHAT_ACTIONS_MENU_ENTRY_IDS.filter((id) => entries[id] && !entries[id].hidden);
126
+ }
@@ -7,6 +7,9 @@
7
7
  // and the server's `cloud` payload field), so the one sync function below is
8
8
  // idempotent and is simply called whenever the header is synced. A
9
9
  // conversation of any other provider gets every element back exactly as it was.
10
+ // The ⋯ menu is not touched here: its entries are decided in one place
11
+ // (chat-actions-menu-state.mjs), which reads the cloud state through
12
+ // isCurrentConversationClaudeCloud() and currentCloudSessionUrl().
10
13
 
11
14
  import { conversations, currentConvId, IS_SHARED_VIEW } from './store.js';
12
15
  import { openExternalNavigation } from './external-link-policy.mjs';
@@ -32,21 +35,10 @@ export function isCurrentConversationClaudeCloud() {
32
35
  return !IS_SHARED_VIEW && isClaudeCloudConversation(currentConversation());
33
36
  }
34
37
 
35
- // Hides an element for cloud chats and gives it back afterwards, without ever
36
- // un-hiding something another owner (shared view, feature gates) had hidden.
37
- function setHiddenForCloud(element, hide) {
38
- if (!element) return;
39
- if (hide) {
40
- if (!element.hidden) {
41
- element.hidden = true;
42
- element.dataset.cloudHidden = '1';
43
- }
44
- return;
45
- }
46
- if (element.dataset.cloudHidden === '1') {
47
- element.hidden = false;
48
- delete element.dataset.cloudHidden;
49
- }
38
+ /** The claude.ai link of the open cloud conversation, once the worker reported one. */
39
+ export function currentCloudSessionUrl() {
40
+ if (!isCurrentConversationClaudeCloud()) return null;
41
+ return buildCloudLineModel(currentConversation()?.cloud)?.sessionUrl || null;
50
42
  }
51
43
 
52
44
  export function syncClaudeCloudConversationUi() {
@@ -65,13 +57,9 @@ export function syncClaudeCloudConversationUi() {
65
57
  line.hidden = !lineHtml;
66
58
  }
67
59
 
68
- // The session has no working directory on the relay host to change.
69
- setHiddenForCloud(document.getElementById('chat-menu-change-cwd'), isCloud);
70
-
71
- const openSessionButton = document.getElementById('chat-menu-open-cloud-session');
72
- if (openSessionButton) {
73
- openSessionButton.hidden = !(isCloud && buildCloudLineModel(conversation?.cloud)?.sessionUrl);
74
- }
60
+ // The ⋯ menu entries (no Change CWD for a cloud chat, Open on claude.ai once
61
+ // there is a link) are decided with the rest of the menu in
62
+ // chat-actions-menu-state.mjs, from `isCloud` and currentCloudSessionUrl().
75
63
 
76
64
  const fileInput = document.getElementById('image-input');
77
65
  if (fileInput) {
@@ -22,6 +22,8 @@ export function hasUploadingAttachments(attachments = []) {
22
22
  // Why a message sent right now queues instead of steering — worker-reported
23
23
  // hold reasons mapped to a human title. Stopping a turn lives on the message
24
24
  // bubbles, never here, so every state below is a Send/Steer/Queue shape.
25
+ export const SETUP_REQUIRED_TEXT = 'Set up a provider to start chatting';
26
+
25
27
  const STEERING_HOLD_TITLES = Object.freeze({
26
28
  question: 'Will steer in after you answer the question',
27
29
  compaction: 'Will steer in once the conversation finishes compacting',
@@ -50,7 +52,21 @@ export function deriveComposerControlState({
50
52
  // An archived conversation: its worker is stopped and it is out of the
51
53
  // live list; sending would start it again behind the user's back.
52
54
  archived = false,
55
+ // No conversation open and no provider known to be ready (a fresh install):
56
+ // a send here would create a Copilot chat that cannot run. The composer is
57
+ // locked, with a placeholder that says what to do, until readiness flips.
58
+ setupRequired = false,
53
59
  } = {}) {
60
+ if (setupRequired) {
61
+ return {
62
+ action: 'send',
63
+ label: 'Send',
64
+ title: SETUP_REQUIRED_TEXT,
65
+ disabled: true,
66
+ composerDisabled: true,
67
+ placeholder: SETUP_REQUIRED_TEXT,
68
+ };
69
+ }
54
70
  if (archived) {
55
71
  return {
56
72
  action: 'send',
@@ -0,0 +1,54 @@
1
+ // When the composer may hold the focus without raising a phone's on-screen
2
+ // keyboard, without the DOM. A focused text field shows no keyboard until the
3
+ // user touches the page; the next tap anywhere that keeps the focus there
4
+ // brings it up. So a phone gets no focus at page load, and a tap that only
5
+ // scrolls (the transcript's ⌄) keeps the focus only while the keyboard is
6
+ // already up.
7
+ //
8
+ // Whether the keyboard is up is read from the visual viewport: with it open
9
+ // the visible area is a keyboard's height shorter than the tallest it has been
10
+ // at the same width. Android shrinks the layout with it
11
+ // (interactive-widget=resizes-content), iOS only the visual viewport; both
12
+ // show here. A rotation changes the width, so each width has its own tallest.
13
+
14
+ /** A visible area this much shorter than its tallest has a keyboard under it. */
15
+ export const KEYBOARD_MIN_SHRINK_PX = 100;
16
+ export const KEYBOARD_MIN_SHRINK_RATIO = 0.15;
17
+
18
+ /** The tallest visible height seen at each layout width. */
19
+ export function createViewportBaseline() {
20
+ const tallestByWidth = new Map();
21
+ return {
22
+ note(width, height) {
23
+ const key = Math.round(Number(width) || 0);
24
+ const value = Math.max(0, Number(height) || 0);
25
+ const tallest = Math.max(tallestByWidth.get(key) || 0, value);
26
+ tallestByWidth.set(key, tallest);
27
+ return tallest;
28
+ },
29
+ };
30
+ }
31
+
32
+ export function isOnScreenKeyboardOpen({ editableFocused = false, visibleHeight = 0, tallestHeight = 0 } = {}) {
33
+ if (!editableFocused) return false;
34
+ const visible = Number(visibleHeight) || 0;
35
+ const tallest = Number(tallestHeight) || 0;
36
+ if (!(visible > 0) || !(tallest > 0)) return false;
37
+ const shrink = tallest - visible;
38
+ return shrink >= Math.max(KEYBOARD_MIN_SHRINK_PX, tallest * KEYBOARD_MIN_SHRINK_RATIO);
39
+ }
40
+
41
+ /**
42
+ * Whether a press on the ⌄ button keeps the focus where it is. A mouse raises
43
+ * no keyboard, so the composer keeps it as it always has; a touch (or a pen,
44
+ * or a pointer that does not say) keeps it only while the keyboard is open.
45
+ */
46
+ export function shouldHoldFocusOnJump({ pointerType = '', keyboardOpen = false } = {}) {
47
+ if (String(pointerType || '') === 'mouse') return true;
48
+ return Boolean(keyboardOpen);
49
+ }
50
+
51
+ /** The composer takes the focus at page load on a desktop only. */
52
+ export function shouldAutofocusComposerOnLoad({ coarsePointer = false, phoneLayout = false } = {}) {
53
+ return !coarsePointer && !phoneLayout;
54
+ }
@@ -103,38 +103,122 @@ export function openContextMenu(x, y, items, { documentRef = document, windowRef
103
103
  return menu;
104
104
  }
105
105
 
106
+ // After a long press opened its menu on release, the click the release makes
107
+ // must not reach the element underneath (it would open the row). Only that
108
+ // click: the next press ends the guard.
109
+ const RELEASE_CLICK_GUARD_MS = 400;
110
+
106
111
  /**
107
112
  * Long-press as the touch equivalent of right-click, on every descendant of
108
113
  * `root` that matches `selector`. Android fires no `contextmenu` for a long
109
114
  * press on a non-selectable element. Cancelled by movement, so it never fires
110
115
  * mid-scroll; mouse pointers are left to the real `contextmenu` event.
111
116
  * `handler(target, x, y)`.
117
+ *
118
+ * With `lift`, the press lifts the element at the delay instead
119
+ * (`lift.onLift(target, x, y)`), and what the finger does next decides:
120
+ * released without moving, the handler runs (the menu opens on release); moved
121
+ * past the slop, `lift.onMove(target, x, y, event)` is offered the gesture (a
122
+ * drag) and the press ends here when it returns true. `lift.onEnd(target)`
123
+ * follows every lift that did not turn into a move. `lift.canLift(target)`
124
+ * false keeps the plain long press for that press (the handler at the delay,
125
+ * a move cancels). A browser `contextmenu` during a touch press is swallowed,
126
+ * so the menu opens once. A page sent to the background ends the press
127
+ * without a menu.
112
128
  */
113
- export function bindLongPress(root, selector, handler, { setTimeoutImpl = setTimeout, clearTimeoutImpl = clearTimeout } = {}) {
129
+ export function bindLongPress(root, selector, handler, {
130
+ setTimeoutImpl = setTimeout,
131
+ clearTimeoutImpl = clearTimeout,
132
+ now = () => Date.now(),
133
+ lift = null,
134
+ } = {}) {
114
135
  let timer = null;
115
136
  let startX = 0;
116
137
  let startY = 0;
138
+ let pressed = null;
139
+ let lifted = false;
140
+ let swallowClicksUntil = 0;
117
141
  const cancel = () => {
118
142
  if (timer !== null) clearTimeoutImpl(timer);
119
143
  timer = null;
144
+ const target = pressed;
145
+ const wasLifted = lifted;
146
+ pressed = null;
147
+ lifted = false;
148
+ if (wasLifted) lift?.onEnd?.(target);
149
+ };
150
+ const release = () => {
151
+ if (!lifted) {
152
+ cancel();
153
+ return;
154
+ }
155
+ const target = pressed;
156
+ cancel();
157
+ swallowClicksUntil = now() + RELEASE_CLICK_GUARD_MS;
158
+ handler(target, startX, startY);
120
159
  };
121
160
  root.addEventListener('pointerdown', (event) => {
161
+ swallowClicksUntil = 0;
122
162
  if (event.pointerType === 'mouse') return;
123
163
  const target = event.target?.closest?.(selector);
124
164
  if (!target || !root.contains(target)) return;
165
+ cancel();
125
166
  startX = event.clientX;
126
167
  startY = event.clientY;
127
- cancel();
168
+ pressed = target;
128
169
  timer = setTimeoutImpl(() => {
129
170
  timer = null;
130
- handler(target, startX, startY);
171
+ if (!lift || lift.canLift?.(pressed) === false) {
172
+ const firing = pressed;
173
+ pressed = null;
174
+ handler(firing, startX, startY);
175
+ return;
176
+ }
177
+ lifted = true;
178
+ lift.onLift?.(pressed, startX, startY);
131
179
  }, LONG_PRESS_MS);
132
180
  }, { passive: true });
133
181
  root.addEventListener('pointermove', (event) => {
134
- if (Math.abs(event.clientX - startX) > LONG_PRESS_SLOP_PX || Math.abs(event.clientY - startY) > LONG_PRESS_SLOP_PX) cancel();
182
+ if (!pressed) return;
183
+ if (Math.abs(event.clientX - startX) <= LONG_PRESS_SLOP_PX && Math.abs(event.clientY - startY) <= LONG_PRESS_SLOP_PX) return;
184
+ if (!lifted) {
185
+ cancel();
186
+ return;
187
+ }
188
+ // A lifted element moved: a drag, when the owner takes it.
189
+ const target = pressed;
190
+ if (lift.onMove?.(target, startX, startY, event) === true) {
191
+ pressed = null;
192
+ lifted = false;
193
+ }
135
194
  }, { passive: true });
136
- for (const name of ['pointerup', 'pointercancel', 'pointerleave']) {
137
- root.addEventListener(name, cancel, { passive: true });
195
+ if (!lift) {
196
+ for (const name of ['pointerup', 'pointercancel', 'pointerleave']) {
197
+ root.addEventListener(name, cancel, { passive: true });
198
+ }
199
+ return cancel;
138
200
  }
201
+ root.addEventListener('pointerup', release, { passive: true });
202
+ // A cancel while lifted (the browser claimed a long press of its own) still
203
+ // opens the menu: the finger was held without moving.
204
+ root.addEventListener('pointercancel', release, { passive: true });
205
+ root.addEventListener('pointerleave', () => {
206
+ if (!lifted) cancel();
207
+ }, { passive: true });
208
+ root.ownerDocument?.addEventListener('visibilitychange', () => {
209
+ if (root.ownerDocument.hidden) cancel();
210
+ });
211
+ root.addEventListener('contextmenu', (event) => {
212
+ if (!pressed) return;
213
+ event.preventDefault();
214
+ event.stopPropagation();
215
+ }, true);
216
+ root.addEventListener('click', (event) => {
217
+ // A click from the keyboard (Enter, Space) is never the release's.
218
+ if (event.detail === 0 || now() >= swallowClicksUntil) return;
219
+ swallowClicksUntil = 0;
220
+ event.preventDefault();
221
+ event.stopPropagation();
222
+ }, true);
139
223
  return cancel;
140
224
  }
@@ -0,0 +1,191 @@
1
+ // Where a conversation row dragged in the sidebar lands, from what the
2
+ // pointer is over. DOM-free: the sidebar measures, this module decides.
3
+ //
4
+ // The list is the arranged one (conversation-group-order.mjs): loose rows and
5
+ // groups ordered by activity, each group a head (a real row, or a synthetic
6
+ // line for a group without its head in view) followed by its members while
7
+ // open. Only the order inside a group is the user's to set, so a drop either
8
+ // places a row inside a group, takes it out of one, or makes a group of two
9
+ // loose rows. Nothing reorders the top level.
10
+ //
11
+ // The pointer is over an item at a relative height `y` (0 top, 1 bottom), or
12
+ // below the last item. The rules, by what is under the pointer:
13
+ //
14
+ // member of group G (upper half: before it; lower half: after it)
15
+ // dragged row in G -> reorder in G (a head of G: none, it stays first)
16
+ // dragged row elsewhere -> join G at that place
17
+ // head of group G (real or synthetic)
18
+ // lower half of an open head -> before G's first member (the head stays first)
19
+ // upper half, or folded -> join G at the end (outline only)
20
+ // a member of G itself -> lower half of the open head: to the top of G; else none
21
+ // loose row
22
+ // dragged row loose -> middle half (25 % to 75 %): a new group of the two; edges: none
23
+ // dragged row in a group -> leave the group (it then sorts by its own activity)
24
+ // below the last item -> a grouped row leaves its group; a loose row: none
25
+ //
26
+ // A drop onto the dragged row itself, or one that would leave a member where
27
+ // it is, is none. A head dragged within its own group is none.
28
+
29
+ const EDGE = 0.25;
30
+
31
+ /** A drop that does nothing. */
32
+ export const NO_DROP = Object.freeze({ action: 'none', groupId: null, beforeConversationId: null, indicator: null });
33
+
34
+ function toText(value) {
35
+ return String(value ?? '').trim();
36
+ }
37
+
38
+ /** The key of a sidebar item: its conversation id, or `group:<id>` for a synthetic head. */
39
+ export function dragItemKey({ conversationId = null, groupId = null } = {}) {
40
+ const id = toText(conversationId);
41
+ return id || `group:${toText(groupId)}`;
42
+ }
43
+
44
+ /**
45
+ * Plain items from arrangeConversationList(): `{ key, kind, conversationId,
46
+ * groupId }`, and on heads `open` and `memberIds` (the group's rows after
47
+ * the head, in list order).
48
+ */
49
+ export function describeDragItems(arranged) {
50
+ const items = [];
51
+ for (const item of Array.isArray(arranged) ? arranged : []) {
52
+ if (!item || !['row', 'head', 'member'].includes(item.kind)) continue;
53
+ const conversationId = toText(item.conversation?.id) || null;
54
+ const groupId = item.kind === 'row' ? null : (toText(item.group?.id) || null);
55
+ if (!conversationId && !groupId) continue;
56
+ const described = { key: dragItemKey({ conversationId, groupId }), kind: item.kind, conversationId, groupId };
57
+ if (item.kind === 'head') {
58
+ described.open = item.group?.open !== false;
59
+ described.memberIds = (Array.isArray(item.group?.members) ? item.group.members : [])
60
+ .map((row) => toText(row?.id))
61
+ .filter(Boolean);
62
+ }
63
+ items.push(described);
64
+ }
65
+ return items;
66
+ }
67
+
68
+ function drop(action, { groupId = null, beforeConversationId = null, indicator = null, targetConversationId = null } = {}) {
69
+ return {
70
+ action,
71
+ groupId,
72
+ beforeConversationId,
73
+ indicator,
74
+ ...(targetConversationId ? { targetConversationId } : {}),
75
+ };
76
+ }
77
+
78
+ /**
79
+ * The drop for `draggedId` with the pointer at `hover`: `{ key, y }` over an
80
+ * item, `{ below: true }` under the last one, or null (nowhere). Returns
81
+ * `{ action: 'none' | 'reorder' | 'join' | 'leave' | 'new-group', groupId,
82
+ * beforeConversationId, indicator: { itemId, edge: 'before' | 'after' |
83
+ * 'inside' } | null }`, with `targetConversationId` on a new group.
84
+ */
85
+ export function resolveDropTarget({ items = [], draggedId = '', hover = null } = {}) {
86
+ const list = Array.isArray(items) ? items : [];
87
+ const id = toText(draggedId);
88
+ const dragged = id ? list.find((item) => item.conversationId === id) : null;
89
+ if (!dragged || !hover) return NO_DROP;
90
+ const draggedGroupId = dragged.kind === 'row' ? null : dragged.groupId;
91
+ const draggedIsHead = dragged.kind === 'head';
92
+ const headOf = (groupId) => list.find((item) => item.kind === 'head' && item.groupId === groupId) || null;
93
+ const membersOf = (groupId) => headOf(groupId)?.memberIds || [];
94
+
95
+ if (hover.below) {
96
+ const last = list[list.length - 1];
97
+ if (!draggedGroupId || !last) return NO_DROP;
98
+ return drop('leave', { indicator: { itemId: last.key, edge: 'after' } });
99
+ }
100
+
101
+ const target = list.find((item) => item.key === toText(hover.key));
102
+ if (!target) return NO_DROP;
103
+ if (target.conversationId && target.conversationId === id) return NO_DROP;
104
+ const y = Math.min(1, Math.max(0, Number(hover.y) || 0));
105
+ const half = y < 0.5 ? 'before' : 'after';
106
+
107
+ if (target.kind === 'member') {
108
+ const groupId = target.groupId;
109
+ if (draggedIsHead && draggedGroupId === groupId) return NO_DROP;
110
+ const members = membersOf(groupId);
111
+ const index = members.indexOf(target.conversationId);
112
+ const before = half === 'before' ? target.conversationId : (members[index + 1] ?? null);
113
+ if (draggedGroupId === groupId) {
114
+ // Before itself, or before the row that already follows it: no move.
115
+ const currentNext = members[members.indexOf(id) + 1] ?? null;
116
+ if (before === id || before === currentNext) return NO_DROP;
117
+ return drop('reorder', { groupId, beforeConversationId: before, indicator: { itemId: target.key, edge: half } });
118
+ }
119
+ return drop('join', { groupId, beforeConversationId: before, indicator: { itemId: target.key, edge: half } });
120
+ }
121
+
122
+ if (target.kind === 'head') {
123
+ const groupId = target.groupId;
124
+ const members = membersOf(groupId);
125
+ const toTop = half === 'after' && target.open === true && members.length > 0;
126
+ if (draggedGroupId === groupId) {
127
+ if (draggedIsHead || !toTop || members[0] === id) return NO_DROP;
128
+ return drop('reorder', { groupId, beforeConversationId: members[0], indicator: { itemId: target.key, edge: 'after' } });
129
+ }
130
+ if (toTop) return drop('join', { groupId, beforeConversationId: members[0], indicator: { itemId: target.key, edge: 'after' } });
131
+ return drop('join', { groupId, beforeConversationId: null, indicator: { itemId: target.key, edge: 'inside' } });
132
+ }
133
+
134
+ // A loose row.
135
+ if (!draggedGroupId) {
136
+ if (y < EDGE || y > 1 - EDGE) return NO_DROP;
137
+ return drop('new-group', {
138
+ indicator: { itemId: target.key, edge: 'inside' },
139
+ targetConversationId: target.conversationId,
140
+ });
141
+ }
142
+ return drop('leave', { indicator: { itemId: target.key, edge: half } });
143
+ }
144
+
145
+ /**
146
+ * "Move up" / "Move down" in a group's hand order: the `beforeConversationId`
147
+ * for the move, or null at the end it cannot pass. `memberIds` are the rows
148
+ * after the head, in list order.
149
+ */
150
+ export function groupMoveStep(memberIds, conversationId, direction) {
151
+ const members = Array.isArray(memberIds) ? memberIds : [];
152
+ const index = members.indexOf(toText(conversationId));
153
+ if (index < 0) return null;
154
+ if (direction === 'up') return index > 0 ? { beforeConversationId: members[index - 1] } : null;
155
+ if (direction === 'down') return index < members.length - 1 ? { beforeConversationId: members[index + 2] ?? null } : null;
156
+ return null;
157
+ }
158
+
159
+ /**
160
+ * Pixels to scroll the list by in one frame while dragging: negative near
161
+ * the top, positive near the bottom, faster toward (and past) the edge, 0
162
+ * outside the zones.
163
+ */
164
+ export function autoScrollStep(pointerY, top, bottom, { zone = 48, maxStep = 18 } = {}) {
165
+ const y = Number(pointerY);
166
+ if (!Number.isFinite(y) || !(bottom > top)) return 0;
167
+ const reach = Math.min(zone, (bottom - top) / 2);
168
+ if (!(reach > 0)) return 0;
169
+ const fromTop = y - top;
170
+ const fromBottom = bottom - y;
171
+ const speed = (distance) => {
172
+ const t = Math.min(1, Math.max(0, (reach - distance) / reach));
173
+ return Math.max(1, Math.ceil(maxStep * t * t));
174
+ };
175
+ if (fromTop < reach && fromTop <= fromBottom) return -speed(fromTop);
176
+ if (fromBottom < reach) return speed(fromBottom);
177
+ return 0;
178
+ }
179
+
180
+ function quoted(text) {
181
+ return `“${toText(text) || 'Untitled'}”`;
182
+ }
183
+
184
+ /** What a screen reader hears after a drop. */
185
+ export function dropAnnouncement(action, { title = '', groupName = '', otherTitle = '' } = {}) {
186
+ if (action === 'reorder') return `Moved ${quoted(title)} within ${quoted(groupName)}`;
187
+ if (action === 'join') return `Moved ${quoted(title)} to ${quoted(groupName)}`;
188
+ if (action === 'leave') return `Removed ${quoted(title)} from its group`;
189
+ if (action === 'new-group') return `New group with ${quoted(otherTitle)} and ${quoted(title)}`;
190
+ return '';
191
+ }