@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.
- package/CHANGELOG.md +440 -1
- package/README.md +156 -972
- package/package.json +13 -13
- package/server/README.md +112 -25
- package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +57 -4
- package/server/claude-worker/claude-session-process.mjs +217 -11
- package/server/claude-worker/claude-session-worker.mjs +26 -1
- package/server/claude-worker/task-output-discovery.mjs +126 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +235 -14
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -0
- package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
- package/server/copilot-worker/copilot-task-settle.mjs +96 -0
- package/server/db-schema.mjs +68 -2
- package/server/grok-worker/acp-client.mjs +2 -1
- package/server/grok-worker/acp-host-services.mjs +46 -6
- package/server/grok-worker/grok-background-tasks.mjs +241 -0
- package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
- package/server/grok-worker/grok-session-worker.mjs +33 -1
- package/server/grok-worker/grok-turn-runner.mjs +11 -0
- package/server/migrations/0013-previews.mjs +130 -0
- package/server/migrations/0014-conversation-groups.mjs +89 -0
- package/server/migrations/0015-conversation-group-position.mjs +76 -0
- package/server/migrations/0016-share-access-log.mjs +110 -0
- package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
- package/server/public/app/about-ui.js +73 -0
- package/server/public/app/about-view.mjs +234 -0
- package/server/public/app/action-confirmations.js +31 -14
- package/server/public/app/agent-exchange-fold-view.mjs +40 -101
- package/server/public/app/api-client.js +264 -16
- package/server/public/app/ask-user-view.js +17 -15
- package/server/public/app/background-tasks-view.mjs +285 -16
- package/server/public/app/bootstrap.js +253 -37
- package/server/public/app/chat-actions-menu-state.mjs +20 -10
- package/server/public/app/claude-background-command-limit-options.mjs +21 -0
- package/server/public/app/context-menu.mjs +90 -6
- package/server/public/app/conversation-group-drag.mjs +191 -0
- package/server/public/app/conversation-group-order.mjs +265 -0
- package/server/public/app/conversation-group-state.mjs +100 -0
- package/server/public/app/conversation-list-drag.mjs +449 -0
- package/server/public/app/conversation-payload-cache.mjs +357 -0
- package/server/public/app/conversation-turn-state.mjs +81 -0
- package/server/public/app/conversation-view.js +567 -94
- package/server/public/app/conversation-window-retention.mjs +96 -0
- package/server/public/app/cwd-picker.js +1 -1
- package/server/public/app/hidden-messages-view.mjs +312 -0
- package/server/public/app/host-suspend-ui.js +7 -1
- package/server/public/app/journal-view.js +1033 -82
- package/server/public/app/preview-cards.mjs +117 -31
- package/server/public/app/preview-command.mjs +10 -1
- package/server/public/app/relay-board-view.js +19 -6
- package/server/public/app/relay-prompt-sanitizer.mjs +11 -4
- package/server/public/app/relay-restart-error.mjs +16 -0
- package/server/public/app/settings-modal.js +150 -10
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/share-format.mjs +49 -0
- package/server/public/app/share-preview-marks.mjs +167 -0
- package/server/public/app/share-state.js +109 -0
- package/server/public/app/shared-conversations-settings.js +480 -0
- package/server/public/app/socket-handlers.js +138 -20
- package/server/public/app/store.js +121 -30
- package/server/public/app/subagent-row.mjs +3 -3
- package/server/public/app/subagent-viewer.mjs +37 -7
- package/server/public/app/task-console-render.mjs +237 -0
- package/server/public/app/task-console-watch.mjs +42 -0
- package/server/public/app/task-console.mjs +630 -0
- package/server/public/app/task-output-text.mjs +234 -0
- package/server/public/app/thinking-follow.js +0 -4
- package/server/public/app/transcript-island.mjs +73 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +230 -26
- package/server/public/app/transcript-jump-bottom.mjs +170 -22
- package/server/public/app/transcript-reading-anchor.mjs +256 -0
- package/server/public/app/transcript-reload-freshness.mjs +17 -1
- package/server/public/app/transcript-viewport-anchor.mjs +159 -0
- package/server/public/index.html +625 -21
- package/server/relay-tools.md +3 -1
- package/server/relay.mjs +2 -1
- package/server/repositories/conversation-group-repository.mjs +444 -0
- package/server/repositories/message-repository.mjs +9 -0
- package/server/repositories/question-repository.mjs +4 -0
- package/server/repositories/session-repository.mjs +12 -37
- package/server/repositories/share-repository.mjs +322 -0
- package/server/repositories/share-visibility-statements.mjs +10 -0
- package/server/routes/about-routes.mjs +15 -0
- package/server/routes/cache-routes.mjs +4 -0
- package/server/routes/conversation-group-routes.mjs +171 -0
- package/server/routes/messages-routes.mjs +433 -68
- package/server/routes/preview-routes.mjs +9 -1
- package/server/routes/sessions-routes.mjs +901 -253
- package/server/server-runtime.mjs +392 -99
- package/server/services/about-service.mjs +461 -0
- package/server/services/background-task-store.mjs +365 -0
- package/server/services/background-work-view.mjs +228 -0
- package/server/services/cache-rebuild-service.mjs +30 -0
- package/server/services/cli-process-runner.mjs +4 -21
- package/server/services/conversation-history-page.mjs +199 -0
- package/server/services/delete-archive-service.mjs +4 -1
- package/server/services/detached-worker-watch-service.mjs +150 -0
- package/server/services/hidden-message-list.mjs +21 -0
- package/server/services/host-suspend-command.mjs +2 -1
- package/server/services/message-pin-list.mjs +1 -1
- package/server/services/preview-health-probe.mjs +8 -1
- package/server/services/preview-proxy-server.mjs +8 -4
- package/server/services/preview-registry-service.mjs +676 -26
- package/server/services/preview-static-handler.mjs +40 -7
- package/server/services/preview-store.mjs +274 -0
- package/server/services/rate-limit-service.mjs +12 -1
- package/server/services/relay-auth.mjs +183 -0
- package/server/services/relay-cli-launcher-service.mjs +3 -1
- package/server/services/relay-event-service.mjs +17 -0
- package/server/services/relay-prompt-sanitizer.mjs +11 -4
- package/server/services/remote-relay-dispatcher.mjs +187 -7
- package/server/services/request-boundary.mjs +183 -0
- package/server/services/session-activity-tracker.mjs +231 -19
- package/server/services/session-worker-launch-service.mjs +190 -6
- package/server/services/session-worker-process-service.mjs +10 -6
- package/server/services/share-access-log-service.mjs +123 -0
- package/server/services/share-path-redaction.mjs +424 -0
- package/server/services/status-event-service.mjs +7 -3
- package/server/services/task-output-rooms.mjs +259 -0
- package/server/services/task-output-service.mjs +562 -0
- package/server/services/tunnel-worker-path-guard.mjs +24 -41
- package/server/services/windows-autostart-service.mjs +6 -1
- package/server/services/windows-boot-autostart-service.mjs +207 -23
- package/shared/claude-background-command-limit.mjs +83 -0
- package/shared/preview-tool-core.mjs +15 -5
- package/shared/remote-relay-contract.mjs +35 -3
- package/shared/share-path-privacy.mjs +30 -0
- package/shared/task-output-text.mjs +737 -0
- package/shared/windows-system-tools.mjs +106 -0
- package/shared/worker-runtime/background-task-digest.mjs +30 -0
- package/shared/worker-runtime/heartbeat.mjs +24 -0
- package/shared/worker-runtime/kept-posts.mjs +83 -0
- package/shared/worker-runtime/task-output-spool.mjs +181 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
// The header's ⋯ menu: one decision for its trigger and for every entry, with
|
|
2
2
|
// or without a conversation open. No DOM here; bootstrap.js applies the result.
|
|
3
3
|
//
|
|
4
|
-
// The menu holds two kinds of entries. Relay-wide ones (Settings,
|
|
5
|
-
// relay, Select Models, Check Usage, the queue, Day/Night, the PWA
|
|
6
|
-
// are shown whenever the app is signed in, so Settings can be reached
|
|
7
|
-
// fresh install, where no conversation exists yet. Per-conversation ones
|
|
8
|
-
// title, Share,
|
|
9
|
-
// Open on claude.ai) are hidden, not
|
|
10
|
-
// screen offers what can be done there
|
|
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.
|
|
11
12
|
//
|
|
12
13
|
// The shared read-only view is its own branch: Day/Night only, whatever the
|
|
13
14
|
// conversation state, and the trigger stays usable even when the shared
|
|
@@ -26,11 +27,14 @@ export const CHAT_ACTIONS_MENU_ENTRY_IDS = Object.freeze([
|
|
|
26
27
|
'chat-menu-pwa-version',
|
|
27
28
|
'chat-menu-queue-status',
|
|
28
29
|
'chat-menu-edit-title',
|
|
29
|
-
'chat-menu-
|
|
30
|
+
'chat-menu-theme-toggle',
|
|
30
31
|
'chat-menu-usage',
|
|
31
32
|
'chat-menu-compact',
|
|
32
33
|
'chat-menu-share-conversation',
|
|
34
|
+
'chat-menu-stop-sharing',
|
|
35
|
+
'chat-menu-preview-shared',
|
|
33
36
|
'chat-menu-archive',
|
|
37
|
+
'chat-menu-group',
|
|
34
38
|
'chat-menu-refresh-history',
|
|
35
39
|
'chat-menu-inspect-tmux',
|
|
36
40
|
'chat-menu-select-models',
|
|
@@ -38,6 +42,7 @@ export const CHAT_ACTIONS_MENU_ENTRY_IDS = Object.freeze([
|
|
|
38
42
|
'chat-menu-git-changes',
|
|
39
43
|
'chat-menu-open-cloud-session',
|
|
40
44
|
'chat-menu-settings',
|
|
45
|
+
'chat-menu-about',
|
|
41
46
|
'chat-menu-restart-relay',
|
|
42
47
|
'chat-menu-suspend-host',
|
|
43
48
|
'chat-menu-kill-session',
|
|
@@ -59,6 +64,7 @@ const hidden = () => ({ hidden: true, disabled: true });
|
|
|
59
64
|
* @param {string|null} [input.cloudSessionUrl] its claude.ai session link, if reported
|
|
60
65
|
* @param {boolean} [input.editing] its title is being edited
|
|
61
66
|
* @param {boolean} [input.suspendVisible] the "show Suspend host" setting
|
|
67
|
+
* @param {boolean} [input.hasActiveShare] it has a public share link
|
|
62
68
|
* @returns {{ trigger: { hidden: boolean, disabled: boolean }, entries: Record<string, { hidden: boolean, disabled: boolean }> }}
|
|
63
69
|
*/
|
|
64
70
|
export function resolveChatActionsMenuState({
|
|
@@ -70,11 +76,12 @@ export function resolveChatActionsMenuState({
|
|
|
70
76
|
cloudSessionUrl = null,
|
|
71
77
|
editing = false,
|
|
72
78
|
suspendVisible = false,
|
|
79
|
+
hasActiveShare = false,
|
|
73
80
|
} = {}) {
|
|
74
81
|
const entries = {};
|
|
75
82
|
if (sharedMode) {
|
|
76
83
|
for (const id of CHAT_ACTIONS_MENU_ENTRY_IDS) entries[id] = hidden();
|
|
77
|
-
entries['chat-menu-
|
|
84
|
+
entries['chat-menu-theme-toggle'] = shown();
|
|
78
85
|
return { trigger: { hidden: false, disabled: false }, entries };
|
|
79
86
|
}
|
|
80
87
|
|
|
@@ -87,10 +94,13 @@ export function resolveChatActionsMenuState({
|
|
|
87
94
|
|
|
88
95
|
for (const id of CHAT_ACTIONS_MENU_ENTRY_IDS) entries[id] = shown();
|
|
89
96
|
for (const id of ALWAYS_HIDDEN_IDS) entries[id] = hidden();
|
|
90
|
-
entries['chat-menu-shared-theme-toggle'] = hidden();
|
|
91
97
|
entries['chat-menu-edit-title'] = perConversation();
|
|
92
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();
|
|
93
102
|
entries['chat-menu-archive'] = perConversation();
|
|
103
|
+
entries['chat-menu-group'] = perConversation();
|
|
94
104
|
entries['chat-menu-inspect-tmux'] = perConversation();
|
|
95
105
|
entries['chat-menu-git-changes'] = perConversation();
|
|
96
106
|
// A cloud session has no working directory on the relay host to change.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Mirrors shared/claude-background-command-limit.mjs, which the browser cannot
|
|
2
|
+
// import (only server/public is served).
|
|
3
|
+
// shared/claude-background-command-limit.test.mjs asserts the two stay
|
|
4
|
+
// identical.
|
|
5
|
+
//
|
|
6
|
+
// The Claude settings slider shows the default until the relay's own value
|
|
7
|
+
// loads; the authoritative bounds come from GET /api/settings/claude.
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_CLAUDE_BACKGROUND_COMMAND_LIMIT_MINUTES = 120;
|
|
10
|
+
export const CLAUDE_BACKGROUND_COMMAND_LIMIT_MIN_MINUTES = 30;
|
|
11
|
+
export const CLAUDE_BACKGROUND_COMMAND_LIMIT_MAX_MINUTES = 480;
|
|
12
|
+
export const CLAUDE_BACKGROUND_COMMAND_LIMIT_STEP_MINUTES = 30;
|
|
13
|
+
|
|
14
|
+
export function formatClaudeBackgroundCommandLimitLabel(minutes) {
|
|
15
|
+
const numeric = Math.round(Number(minutes));
|
|
16
|
+
const value = Number.isFinite(numeric) && numeric > 0 ? numeric : DEFAULT_CLAUDE_BACKGROUND_COMMAND_LIMIT_MINUTES;
|
|
17
|
+
if (value < 60) return `${value} min`;
|
|
18
|
+
const hours = Math.floor(value / 60);
|
|
19
|
+
const remainder = value % 60;
|
|
20
|
+
return remainder ? `${hours} h ${remainder} min` : `${hours} h`;
|
|
21
|
+
}
|
|
@@ -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, {
|
|
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
|
-
|
|
168
|
+
pressed = target;
|
|
128
169
|
timer = setTimeoutImpl(() => {
|
|
129
170
|
timer = null;
|
|
130
|
-
|
|
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 (
|
|
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
|
-
|
|
137
|
-
|
|
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
|
+
}
|