@oar-sh/oar 0.9.19 → 0.9.21
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/.github/extensions/web-relay/polling/polling-loop.mjs +2 -26
- package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +2 -26
- package/CHANGELOG.md +529 -0
- package/README.md +14 -14
- package/bin/oar.js +392 -98
- package/package.json +4 -2
- package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +60 -0
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +258 -1
- package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +22 -1
- package/server/claude-cloud-worker/claude-cloud-worker-steering.mjs +34 -0
- package/server/claude-worker/claude-remote-relay-tool.mjs +1 -0
- package/server/claude-worker/claude-session-process.mjs +10 -0
- package/server/claude-worker/claude-session-worker-main.mjs +8 -1
- package/server/claude-worker/claude-turn-publisher.mjs +163 -8
- package/server/claude-worker/claude-usage-totals.mjs +58 -0
- package/server/copilot-worker/copilot-plan-board.mjs +4 -7
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +86 -3
- package/server/copilot-worker/copilot-sdk-session-process.mjs +131 -5
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -1
- package/server/cursor-worker/cursor-session-worker.mjs +12 -1
- package/server/cursor-worker/cursor-turn-runner.mjs +42 -6
- package/server/cursor-worker/cursor-worker-steering.mjs +20 -0
- package/server/db-schema.mjs +18 -0
- package/server/deepseek-worker/deepseek-claude-backend.mjs +14 -0
- package/server/grok-worker/grok-background-tasks.mjs +9 -0
- package/server/grok-worker/grok-session-worker.mjs +12 -1
- package/server/grok-worker/grok-turn-runner.mjs +47 -6
- package/server/grok-worker/grok-worker-steering.mjs +23 -0
- package/server/migrations/0001-add-structured-answer.mjs +5 -7
- package/server/migrations/0002-recent-workspace-roots-path-key.mjs +5 -7
- package/server/migrations/0003-push-subscriptions.mjs +5 -7
- package/server/migrations/0004-steer-settle-markers.mjs +5 -7
- package/server/migrations/0005-remote-relays.mjs +5 -7
- package/server/migrations/0006-workspace-root-settings.mjs +5 -7
- package/server/migrations/0007-claude-cloud.mjs +5 -7
- package/server/migrations/0008-message-pins.mjs +5 -7
- package/server/migrations/0009-subagent-timeline.mjs +5 -7
- package/server/migrations/0010-subagent-timeline-pruning.mjs +5 -7
- package/server/migrations/0011-relay-event-subscriptions.mjs +5 -7
- package/server/migrations/0012-cross-relay-event-subscriptions.mjs +5 -7
- package/server/migrations/0013-previews.mjs +5 -7
- package/server/migrations/0014-conversation-groups.mjs +5 -7
- package/server/migrations/0015-conversation-group-position.mjs +5 -7
- package/server/migrations/0016-share-access-log.mjs +5 -7
- package/server/migrations/0017-conversation-history-indexes.mjs +5 -7
- package/server/migrations/0018-background-task-rows.mjs +5 -7
- package/server/migrations/0019-relay-event-subscription-options.mjs +5 -7
- package/server/migrations/0020-conversation-may-create-sessions.mjs +5 -7
- package/server/migrations/0021-conversation-group-agent-marks.mjs +5 -7
- package/server/migrations/0022-relay-schedules.mjs +5 -7
- package/server/migrations/0023-relay-asks.mjs +5 -7
- package/server/migrations/0024-deepseek-usage-totals.mjs +5 -7
- package/server/migrations/0025-deepseek-usage-requests-daily.mjs +5 -7
- package/server/migrations/0026-deepseek-usage-served-models.mjs +5 -7
- package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +5 -7
- package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +5 -7
- package/server/migrations/0029-deepseek-balance-rises.mjs +5 -7
- package/server/migrations/0030-deepseek-usage-actuals.mjs +5 -7
- package/server/migrations/0031-queue-provider.mjs +5 -7
- package/server/migrations/0032-runtime-session-billing-switch.mjs +5 -7
- package/server/migrations/0033-claude-api-usage-checkpoints.mjs +5 -7
- package/server/migrations/0034-claude-api-usage-totals.mjs +5 -7
- package/server/migrations/0035-conversation-plan-only.mjs +87 -0
- package/server/migrations/0036-token-usage.mjs +267 -0
- package/server/public/app/about-ui.js +1 -1
- package/server/public/app/about-view.mjs +106 -3
- package/server/public/app/action-confirmations.js +1 -1
- package/server/public/app/api-client.js +6 -3
- package/server/public/app/ask-user-view.js +20 -6
- package/server/public/app/attachments-view.js +1 -1
- package/server/public/app/back-guard.mjs +31 -5
- package/server/public/app/bootstrap.js +611 -70
- package/server/public/app/chat-actions-menu-state.mjs +139 -4
- package/server/public/app/chat-header-meta.mjs +27 -0
- package/server/public/app/claude-billing-toggle-ui.js +2 -1
- package/server/public/app/claude-cloud-conversation-ui.js +2 -2
- package/server/public/app/composer-control-state.mjs +12 -2
- package/server/public/app/composer-settings-label.mjs +90 -0
- package/server/public/app/composer-settings-ui.js +442 -0
- package/server/public/app/context-menu.mjs +36 -5
- package/server/public/app/context-usage-view.mjs +16 -0
- package/server/public/app/conversation-deep-link.mjs +15 -11
- package/server/public/app/conversation-group-drag.mjs +12 -2
- package/server/public/app/conversation-group-order.mjs +9 -2
- package/server/public/app/conversation-group-state.mjs +47 -10
- package/server/public/app/conversation-origin-ui.js +17 -1
- package/server/public/app/conversation-view.js +20 -2
- package/server/public/app/cwd-menu-placement.mjs +18 -6
- package/server/public/app/cwd-picker.js +36 -6
- package/server/public/app/emoji-view.js +1 -1
- package/server/public/app/font-scaling.js +26 -0
- package/server/public/app/header-priority-actions.mjs +234 -0
- package/server/public/app/hidden-messages-view.mjs +4 -0
- package/server/public/app/inbox-ui.js +50 -2
- package/server/public/app/inbox-view.mjs +141 -5
- package/server/public/app/journal-view.js +311 -58
- package/server/public/app/phone-landscape-ui.js +225 -0
- package/server/public/app/phone-landscape.mjs +99 -0
- package/server/public/app/phone-typing-state.mjs +104 -0
- package/server/public/app/pinned-messages-view.mjs +5 -0
- package/server/public/app/plan-board-shared.mjs +49 -0
- package/server/public/app/plan-usage-view.mjs +1 -1
- package/server/public/app/push-settings.js +2 -2
- package/server/public/app/pwa-install.js +24 -3
- package/server/public/app/relay-board-inline.mjs +4 -1
- package/server/public/app/relay-board-view.js +38 -10
- package/server/public/app/relay-event-subscriptions-ui.js +71 -22
- package/server/public/app/relay-event-view.mjs +42 -0
- package/server/public/app/relay-restart-ui.js +2 -2
- package/server/public/app/relay-schedule-view.mjs +32 -6
- package/server/public/app/relay-schedules-ui.js +69 -20
- package/server/public/app/remote-relay-origin-view.mjs +41 -23
- package/server/public/app/session-history-ui.js +250 -0
- package/server/public/app/session-history.mjs +599 -0
- package/server/public/app/settings-modal.js +61 -6
- package/server/public/app/settings-tabs.js +12 -0
- package/server/public/app/share-state.js +1 -1
- package/server/public/app/shared-conversations-settings.js +2 -2
- package/server/public/app/socket-handlers.js +15 -4
- package/server/public/app/status-view.mjs +18 -2
- package/server/public/app/store.js +87 -16
- package/server/public/app/subagent-viewer.mjs +27 -2
- package/server/public/app/tab-strip-fade.mjs +101 -0
- package/server/public/app/tap-actions.js +66 -0
- package/server/public/app/usage-limit-ui.js +63 -2
- package/server/public/app/viewer-history.mjs +13 -2
- package/server/public/index.html +2258 -1585
- package/server/public/sw.js +8 -0
- package/server/relay-heap-guard.mjs +14 -1
- package/server/relay-self-restart.mjs +83 -2
- package/server/relay-tools.md +5 -3
- package/server/repositories/message-repository.mjs +54 -1
- package/server/repositories/remote-relay-repository.mjs +10 -0
- package/server/repositories/session-repository.mjs +25 -1
- package/server/routes/claude-cloud-routes.mjs +19 -0
- package/server/routes/messages-routes.mjs +144 -1
- package/server/routes/relay-ask-routes.mjs +138 -8
- package/server/routes/relay-board-routes.mjs +55 -40
- package/server/routes/sessions-routes.mjs +144 -14
- package/server/server-runtime.mjs +322 -51
- package/server/services/about-service.mjs +81 -53
- package/server/services/cache-rebuild-service.mjs +7 -0
- package/server/services/claude-billing-swap-service.mjs +7 -20
- package/server/services/claude-cloud-session-service.mjs +47 -5
- package/server/services/conversation-preferences-service.mjs +31 -0
- package/server/services/database-file.mjs +413 -0
- package/server/services/db-backup-service.mjs +809 -0
- package/server/services/deepseek-usage-import.mjs +6 -9
- package/server/services/delete-archive-service.mjs +3 -0
- package/server/services/earlier-database-service.mjs +69 -126
- package/server/services/oar-cli-helpers.mjs +214 -12
- package/server/services/oar-cli-menu.mjs +1 -0
- package/server/services/oar-state-migration-service.mjs +33 -13
- package/server/services/package-data-rescue.mjs +4 -2
- package/server/services/plan-usage-claude-api.mjs +14 -15
- package/server/services/plan-usage-contract.mjs +6 -2
- package/server/services/plan-usage-deepseek.mjs +1 -1
- package/server/services/plan-usage-tokens.mjs +248 -0
- package/server/services/push-dispatch-service.mjs +134 -9
- package/server/services/relay-cli-launcher-service.mjs +3 -0
- package/server/services/relay-event-service.mjs +30 -0
- package/server/services/relay-question-context.mjs +11 -0
- package/server/services/relay-restart-picture.mjs +67 -22
- package/server/services/remote-inbox-watch-service.mjs +204 -0
- package/server/services/remote-relay-caller-context.mjs +80 -7
- package/server/services/remote-relay-dispatcher.mjs +101 -19
- package/server/services/restart-resume-service.mjs +5 -2
- package/server/services/session-activity-tracker.mjs +1 -1
- package/server/services/session-launch-mutex-service.mjs +23 -0
- package/server/services/session-worker-launch-service.mjs +12 -3
- package/server/services/session-worker-registry-service.mjs +36 -0
- package/server/services/session-worker-stop-service.mjs +27 -6
- package/server/services/session-worker-websocket-service.mjs +40 -16
- package/server/services/token-usage-service.mjs +430 -0
- package/server/services/update-install-service.mjs +13 -0
- package/server/services/usage-limit-pause-service.mjs +8 -1
- package/server/services/worker-recycle-service.mjs +772 -0
- package/server/services/worker-replacement.mjs +197 -0
- package/server/services/worker-scope.mjs +77 -0
- package/shared/build-id.mjs +128 -0
- package/shared/plan-board-actions.mjs +130 -0
- package/shared/plan-only.mjs +43 -0
- package/shared/relay-asks.mjs +3 -0
- package/shared/relay-events.mjs +38 -4
- package/shared/remote-relay-contract.mjs +66 -11
- package/shared/turn-token-usage.mjs +144 -0
- package/shared/worker-runtime/heartbeat.mjs +5 -0
- package/shared/worker-runtime/simple-steering-state.mjs +38 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +23 -0
- package/shared/worker-runtime/working-directory.mjs +1 -1
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { revealSelectedTabs, watchTabStripFades } from './tab-strip-fade.mjs';
|
|
2
|
+
|
|
1
3
|
const SETTINGS_TAB_STORAGE_KEY = 'copilot_settings_tab';
|
|
2
4
|
const SETTINGS_PROVIDER_TAB_STORAGE_KEY = 'copilot_settings_provider_tab';
|
|
3
5
|
|
|
@@ -109,6 +111,7 @@ export function ensureSettingsTabsBound() {
|
|
|
109
111
|
const modal = document.getElementById('settings-modal');
|
|
110
112
|
if (!modal) return;
|
|
111
113
|
settingsTabsBound = true;
|
|
114
|
+
watchTabStripFades();
|
|
112
115
|
modal.addEventListener('click', handleTabClick);
|
|
113
116
|
modal.addEventListener('keydown', handleTabKeydown);
|
|
114
117
|
}
|
|
@@ -125,5 +128,14 @@ export function selectSettingsTab(tab, providerTab) {
|
|
|
125
128
|
if (applyTabState('data-settings-provider-tab', 'data-settings-provider-panel', nextProviderTab)) {
|
|
126
129
|
writeLocalStorage(SETTINGS_PROVIDER_TAB_STORAGE_KEY, nextProviderTab);
|
|
127
130
|
}
|
|
131
|
+
// The selected tabs whole, clear of the strips' fades (a closed dialog
|
|
132
|
+
// is not laid out: openSettingsModal does it again once it shows).
|
|
133
|
+
revealSettingsTabs();
|
|
128
134
|
return { tab: nextTab, providerTab: nextProviderTab };
|
|
129
135
|
}
|
|
136
|
+
|
|
137
|
+
/** Each Settings strip: its selected tab scrolled into view, its fade marked. */
|
|
138
|
+
export function revealSettingsTabs() {
|
|
139
|
+
const modal = document.getElementById('settings-modal');
|
|
140
|
+
if (modal) revealSelectedTabs(modal);
|
|
141
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// The relay's active public shares, as it last reported them: one entry per
|
|
2
2
|
// shared conversation (Settings → Shared conversations) and whether the open
|
|
3
|
-
// conversation has one (the
|
|
3
|
+
// conversation has one (the ︙ menu's "Stop sharing"). Read from GET /api/shares
|
|
4
4
|
// when the socket connects and again on every share event; no DOM here.
|
|
5
5
|
|
|
6
6
|
import { listConversationShares } from './api-client.js';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Settings → Sharing: every conversation with a public link, its access log,
|
|
2
|
-
// and the actions on a link (copy, renew, stop). The
|
|
2
|
+
// and the actions on a link (copy, renew, stop). The ︙ menu's "Stop sharing"
|
|
3
3
|
// uses the same confirmation. The list lives in share-state.js; the socket's
|
|
4
4
|
// share events keep it current while the modal is open.
|
|
5
5
|
|
|
@@ -461,7 +461,7 @@ export function openRenewShareConfirmation(conversationId, title = '') {
|
|
|
461
461
|
}));
|
|
462
462
|
}
|
|
463
463
|
|
|
464
|
-
/**
|
|
464
|
+
/** ︙ → Share conversation: the link, made if there is none, to the clipboard. */
|
|
465
465
|
export async function shareConversationFromMenu(conversationId) {
|
|
466
466
|
const result = await createConversationShareLink(conversationId);
|
|
467
467
|
const shareUrl = String(result?.shareUrl || '').trim();
|
|
@@ -40,7 +40,7 @@ import { applyBackgroundTasksToConversation, applyTurnStatusToConversation } fro
|
|
|
40
40
|
import { applyRelayEventSubscriptions, loadRelayEventSubscriptions } from './relay-event-subscriptions-ui.js';
|
|
41
41
|
import { applyRelaySchedules, loadRelaySchedules } from './relay-schedules-ui.js';
|
|
42
42
|
import { noteInboxQuestionsChanged, noteRelayAskUpdated, refreshInbox } from './inbox-ui.js';
|
|
43
|
-
import { RELAY_ASK_SOCKET_EVENT } from './inbox-view.mjs';
|
|
43
|
+
import { INBOX_REMOTE_CHANGED_EVENT, RELAY_ASK_SOCKET_EVENT } from './inbox-view.mjs';
|
|
44
44
|
import {
|
|
45
45
|
upsertRelayQuestion,
|
|
46
46
|
loadRelayQuestions,
|
|
@@ -104,6 +104,8 @@ import { stripRelayPromptContext } from './relay-prompt-sanitizer.mjs';
|
|
|
104
104
|
import { isLikelyLiveDuplicateMessage } from './live-message-dedupe.mjs';
|
|
105
105
|
import { mergeRelayThoughts } from './relay-thoughts.mjs';
|
|
106
106
|
import { conversationPayloadCache } from './conversation-payload-cache.mjs';
|
|
107
|
+
import { planOnlyAfterBroadcast } from './plan-board-shared.mjs';
|
|
108
|
+
import { noteConversationGone } from './session-history-ui.js';
|
|
107
109
|
|
|
108
110
|
// A live row of the conversation on screen joins its kept window, so a
|
|
109
111
|
// switch away and back still shows it. Rows of other conversations do not:
|
|
@@ -664,12 +666,16 @@ export async function connectSocket(overrideDeps) {
|
|
|
664
666
|
loadRelayQuestions(currentConvId);
|
|
665
667
|
noteInboxQuestionsChanged();
|
|
666
668
|
});
|
|
669
|
+
// A paired relay's pending cards changed (this relay checks them every ~20 s).
|
|
670
|
+
socket.on(INBOX_REMOTE_CHANGED_EVENT, () => noteInboxQuestionsChanged());
|
|
667
671
|
// Questions that don't block: one ask, after every change of it.
|
|
668
672
|
socket.on(RELAY_ASK_SOCKET_EVENT, (payload) => noteRelayAskUpdated(payload));
|
|
669
673
|
socket.on('relay_board', ({ board }) => upsertRelayBoard(board));
|
|
670
674
|
socket.on('relay_board_updated', ({ board }) => upsertRelayBoard(board));
|
|
671
675
|
socket.on('relay_board_changed', () => {
|
|
672
676
|
loadRelayBoards();
|
|
677
|
+
// Plan cards are in the Inbox too.
|
|
678
|
+
noteInboxQuestionsChanged();
|
|
673
679
|
});
|
|
674
680
|
// Relay event subscriptions: the whole active list, after every change.
|
|
675
681
|
socket.on('relay_event_subscriptions', ({ subscriptions }) => {
|
|
@@ -715,7 +721,7 @@ export async function connectSocket(overrideDeps) {
|
|
|
715
721
|
setPreviews(previews);
|
|
716
722
|
});
|
|
717
723
|
// Public shares: the events name the conversation only; the list (Settings
|
|
718
|
-
// → Sharing, the
|
|
724
|
+
// → Sharing, the ︙ menu's Stop sharing) is read again.
|
|
719
725
|
for (const event of ['share_created', 'share_renewed', 'share_revoked']) {
|
|
720
726
|
socket.on(event, () => {
|
|
721
727
|
void refreshShares().catch(() => {});
|
|
@@ -837,7 +843,8 @@ export async function connectSocket(overrideDeps) {
|
|
|
837
843
|
conversationPayloadCache.forget(targetConversationId);
|
|
838
844
|
await refreshConversations();
|
|
839
845
|
if (currentConvId === sourceConversationId) {
|
|
840
|
-
|
|
846
|
+
// The successor takes the compacted one's history entry: no step back into it.
|
|
847
|
+
await openConversation(targetConversationId, { history: 'replace' });
|
|
841
848
|
} else {
|
|
842
849
|
updateCompactButton();
|
|
843
850
|
}
|
|
@@ -846,7 +853,7 @@ export async function connectSocket(overrideDeps) {
|
|
|
846
853
|
applyConversationTitleUpdate(conversationId, title, updatedAt);
|
|
847
854
|
syncChatTitleControls();
|
|
848
855
|
});
|
|
849
|
-
socket.on('conversation_preferences_updated', ({ conversationId, preferredRelayMode, preferredModel, preferredReasoningEffort, autoCompactWindow, thinkingEnabled, thinkingDisplay, mayCreateSessions, senderClientId }) => {
|
|
856
|
+
socket.on('conversation_preferences_updated', ({ conversationId, preferredRelayMode, preferredModel, preferredReasoningEffort, autoCompactWindow, thinkingEnabled, thinkingDisplay, mayCreateSessions, planOnly, senderClientId }) => {
|
|
850
857
|
if (senderClientId && senderClientId === CLIENT_ID) return;
|
|
851
858
|
const id = String(conversationId || '').trim();
|
|
852
859
|
if (!id || !conversations[id]) return;
|
|
@@ -872,6 +879,9 @@ export async function connectSocket(overrideDeps) {
|
|
|
872
879
|
mayCreateSessions: typeof mayCreateSessions === 'boolean'
|
|
873
880
|
? mayCreateSessions
|
|
874
881
|
: conversations[id].mayCreateSessions === true,
|
|
882
|
+
// Plan-only: a broadcast without it (an older relay, a board's mode
|
|
883
|
+
// follow-up) keeps the known value.
|
|
884
|
+
planOnly: planOnlyAfterBroadcast(planOnly, conversations[id].planOnly),
|
|
875
885
|
};
|
|
876
886
|
if (String(currentConvId || '').trim() === id) {
|
|
877
887
|
applyConversationPreferencesForConversation(id, {
|
|
@@ -1002,6 +1012,7 @@ export async function connectSocket(overrideDeps) {
|
|
|
1002
1012
|
});
|
|
1003
1013
|
socket.on('conversation_deleted', ({ conversationId }) => {
|
|
1004
1014
|
delete conversations[conversationId];
|
|
1015
|
+
noteConversationGone(conversationId);
|
|
1005
1016
|
conversationPayloadCache.forget(conversationId);
|
|
1006
1017
|
for (const [id, question] of relayQuestions.entries()) {
|
|
1007
1018
|
if (question?.conversationId === conversationId) relayQuestions.delete(id);
|
|
@@ -285,12 +285,28 @@ export function leaveStatusView() {
|
|
|
285
285
|
return wasActive;
|
|
286
286
|
}
|
|
287
287
|
|
|
288
|
-
|
|
288
|
+
/**
|
|
289
|
+
* Nothing to go back to (opened with nothing open, as after an unknown
|
|
290
|
+
* link): the header and the transcript give way to the empty screen.
|
|
291
|
+
* Loaded on demand: the transcript's module is already in the page.
|
|
292
|
+
*/
|
|
293
|
+
async function showNothingOpen() {
|
|
294
|
+
const { clearOpenConversation } = await import('./journal-view.js');
|
|
295
|
+
clearOpenConversation({ history: false });
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Opens Status, or leaves it for the conversation it was opened from. Opened
|
|
300
|
+
* from the Inbox there is none stored: `returnTo` (the session the history
|
|
301
|
+
* stands on) is opened instead, so the transcript never keeps Status's rows.
|
|
302
|
+
*/
|
|
303
|
+
export async function toggleStatusView({ returnTo = '' } = {}) {
|
|
289
304
|
if (statusViewActive) {
|
|
290
305
|
leaveStatusView();
|
|
291
|
-
const restoreId = previousConversationId;
|
|
306
|
+
const restoreId = previousConversationId || String(returnTo || '').trim();
|
|
292
307
|
previousConversationId = '';
|
|
293
308
|
if (restoreId) await window.openConversation?.(restoreId, { restoreScroll: true });
|
|
309
|
+
else await showNothingOpen();
|
|
294
310
|
return;
|
|
295
311
|
}
|
|
296
312
|
previousConversationId = String(currentConvId || '').trim();
|
|
@@ -7,6 +7,8 @@ import { isChatInteractionHeld } from './selection-guard.mjs';
|
|
|
7
7
|
import { nextSubagentStatus } from './subagent-fold.mjs';
|
|
8
8
|
import { useStrictStrikethrough } from './markdown-strikethrough.mjs';
|
|
9
9
|
import { parseGroupFoldChoices, pruneGroupFoldChoices } from './conversation-group-state.mjs';
|
|
10
|
+
import { isPhoneLandscapeLayout, shouldCollapseListAfterPick, syncPhoneLandscapeAttribute } from './phone-landscape.mjs';
|
|
11
|
+
import { FONT_SCALE_CHANGE_EVENT } from './font-scaling.js';
|
|
10
12
|
|
|
11
13
|
function resolveAppBase() {
|
|
12
14
|
const configuredBase = typeof window.__COPILOT_APP_CONFIG?.basePath === 'string'
|
|
@@ -265,26 +267,37 @@ export function pruneConversationGroups(keepGroupIds) {
|
|
|
265
267
|
return dropped;
|
|
266
268
|
}
|
|
267
269
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
+
// The live list and the Archived view keep their fold choices apart.
|
|
271
|
+
function conversationGroupFoldsStorageKey(archived) {
|
|
272
|
+
return archived === true ? `${CONVERSATION_GROUP_FOLDS_STORAGE_KEY}_archived` : CONVERSATION_GROUP_FOLDS_STORAGE_KEY;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function readConversationGroupFoldChoices({ archived = false } = {}) {
|
|
276
|
+
return parseGroupFoldChoices(localStorage.getItem(conversationGroupFoldsStorageKey(archived)) || '');
|
|
270
277
|
}
|
|
271
278
|
|
|
272
279
|
// The stored fold choices are trimmed to the loaded groups only past this
|
|
273
|
-
// size: a choice for a group on an older page
|
|
274
|
-
// not go with every toggle.
|
|
280
|
+
// size: a choice for a group on an older page must not go with every toggle.
|
|
275
281
|
const CONVERSATION_GROUP_FOLDS_MAX = 300;
|
|
276
282
|
|
|
277
|
-
/** Stores the user's fold choice for a group. */
|
|
278
|
-
export function setConversationGroupFoldChoice(groupId, choice) {
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
283
|
+
/** Stores the user's fold choice for a group, in the live list or the Archived view. */
|
|
284
|
+
export function setConversationGroupFoldChoice(groupId, choice, { archived = false } = {}) {
|
|
285
|
+
setConversationGroupFoldChoices([groupId], choice, { archived });
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Stores one fold choice for several groups at once (fold all, unfold all). */
|
|
289
|
+
export function setConversationGroupFoldChoices(groupIds, choice, { archived = false } = {}) {
|
|
290
|
+
const ids = (Array.isArray(groupIds) ? groupIds : []).map((groupId) => String(groupId || '').trim()).filter(Boolean);
|
|
291
|
+
if (!ids.length) return;
|
|
292
|
+
let next = readConversationGroupFoldChoices({ archived });
|
|
293
|
+
if (Object.keys(next).length + ids.length > CONVERSATION_GROUP_FOLDS_MAX) {
|
|
294
|
+
next = pruneGroupFoldChoices(next, [...Object.keys(conversationGroups), ...ids]);
|
|
295
|
+
}
|
|
296
|
+
for (const id of ids) {
|
|
297
|
+
if (choice === 'open' || choice === 'folded') next[id] = choice;
|
|
298
|
+
else delete next[id];
|
|
284
299
|
}
|
|
285
|
-
|
|
286
|
-
else delete next[id];
|
|
287
|
-
localStorage.setItem(CONVERSATION_GROUP_FOLDS_STORAGE_KEY, JSON.stringify(next));
|
|
300
|
+
localStorage.setItem(conversationGroupFoldsStorageKey(archived), JSON.stringify(next));
|
|
288
301
|
}
|
|
289
302
|
|
|
290
303
|
export function setConversationWatcherCount(conversationId, count) {
|
|
@@ -744,6 +757,14 @@ export function isMobileComposerViewport() {
|
|
|
744
757
|
return window.matchMedia('(max-width: 680px)').matches;
|
|
745
758
|
}
|
|
746
759
|
|
|
760
|
+
// A phone either way round: the portrait layout above, or a phone turned
|
|
761
|
+
// sideways, which has the desktop layout (:root[data-phone-landscape]).
|
|
762
|
+
// Opening a session leaves the message box unfocused here, so no keyboard
|
|
763
|
+
// comes up by itself.
|
|
764
|
+
export function isPhoneComposerViewport() {
|
|
765
|
+
return isMobileComposerViewport() || isPhoneLandscapeLayout();
|
|
766
|
+
}
|
|
767
|
+
|
|
747
768
|
export function releaseComposerFocusAfterSend(input) {
|
|
748
769
|
if (!input || !isMobileComposerViewport()) return;
|
|
749
770
|
try { input.blur(); } catch {}
|
|
@@ -1131,18 +1152,30 @@ function measureSidebarHeaderMinWidthPx() {
|
|
|
1131
1152
|
sectionCount += 1;
|
|
1132
1153
|
}
|
|
1133
1154
|
|
|
1155
|
+
// New Chat grows into the free room, so its box says nothing: its label's
|
|
1156
|
+
// own width is what the head wraps by.
|
|
1134
1157
|
const newConversationButton = document.getElementById('new-conv-btn');
|
|
1135
1158
|
if (isVisibleElement(newConversationButton)) {
|
|
1136
1159
|
const buttonStyle = window.getComputedStyle(newConversationButton);
|
|
1137
|
-
|
|
1160
|
+
let labelWidth = 0;
|
|
1161
|
+
try {
|
|
1162
|
+
const range = document.createRange();
|
|
1163
|
+
range.selectNodeContents(newConversationButton);
|
|
1164
|
+
labelWidth = range.getBoundingClientRect().width || 0;
|
|
1165
|
+
} catch {}
|
|
1166
|
+
const natural = labelWidth
|
|
1167
|
+
+ parseCssPx(buttonStyle.paddingLeft) + parseCssPx(buttonStyle.paddingRight)
|
|
1168
|
+
+ parseCssPx(buttonStyle.borderLeftWidth) + parseCssPx(buttonStyle.borderRightWidth);
|
|
1169
|
+
neededWidth += Math.ceil(Math.max(parseCssPx(buttonStyle.minWidth), natural));
|
|
1138
1170
|
sectionCount += 1;
|
|
1139
1171
|
}
|
|
1140
1172
|
|
|
1173
|
+
// These two keep their own width (Inbox grows with its count).
|
|
1141
1174
|
for (const id of ['status-btn', 'inbox-btn']) {
|
|
1142
1175
|
const headerButton = document.getElementById(id);
|
|
1143
1176
|
if (!isVisibleElement(headerButton)) continue;
|
|
1144
1177
|
const buttonStyle = window.getComputedStyle(headerButton);
|
|
1145
|
-
neededWidth += Math.ceil(parseCssPx(buttonStyle.minWidth));
|
|
1178
|
+
neededWidth += Math.ceil(Math.max(parseCssPx(buttonStyle.minWidth), headerButton.getBoundingClientRect().width || 0));
|
|
1146
1179
|
sectionCount += 1;
|
|
1147
1180
|
}
|
|
1148
1181
|
|
|
@@ -1260,6 +1293,23 @@ export function toggleSidebar(forceOpen = null) {
|
|
|
1260
1293
|
setDesktopSidebarCollapsed(!nextOpen);
|
|
1261
1294
|
}
|
|
1262
1295
|
|
|
1296
|
+
/**
|
|
1297
|
+
* A session picked from the list (a tap on its row, or Open in its menu). A
|
|
1298
|
+
* phone turned sideways has the desktop's persistent list; the pick collapses
|
|
1299
|
+
* it, as the overlay list closes on a portrait phone (openConversation's
|
|
1300
|
+
* closeSidebar). A long press never gets here: it opens the row menu, and
|
|
1301
|
+
* the click its release makes is swallowed (context-menu.mjs).
|
|
1302
|
+
*/
|
|
1303
|
+
export function collapseSidebarAfterListPick() {
|
|
1304
|
+
const collapse = shouldCollapseListAfterPick({
|
|
1305
|
+
phoneLandscape: isPhoneLandscapeLayout(),
|
|
1306
|
+
overlayList: isOverlaySidebarViewport(),
|
|
1307
|
+
listOpen: isSidebarOpen(),
|
|
1308
|
+
});
|
|
1309
|
+
if (collapse) toggleSidebar(false);
|
|
1310
|
+
return collapse;
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1263
1313
|
export function syncSidebarLayoutState() {
|
|
1264
1314
|
applySidebarWidthPercent(readSidebarWidthPercentForCurrentOrientation(), { persist: false });
|
|
1265
1315
|
if (isOverlaySidebarViewport()) {
|
|
@@ -1312,11 +1362,30 @@ export function initSidebarLayout() {
|
|
|
1312
1362
|
const resync = () => syncSidebarLayoutState();
|
|
1313
1363
|
window.addEventListener('resize', resync, { passive: true });
|
|
1314
1364
|
window.addEventListener('orientationchange', resync, { passive: true });
|
|
1365
|
+
// A zoom changes the head's buttons, and with them the narrowest list.
|
|
1366
|
+
window.addEventListener(FONT_SCALE_CHANGE_EVENT, resync);
|
|
1315
1367
|
const chatHeader = document.getElementById('chat-header');
|
|
1316
1368
|
if (chatHeader && typeof ResizeObserver === 'function') {
|
|
1317
1369
|
const observer = new ResizeObserver(syncMobileSidebarTopOffset);
|
|
1318
1370
|
observer.observe(chatHeader);
|
|
1319
1371
|
}
|
|
1372
|
+
// The Inbox count arrives after the first pass (and changes): the list
|
|
1373
|
+
// widens to keep the head on one row where it can. Neither button takes
|
|
1374
|
+
// its width from the list's, so this cannot feed back.
|
|
1375
|
+
if (typeof ResizeObserver === 'function') {
|
|
1376
|
+
const headButtons = ['status-btn', 'inbox-btn'].map((id) => document.getElementById(id)).filter(Boolean);
|
|
1377
|
+
const widths = new Map();
|
|
1378
|
+
const headObserver = new ResizeObserver((entries) => {
|
|
1379
|
+
let changed = false;
|
|
1380
|
+
for (const entry of entries) {
|
|
1381
|
+
const width = Math.round(entry.contentRect?.width || 0);
|
|
1382
|
+
if (widths.get(entry.target) !== width) changed = true;
|
|
1383
|
+
widths.set(entry.target, width);
|
|
1384
|
+
}
|
|
1385
|
+
if (changed && !isOverlaySidebarViewport()) syncSidebarLayoutState();
|
|
1386
|
+
});
|
|
1387
|
+
for (const button of headButtons) headObserver.observe(button);
|
|
1388
|
+
}
|
|
1320
1389
|
}
|
|
1321
1390
|
|
|
1322
1391
|
export function setCompactInFlight(value) {
|
|
@@ -1436,6 +1505,8 @@ function syncScreenShortSide() {
|
|
|
1436
1505
|
if (!shortSide) return;
|
|
1437
1506
|
document.documentElement.style.setProperty('--screen-short-side', `${shortSide}px`);
|
|
1438
1507
|
document.documentElement.toggleAttribute('data-phone-screen', shortSide <= 500);
|
|
1508
|
+
// The same screen, turned sideways: :root[data-phone-landscape].
|
|
1509
|
+
syncPhoneLandscapeAttribute(window);
|
|
1439
1510
|
}
|
|
1440
1511
|
|
|
1441
1512
|
export function syncViewportMetrics() {
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import { apiFetch, cancelSubagentRun, loadSharedSubagentRecord } from './api-client.js';
|
|
18
18
|
import { renderMarkdownPreview } from './router.js';
|
|
19
|
-
import { currentConvId, getSubagentRun, IS_SHARED_VIEW, upsertSubagentRun } from './store.js';
|
|
19
|
+
import { closeSummaryModal, currentConvId, getSubagentRun, IS_SHARED_VIEW, openSummaryModal, upsertSubagentRun } from './store.js';
|
|
20
20
|
import { createViewerHistory } from './viewer-history.mjs';
|
|
21
21
|
import { getSocket } from './socket-handlers.js';
|
|
22
22
|
import { createSubagentViewerWatch } from './subagent-viewer-watch.mjs';
|
|
@@ -360,6 +360,31 @@ async function openParent() {
|
|
|
360
360
|
await loadRun(top.runId);
|
|
361
361
|
}
|
|
362
362
|
|
|
363
|
+
/** Stop asks first: the agent's work so far stays, but it ends here. */
|
|
364
|
+
function askBeforeStopRun() {
|
|
365
|
+
const run = viewer.run;
|
|
366
|
+
if (IS_SHARED_VIEW || !run || viewer.stopping || !runIsRunning(run)) return;
|
|
367
|
+
openSummaryModal({
|
|
368
|
+
title: 'Stop agent',
|
|
369
|
+
subtitle: run.displayName || '',
|
|
370
|
+
kind: 'subagent-stop',
|
|
371
|
+
bodyHtml: `
|
|
372
|
+
<p>Stop <strong>${escHtml(run.displayName || 'this agent')}</strong>?</p>
|
|
373
|
+
<p>It ends where it is now; what it has done so far stays.</p>
|
|
374
|
+
<div class="summary-modal-actions">
|
|
375
|
+
<button class="chat-title-action-btn danger-btn" type="button" data-subagent-stop-confirm="1">⏹ Stop agent</button>
|
|
376
|
+
<button class="chat-title-action-btn" type="button" data-subagent-stop-cancel="1">Cancel</button>
|
|
377
|
+
</div>
|
|
378
|
+
`,
|
|
379
|
+
});
|
|
380
|
+
const dialog = document.getElementById('summary-modal');
|
|
381
|
+
dialog?.querySelector('[data-subagent-stop-confirm]')?.addEventListener('click', () => {
|
|
382
|
+
closeSummaryModal();
|
|
383
|
+
void stopRun();
|
|
384
|
+
}, { once: true });
|
|
385
|
+
dialog?.querySelector('[data-subagent-stop-cancel]')?.addEventListener('click', () => closeSummaryModal(), { once: true });
|
|
386
|
+
}
|
|
387
|
+
|
|
363
388
|
async function stopRun() {
|
|
364
389
|
const run = viewer.run;
|
|
365
390
|
if (IS_SHARED_VIEW || !run || viewer.stopping || !runIsRunning(run)) return;
|
|
@@ -518,7 +543,7 @@ export function initSubagentViewer() {
|
|
|
518
543
|
if (event.target === modal) closeSubagentViewer();
|
|
519
544
|
});
|
|
520
545
|
modal?.querySelector('.subagent-viewer-close')?.addEventListener('click', () => closeSubagentViewer());
|
|
521
|
-
stop?.addEventListener('click', () =>
|
|
546
|
+
stop?.addEventListener('click', () => askBeforeStopRun());
|
|
522
547
|
parent?.addEventListener('click', () => void openParent());
|
|
523
548
|
jump?.addEventListener('click', () => {
|
|
524
549
|
if (body) body.scrollTop = body.scrollHeight;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// The fade on a tab strip that scrolls sideways (Settings, its provider tabs,
|
|
2
|
+
// the usage dialog's providers): the strip has no scrollbar, so the side with
|
|
3
|
+
// more tabs fades out. Marked from the strip's own scroll as `data-fade`
|
|
4
|
+
// ("right" at the start, "both" in between, "left" at the end, none when the
|
|
5
|
+
// strip fits or is not laid out); the CSS draws the mask from it.
|
|
6
|
+
|
|
7
|
+
import { FONT_SCALE_CHANGE_EVENT } from './font-scaling.js';
|
|
8
|
+
|
|
9
|
+
export const TAB_STRIP_SELECTOR = '.settings-tab-strip, .plan-usage-tab-strip';
|
|
10
|
+
|
|
11
|
+
// Sub-pixel scroll ends (a zoomed page) count as the end.
|
|
12
|
+
const EDGE_SLACK_PX = 1;
|
|
13
|
+
|
|
14
|
+
let watching = false;
|
|
15
|
+
|
|
16
|
+
/** The `data-fade` value for a strip's scroll metrics ('' for none). */
|
|
17
|
+
export function tabStripFadeState({ scrollLeft = 0, scrollWidth = 0, clientWidth = 0 } = {}) {
|
|
18
|
+
const room = Number(scrollWidth) - Number(clientWidth);
|
|
19
|
+
if (!(room > EDGE_SLACK_PX)) return '';
|
|
20
|
+
const offset = Math.abs(Number(scrollLeft) || 0);
|
|
21
|
+
const before = offset > EDGE_SLACK_PX;
|
|
22
|
+
const after = offset < room - EDGE_SLACK_PX;
|
|
23
|
+
if (before && after) return 'both';
|
|
24
|
+
if (before) return 'left';
|
|
25
|
+
return after ? 'right' : '';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function syncTabStripFade(strip) {
|
|
29
|
+
if (!strip?.setAttribute) return '';
|
|
30
|
+
const state = tabStripFadeState(strip);
|
|
31
|
+
if (state) {
|
|
32
|
+
if (strip.getAttribute('data-fade') !== state) strip.setAttribute('data-fade', state);
|
|
33
|
+
} else if (strip.hasAttribute('data-fade')) {
|
|
34
|
+
strip.removeAttribute('data-fade');
|
|
35
|
+
}
|
|
36
|
+
return state;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function fadeWidth(strip) {
|
|
40
|
+
try {
|
|
41
|
+
// The CSS gives the strip the fade's width as its scroll padding.
|
|
42
|
+
const value = parseFloat(getComputedStyle(strip).scrollPaddingLeft);
|
|
43
|
+
return Number.isFinite(value) ? value : 0;
|
|
44
|
+
} catch {
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Scrolls the strip (and nothing around it) so `tab` shows whole and clear of
|
|
51
|
+
* the fade; at either end the strip stops there, and that edge has none.
|
|
52
|
+
*/
|
|
53
|
+
export function revealTab(strip, tab) {
|
|
54
|
+
if (!strip || !tab || !strip.contains(tab)) return;
|
|
55
|
+
const room = strip.scrollWidth - strip.clientWidth;
|
|
56
|
+
if (room > EDGE_SLACK_PX) {
|
|
57
|
+
const stripBox = strip.getBoundingClientRect();
|
|
58
|
+
const tabBox = tab.getBoundingClientRect();
|
|
59
|
+
const margin = fadeWidth(strip);
|
|
60
|
+
const left = tabBox.left - stripBox.left - strip.clientLeft;
|
|
61
|
+
const right = left + tabBox.width;
|
|
62
|
+
let next = strip.scrollLeft;
|
|
63
|
+
if (left - margin < 0) next += left - margin;
|
|
64
|
+
else if (right + margin > strip.clientWidth) next += right + margin - strip.clientWidth;
|
|
65
|
+
next = Math.max(0, Math.min(room, next));
|
|
66
|
+
if (Math.abs(next - strip.scrollLeft) >= 0.5) strip.scrollLeft = next;
|
|
67
|
+
}
|
|
68
|
+
syncTabStripFade(strip);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Each strip under `root`: its selected tab in view, its fade marked. */
|
|
72
|
+
export function revealSelectedTabs(root = document) {
|
|
73
|
+
if (!root?.querySelectorAll) return;
|
|
74
|
+
const strips = root.matches?.(TAB_STRIP_SELECTOR) ? [root] : Array.from(root.querySelectorAll(TAB_STRIP_SELECTOR));
|
|
75
|
+
for (const strip of strips) {
|
|
76
|
+
const selected = strip.querySelector('[aria-selected="true"]');
|
|
77
|
+
if (selected) revealTab(strip, selected);
|
|
78
|
+
else syncTabStripFade(strip);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Once per page: a strip's scroll re-marks it (scroll does not bubble, so the
|
|
84
|
+
* listener captures), and so does a resize or a turn of the phone.
|
|
85
|
+
*/
|
|
86
|
+
export function watchTabStripFades() {
|
|
87
|
+
if (watching || typeof document === 'undefined') return;
|
|
88
|
+
watching = true;
|
|
89
|
+
document.addEventListener('scroll', (event) => {
|
|
90
|
+
const strip = event.target;
|
|
91
|
+
if (strip?.matches?.(TAB_STRIP_SELECTOR)) syncTabStripFade(strip);
|
|
92
|
+
}, { capture: true, passive: true });
|
|
93
|
+
if (typeof window !== 'undefined') {
|
|
94
|
+
const syncAll = () => {
|
|
95
|
+
for (const strip of document.querySelectorAll(TAB_STRIP_SELECTOR)) syncTabStripFade(strip);
|
|
96
|
+
};
|
|
97
|
+
window.addEventListener('resize', syncAll, { passive: true });
|
|
98
|
+
// The zoom (font-scaling.js) resizes the tabs, not the window.
|
|
99
|
+
window.addEventListener(FONT_SCALE_CHANGE_EVENT, () => requestAnimationFrame(syncAll));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -35,6 +35,71 @@ export function bindTapAction(element, handler) {
|
|
|
35
35
|
});
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
// The ︙ menu's shield (see lockChatActionsMenuShield in bootstrap.js).
|
|
39
|
+
export const CHAT_ACTIONS_MENU_SHIELD_ID = 'chat-actions-menu-backdrop';
|
|
40
|
+
// Longer than the shield's lock and the gap between a touch's pointerup and
|
|
41
|
+
// its compatibility click.
|
|
42
|
+
export const OPENING_CLICK_GRACE_MS = 400;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* True for the click that belongs to the gesture which opened a popover, so
|
|
46
|
+
* a document-level "click outside closes it" handler must leave it alone.
|
|
47
|
+
* A ︙ entry runs on pointerup and closes the menu; the touch's compatibility
|
|
48
|
+
* click then lands on the menu's shield and bubbles to `document`, where it
|
|
49
|
+
* would close the popover that entry has just opened. Later clicks on the
|
|
50
|
+
* shield (a tap beside the open ︙ menu) still count as outside.
|
|
51
|
+
*
|
|
52
|
+
* `openedAt` is the Date.now() of the opening, 0 when unknown.
|
|
53
|
+
*/
|
|
54
|
+
export function isOpeningGestureClick(event, openedAt, now = Date.now()) {
|
|
55
|
+
const opened = Number(openedAt) || 0;
|
|
56
|
+
if (opened <= 0) return false;
|
|
57
|
+
const sinceOpen = now - opened;
|
|
58
|
+
if (sinceOpen < 0 || sinceOpen >= OPENING_CLICK_GRACE_MS) return false;
|
|
59
|
+
return event?.target?.id === CHAT_ACTIONS_MENU_SHIELD_ID;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The one click a touch still owes after a ︙ entry ran on its pointerup.
|
|
64
|
+
* The browser hit-tests that compatibility click when it fires, a few ms
|
|
65
|
+
* later, so it lands on whatever the entry opened: a dialog's backdrop
|
|
66
|
+
* (which closes on a click on itself) or the shield (whose click reaches
|
|
67
|
+
* `document`, where a list closes on a click outside it). `arm()` on the
|
|
68
|
+
* pointerup; the next pointer click within the window is that click.
|
|
69
|
+
*/
|
|
70
|
+
export function createGhostClickGuard({ now = () => Date.now() } = {}) {
|
|
71
|
+
let until = 0;
|
|
72
|
+
return {
|
|
73
|
+
arm(ms = OPENING_CLICK_GRACE_MS) {
|
|
74
|
+
until = now() + Math.max(0, Number(ms) || 0);
|
|
75
|
+
},
|
|
76
|
+
// Disarms on the first click it sees, swallowed or not.
|
|
77
|
+
take(event) {
|
|
78
|
+
if (!until) return false;
|
|
79
|
+
const live = now() < until;
|
|
80
|
+
until = 0;
|
|
81
|
+
// detail 0: Enter or Space on a button, or a script, never a touch.
|
|
82
|
+
return live && Number(event?.detail) !== 0;
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const ghostClickGuard = createGhostClickGuard();
|
|
88
|
+
let ghostClickListenerBound = false;
|
|
89
|
+
|
|
90
|
+
function swallowGhostClickAfterTouch(event) {
|
|
91
|
+
// A mouse's click goes to the entry it pressed, which drops it itself.
|
|
92
|
+
if (!event || event.pointerType === 'mouse') return;
|
|
93
|
+
ghostClickGuard.arm();
|
|
94
|
+
if (ghostClickListenerBound || typeof window === 'undefined') return;
|
|
95
|
+
ghostClickListenerBound = true;
|
|
96
|
+
window.addEventListener('click', (clickEvent) => {
|
|
97
|
+
if (!ghostClickGuard.take(clickEvent)) return;
|
|
98
|
+
clickEvent.preventDefault();
|
|
99
|
+
clickEvent.stopImmediatePropagation();
|
|
100
|
+
}, true);
|
|
101
|
+
}
|
|
102
|
+
|
|
38
103
|
export function bindMenuAction(element, handler) {
|
|
39
104
|
if (!element || element.dataset.menuTapBound === '1') return;
|
|
40
105
|
element.dataset.menuTapBound = '1';
|
|
@@ -48,6 +113,7 @@ export function bindMenuAction(element, handler) {
|
|
|
48
113
|
event.stopPropagation();
|
|
49
114
|
event.stopImmediatePropagation();
|
|
50
115
|
markSuppressed();
|
|
116
|
+
swallowGhostClickAfterTouch(event);
|
|
51
117
|
handler(event);
|
|
52
118
|
}, true);
|
|
53
119
|
element.addEventListener('click', (event) => {
|