@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,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
IS_SHARED_VIEW,
|
|
3
|
+
conversations,
|
|
3
4
|
currentConvId,
|
|
4
5
|
escHtml,
|
|
5
6
|
relayBoards,
|
|
@@ -12,9 +13,11 @@ import {
|
|
|
12
13
|
boardsOfConversation,
|
|
13
14
|
} from './relay-board-inline.mjs';
|
|
14
15
|
import {
|
|
16
|
+
loadRelayBoard as loadRelayBoardApi,
|
|
15
17
|
loadRelayBoards as loadRelayBoardsApi,
|
|
16
18
|
submitRelayBoardAction as submitRelayBoardActionApi,
|
|
17
19
|
} from './api-client.js';
|
|
20
|
+
import { planBoardConfirmText } from './plan-board-shared.mjs';
|
|
18
21
|
import { renderMarkdownPreview } from './router.js';
|
|
19
22
|
import { attachCodeCopyButtons } from './code-copy.mjs';
|
|
20
23
|
import { beginOwnSend, followsTranscriptEnd, refreshJumpButton, scrollAfterOwnSend } from './transcript-jump-bottom-view.mjs';
|
|
@@ -203,27 +206,52 @@ export function renderRelayBoards() {
|
|
|
203
206
|
return added;
|
|
204
207
|
}
|
|
205
208
|
|
|
206
|
-
|
|
209
|
+
/**
|
|
210
|
+
* Settle a board, from its card or from the Inbox (`board`: what the caller
|
|
211
|
+
* shows, when the store has no copy; `controls`: the buttons to hold while
|
|
212
|
+
* it goes). On a plan-only session an action that leaves plan mode asks
|
|
213
|
+
* first. A board the other view settled meanwhile is shown as settled, with
|
|
214
|
+
* no alert. Returns the board as it is now, or null when nothing was done.
|
|
215
|
+
*/
|
|
216
|
+
export async function actOnRelayBoard(boardId, actionId, { board: shown = null, controls = [] } = {}) {
|
|
207
217
|
const id = String(boardId || '').trim();
|
|
208
218
|
const nextActionId = String(actionId || '').trim();
|
|
209
|
-
if (!id || !nextActionId) return;
|
|
210
|
-
const
|
|
211
|
-
|
|
219
|
+
if (!id || !nextActionId) return null;
|
|
220
|
+
const board = relayBoards.get(id) || shown;
|
|
221
|
+
// The flag as the page knows it now; the board's own copy otherwise.
|
|
222
|
+
const conversation = conversations[String(board?.conversationId || shown?.session?.id || '').trim()] || null;
|
|
223
|
+
const planOnly = typeof conversation?.planOnly === 'boolean' ? conversation.planOnly : board?.planOnly === true;
|
|
224
|
+
const action = (Array.isArray(board?.actions) ? board.actions : [])
|
|
225
|
+
.find((item) => String(item?.id || '').trim().toLowerCase() === nextActionId.toLowerCase());
|
|
226
|
+
const question = planBoardConfirmText({ planOnly, actionId: nextActionId, label: action?.label });
|
|
227
|
+
if (question && !window.confirm(question)) return null;
|
|
212
228
|
controls.forEach((control) => { control.disabled = true; });
|
|
213
|
-
|
|
214
|
-
|
|
229
|
+
let status = 0;
|
|
215
230
|
try {
|
|
216
|
-
const r = await submitRelayBoardActionApi(id, nextActionId);
|
|
217
|
-
|
|
231
|
+
const r = await submitRelayBoardActionApi(id, nextActionId, { onErrorResponse: (response) => { status = response.status; } });
|
|
232
|
+
let settled = r?.board || null;
|
|
233
|
+
// Settled by the other view (or another page) meanwhile: show what was chosen.
|
|
234
|
+
if (!settled && status === 409) settled = (await loadRelayBoardApi(id))?.board || null;
|
|
235
|
+
if (!settled) throw new Error('Failed to submit board action');
|
|
218
236
|
// Kept once it is settled: the reply goes on showing the plan and says
|
|
219
237
|
// what was chosen.
|
|
220
|
-
relayBoards.set(id,
|
|
238
|
+
relayBoards.set(id, settled);
|
|
221
239
|
window.renderConvList?.();
|
|
222
240
|
renderRelayBoards();
|
|
223
|
-
|
|
241
|
+
return settled;
|
|
224
242
|
} catch (error) {
|
|
225
243
|
controls.forEach((control) => { control.disabled = false; });
|
|
226
244
|
alert(error.message || 'Failed to submit board action');
|
|
245
|
+
return null;
|
|
227
246
|
}
|
|
228
247
|
}
|
|
229
248
|
|
|
249
|
+
export async function submitRelayBoardAction(boardId, actionId) {
|
|
250
|
+
const id = String(boardId || '').trim();
|
|
251
|
+
if (!id || !String(actionId || '').trim()) return;
|
|
252
|
+
const card = document.querySelector(`.relay-board-inline[data-board-id="${CSS.escape(id)}"]`);
|
|
253
|
+
const controls = card ? [...card.querySelectorAll('button')] : [];
|
|
254
|
+
const send = beginOwnSend();
|
|
255
|
+
if (await actOnRelayBoard(id, actionId, { controls })) scrollAfterOwnSend(send);
|
|
256
|
+
}
|
|
257
|
+
|
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
// Relay event subscriptions on the page: the header's "watching N sessions"
|
|
2
|
-
// chip with its list and Unsubscribe buttons (on the watcher),
|
|
3
|
-
//
|
|
2
|
+
// chip with its list and Unsubscribe buttons (on the watcher), the "watched"
|
|
3
|
+
// badge in a watched session's header, and the "Watched by" line in its
|
|
4
|
+
// sidebar row's ⓘ menu.
|
|
4
5
|
// The relay sends the whole active list on every change (socket
|
|
5
6
|
// `relay_event_subscriptions`); the page loads it once at start.
|
|
6
7
|
|
|
7
8
|
import { apiFetch } from './api-client.js';
|
|
9
|
+
import { FONT_SCALE_CHANGE_EVENT, getUiScale } from './font-scaling.js';
|
|
10
|
+
import { isOpeningGestureClick } from './tap-actions.js';
|
|
8
11
|
import { conversations, currentConvId, escHtml, IS_SHARED_VIEW, showTransientRelayNotice } from './store.js';
|
|
9
12
|
import {
|
|
10
13
|
renderWatchedBadgeHtml,
|
|
14
|
+
renderWatchingChipHtml,
|
|
11
15
|
renderWatchingPanelHtml,
|
|
16
|
+
watchedInfoText,
|
|
12
17
|
watchersOf,
|
|
13
18
|
watchingChipHint,
|
|
14
19
|
watchingChipText,
|
|
@@ -19,12 +24,40 @@ let subscriptions = [];
|
|
|
19
24
|
let renderedChipKey = '';
|
|
20
25
|
let panelOpen = false;
|
|
21
26
|
let panelConversationId = '';
|
|
27
|
+
// What the open list hangs from when the chip has no box (phone landscape
|
|
28
|
+
// hides the meta line): the ︙ button its menu entry was picked from.
|
|
29
|
+
let panelAnchor = null;
|
|
30
|
+
// When the list last opened: the touch that opened it from ︙ still has its
|
|
31
|
+
// click to come.
|
|
32
|
+
let panelOpenedAt = 0;
|
|
22
33
|
const changeListeners = new Set();
|
|
23
34
|
|
|
24
35
|
export function getRelayEventSubscriptions() {
|
|
25
36
|
return subscriptions;
|
|
26
37
|
}
|
|
27
38
|
|
|
39
|
+
/** The watching chip's text for a conversation ("watching 2 sessions"), or '' when it has none. */
|
|
40
|
+
export function relayWatchingChipText(conversationId) {
|
|
41
|
+
const id = String(conversationId || '').trim();
|
|
42
|
+
if (IS_SHARED_VIEW || !id) return '';
|
|
43
|
+
return watchingChipText(watchingSummary(subscriptions, id));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Opens the watching chip's list, as a tap on the chip does, from elsewhere
|
|
48
|
+
* (the ︙ entry in phone landscape). `anchor` places it while the chip is
|
|
49
|
+
* not drawn.
|
|
50
|
+
*/
|
|
51
|
+
export function openRelayWatchPanel({ anchor = null } = {}) {
|
|
52
|
+
ensurePanel();
|
|
53
|
+
setPanelOpen(true, { anchor });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Closes the watching chip's list (back closes it before anything else). */
|
|
57
|
+
export function closeRelayWatchPanel() {
|
|
58
|
+
if (panelOpen) setPanelOpen(false);
|
|
59
|
+
}
|
|
60
|
+
|
|
28
61
|
/** Runs after every change of the list (the sidebar re-renders its badges). */
|
|
29
62
|
export function onRelayEventSubscriptionsChanged(listener) {
|
|
30
63
|
changeListeners.add(listener);
|
|
@@ -45,10 +78,10 @@ export async function loadRelayEventSubscriptions() {
|
|
|
45
78
|
if (response && Array.isArray(response.subscriptions)) applyRelayEventSubscriptions(response.subscriptions);
|
|
46
79
|
}
|
|
47
80
|
|
|
48
|
-
/** The sidebar's
|
|
49
|
-
export function
|
|
81
|
+
/** The line of the sidebar row's ⓘ menu for one conversation, or '': listed sessions only. */
|
|
82
|
+
export function relayEventWatchedInfoText(conversationId) {
|
|
50
83
|
if (IS_SHARED_VIEW) return '';
|
|
51
|
-
return
|
|
84
|
+
return watchedInfoText(watchersOf(subscriptions, conversationId, { includeAll: false }), { conversations, subscriptions });
|
|
52
85
|
}
|
|
53
86
|
|
|
54
87
|
async function unsubscribe(subscriptionId, button) {
|
|
@@ -66,7 +99,8 @@ async function unsubscribe(subscriptionId, button) {
|
|
|
66
99
|
|
|
67
100
|
// The list opens as a fixed popover on <body>, placed under the chip: the
|
|
68
101
|
// title slot clips its overflow (that keeps the title's ellipsis), so a panel
|
|
69
|
-
// inside it would be cut off.
|
|
102
|
+
// inside it would be cut off. Opened from ︙ (phone landscape, no chip
|
|
103
|
+
// drawn), it hangs from ︙ instead.
|
|
70
104
|
function ensurePanel() {
|
|
71
105
|
let panel = document.getElementById('relay-watch-panel');
|
|
72
106
|
if (panel) return panel;
|
|
@@ -82,50 +116,68 @@ function ensurePanel() {
|
|
|
82
116
|
void unsubscribe(unsubscribeButton.getAttribute('data-subscription-id'), unsubscribeButton);
|
|
83
117
|
});
|
|
84
118
|
document.addEventListener('click', (event) => {
|
|
85
|
-
if (!panelOpen) return;
|
|
119
|
+
if (!panelOpen || isOpeningGestureClick(event, panelOpenedAt)) return;
|
|
86
120
|
// composedPath: an Unsubscribe click may have re-rendered the panel already.
|
|
87
121
|
const path = event.composedPath();
|
|
88
|
-
if (path.includes(panel) || path.some((node) => node instanceof Element && node.classList.contains('relay-watch-chip'))) return;
|
|
122
|
+
if (path.includes(panel) || (panelAnchor && path.includes(panelAnchor)) || path.some((node) => node instanceof Element && node.classList.contains('relay-watch-chip'))) return;
|
|
89
123
|
setPanelOpen(false);
|
|
90
124
|
});
|
|
91
125
|
document.addEventListener('keydown', (event) => {
|
|
92
126
|
if (!panelOpen || event.key !== 'Escape') return;
|
|
93
127
|
event.stopPropagation();
|
|
128
|
+
const anchor = panelAnchor;
|
|
94
129
|
setPanelOpen(false);
|
|
95
|
-
document.querySelector('#chat-title-relay-events .relay-watch-chip')
|
|
130
|
+
const chip = document.querySelector('#chat-title-relay-events .relay-watch-chip');
|
|
131
|
+
(chip?.getClientRects?.().length ? chip : anchor || chip)?.focus?.();
|
|
96
132
|
}, true);
|
|
97
133
|
window.addEventListener('resize', () => { if (panelOpen) placePanel(); });
|
|
134
|
+
window.addEventListener(FONT_SCALE_CHANGE_EVENT, () => { if (panelOpen) placePanel(); });
|
|
98
135
|
return panel;
|
|
99
136
|
}
|
|
100
137
|
|
|
101
|
-
function setPanelOpen(open) {
|
|
138
|
+
function setPanelOpen(open, { anchor = null } = {}) {
|
|
139
|
+
if (open && !panelOpen) panelOpenedAt = Date.now();
|
|
102
140
|
panelOpen = Boolean(open);
|
|
141
|
+
panelAnchor = panelOpen ? anchor : null;
|
|
103
142
|
renderedChipKey = '';
|
|
104
143
|
syncRelayEventHeader();
|
|
105
144
|
}
|
|
106
145
|
|
|
146
|
+
// A box to place against, or null: an element that is not drawn has none.
|
|
147
|
+
function drawnRect(element) {
|
|
148
|
+
const rect = element?.getBoundingClientRect?.();
|
|
149
|
+
return rect && (rect.width || rect.height) ? rect : null;
|
|
150
|
+
}
|
|
151
|
+
|
|
107
152
|
function placePanel() {
|
|
108
153
|
const panel = document.getElementById('relay-watch-panel');
|
|
109
154
|
const chip = document.querySelector('#chat-title-relay-events .relay-watch-chip');
|
|
110
155
|
if (!panel || panel.hidden) return;
|
|
111
|
-
|
|
112
|
-
|
|
156
|
+
// Under the chip; with no chip drawn, under the anchor, right edges lined up.
|
|
157
|
+
const chipRect = drawnRect(chip);
|
|
158
|
+
const rect = chipRect || drawnRect(panelAnchor);
|
|
159
|
+
if (!rect) {
|
|
113
160
|
panel.hidden = true;
|
|
114
161
|
return;
|
|
115
162
|
}
|
|
116
|
-
|
|
163
|
+
// 8px edge, 4px gap and a 120px floor at 100 %, scaled with the zoom.
|
|
164
|
+
const scale = getUiScale();
|
|
165
|
+
const margin = 8 * scale;
|
|
117
166
|
const viewportWidth = document.documentElement.clientWidth || window.innerWidth;
|
|
118
|
-
const top = Math.round(rect.bottom + 4);
|
|
167
|
+
const top = Math.round(rect.bottom + 4 * scale);
|
|
119
168
|
panel.style.top = `${top}px`;
|
|
120
|
-
panel.style.maxHeight = `${Math.max(120, window.innerHeight - top - margin)}px`;
|
|
169
|
+
panel.style.maxHeight = `${Math.round(Math.max(120 * scale, window.innerHeight - top - margin))}px`;
|
|
121
170
|
panel.style.left = '0px';
|
|
122
171
|
const width = panel.offsetWidth;
|
|
123
|
-
const
|
|
172
|
+
const preferred = chipRect ? rect.left : rect.right - width;
|
|
173
|
+
const left = Math.max(margin, Math.min(preferred, viewportWidth - width - margin));
|
|
124
174
|
panel.style.left = `${Math.round(left)}px`;
|
|
125
175
|
}
|
|
126
176
|
|
|
127
177
|
function ensureHeaderElements() {
|
|
128
|
-
|
|
178
|
+
// The meta lines under the title live in #chat-title-meta (one sideways
|
|
179
|
+
// scrolling row on a phone); the slot itself is the fallback.
|
|
180
|
+
const slot = document.getElementById('chat-title-meta') || document.getElementById('chat-title-slot');
|
|
129
181
|
if (!slot) return null;
|
|
130
182
|
let line = document.getElementById('chat-title-relay-events');
|
|
131
183
|
if (!line) {
|
|
@@ -133,7 +185,7 @@ function ensureHeaderElements() {
|
|
|
133
185
|
line.id = 'chat-title-relay-events';
|
|
134
186
|
line.hidden = true;
|
|
135
187
|
const origin = document.getElementById('chat-title-origin');
|
|
136
|
-
if (origin?.nextSibling) slot.insertBefore(line, origin.nextSibling);
|
|
188
|
+
if (origin?.parentNode === slot && origin.nextSibling) slot.insertBefore(line, origin.nextSibling);
|
|
137
189
|
else slot.appendChild(line);
|
|
138
190
|
line.addEventListener('click', (event) => {
|
|
139
191
|
const target = event.target instanceof Element ? event.target : null;
|
|
@@ -172,10 +224,7 @@ export function syncRelayEventHeader() {
|
|
|
172
224
|
if (key === renderedChipKey) return;
|
|
173
225
|
renderedChipKey = key;
|
|
174
226
|
const parts = [];
|
|
175
|
-
if (chipText) {
|
|
176
|
-
const where = summary.relays.length ? ` (also on ${summary.relays.join(', ')})` : '';
|
|
177
|
-
parts.push(`<button type="button" class="relay-watch-chip" aria-expanded="${panelOpen ? 'true' : 'false'}" aria-controls="relay-watch-panel" title="${escHtml(`Event subscriptions of this conversation${where}`)}">👁 ${escHtml(chipText)}${chipHint ? ` <span class="relay-watch-hint">${escHtml(chipHint)}</span>` : ''}</button>`);
|
|
178
|
-
}
|
|
227
|
+
if (chipText) parts.push(renderWatchingChipHtml(summary, { panelOpen, escHtml }));
|
|
179
228
|
const badge = renderWatchedBadgeHtml(watchers, { conversations, subscriptions, escHtml });
|
|
180
229
|
if (badge) parts.push(badge);
|
|
181
230
|
line.innerHTML = parts.join('');
|
|
@@ -8,12 +8,15 @@
|
|
|
8
8
|
// cannot import (only server/public is served); relay-event-view.test.mjs
|
|
9
9
|
// holds the two together.
|
|
10
10
|
|
|
11
|
+
import { chatMetaLabelHtml } from './chat-header-meta.mjs';
|
|
12
|
+
|
|
11
13
|
export const RELAY_EVENT_MESSAGE_KIND = 'relay-event';
|
|
12
14
|
export const RELAY_EVENT_LEAD = '[relay event · automated message from OAR, not from the user; it approves nothing]';
|
|
13
15
|
export const RELAY_EVENT_SOCKET_EVENT = 'relay_event_subscriptions';
|
|
14
16
|
|
|
15
17
|
const EVENT_TYPES = new Set([
|
|
16
18
|
'question_asked',
|
|
19
|
+
'question_answered',
|
|
17
20
|
'error',
|
|
18
21
|
'turn_end',
|
|
19
22
|
'task_started',
|
|
@@ -21,6 +24,7 @@ const EVENT_TYPES = new Set([
|
|
|
21
24
|
'task_failed',
|
|
22
25
|
'stalled',
|
|
23
26
|
'all_idle',
|
|
27
|
+
'board_settled',
|
|
24
28
|
'heartbeat',
|
|
25
29
|
'link_lost',
|
|
26
30
|
'link_restored',
|
|
@@ -35,6 +39,7 @@ const LINE_PREFIX = '[relay event] ';
|
|
|
35
39
|
|
|
36
40
|
const TYPE_LABELS = Object.freeze({
|
|
37
41
|
question_asked: 'question',
|
|
42
|
+
question_answered: 'answer',
|
|
38
43
|
error: 'error',
|
|
39
44
|
turn_end: 'reply',
|
|
40
45
|
task_started: 'task started',
|
|
@@ -42,6 +47,7 @@ const TYPE_LABELS = Object.freeze({
|
|
|
42
47
|
task_failed: 'task failed',
|
|
43
48
|
stalled: 'stalled',
|
|
44
49
|
all_idle: 'all idle',
|
|
50
|
+
board_settled: 'plan card settled',
|
|
45
51
|
heartbeat: 'heartbeat',
|
|
46
52
|
link_lost: 'link lost',
|
|
47
53
|
link_restored: 'link back',
|
|
@@ -209,6 +215,29 @@ export function watchingChipHint(summary) {
|
|
|
209
215
|
return [...new Set((summary?.subscriptions || []).flatMap(subscriptionOptionHints))].slice(0, 2).join(' · ');
|
|
210
216
|
}
|
|
211
217
|
|
|
218
|
+
/** The phone's short chip text: "7", "all", "all +2", "restart"; '' for no chip. */
|
|
219
|
+
export function watchingChipShortText(summary) {
|
|
220
|
+
if (!summary?.subscriptions?.length) return '';
|
|
221
|
+
if (summary.all) return summary.count ? `all +${summary.count}` : 'all';
|
|
222
|
+
if (!summary.count) return 'restart';
|
|
223
|
+
return String(summary.count);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The header's "watching" chip: a button that opens the list, with the long
|
|
228
|
+
* label ("👁 watching 7 sessions" and its hint) and the phone's short one
|
|
229
|
+
* ("👁 7"). '' when the conversation watches nothing.
|
|
230
|
+
*/
|
|
231
|
+
export function renderWatchingChipHtml(summary, { panelOpen = false, escHtml = defaultEscape } = {}) {
|
|
232
|
+
const chipText = watchingChipText(summary);
|
|
233
|
+
if (!chipText) return '';
|
|
234
|
+
const chipHint = watchingChipHint(summary);
|
|
235
|
+
const where = summary.relays?.length ? ` (also on ${summary.relays.join(', ')})` : '';
|
|
236
|
+
const longHtml = `👁 ${escHtml(chipText)}${chipHint ? ` <span class="relay-watch-hint">${escHtml(chipHint)}</span>` : ''}`;
|
|
237
|
+
const shortHtml = `👁 ${escHtml(watchingChipShortText(summary))}`;
|
|
238
|
+
return `<button type="button" class="relay-watch-chip" aria-expanded="${panelOpen ? 'true' : 'false'}" aria-controls="relay-watch-panel" title="${escHtml(`Event subscriptions of this conversation${where}`)}">${chatMetaLabelHtml(longHtml, shortHtml)}</button>`;
|
|
239
|
+
}
|
|
240
|
+
|
|
212
241
|
function conversationTitle(conversations, id) {
|
|
213
242
|
const title = String(conversations?.[id]?.title || '').trim();
|
|
214
243
|
return title || `session ${String(id).slice(0, 8)}`;
|
|
@@ -257,6 +286,19 @@ function watcherName(id, conversations, subscriptions) {
|
|
|
257
286
|
return `${title} on ${relay}`;
|
|
258
287
|
}
|
|
259
288
|
|
|
289
|
+
/**
|
|
290
|
+
* The line of the sidebar row's ⓘ menu for a watched conversation: "Watched
|
|
291
|
+
* by title" for one watcher, "Watched by 3 sessions: a, b, c" for more (each
|
|
292
|
+
* named, as the header's list does); '' for none. Plain text, for the menu
|
|
293
|
+
* to escape.
|
|
294
|
+
*/
|
|
295
|
+
export function watchedInfoText(watcherIds, { conversations = null, subscriptions = [] } = {}) {
|
|
296
|
+
if (!watcherIds?.length) return '';
|
|
297
|
+
const names = watcherIds.map((id) => watcherName(id, conversations, subscriptions));
|
|
298
|
+
if (names.length > 1) return `Watched by ${names.length} sessions: ${names.join(', ')}`;
|
|
299
|
+
return `Watched by ${names[0]}`;
|
|
300
|
+
}
|
|
301
|
+
|
|
260
302
|
export function renderWatchedBadgeHtml(watcherIds, { conversations = null, subscriptions = [], escHtml = defaultEscape } = {}) {
|
|
261
303
|
if (!watcherIds?.length) return '';
|
|
262
304
|
const names = watcherIds.map((id) => watcherName(id, conversations, subscriptions)).join(', ');
|
|
@@ -313,7 +313,7 @@ let applyState = () => {};
|
|
|
313
313
|
let inFlight = false;
|
|
314
314
|
// What an open "now" button acts on: the action shown (a queued shutdown
|
|
315
315
|
// stays a shutdown) and the queued request it was shown for, or null from
|
|
316
|
-
// the
|
|
316
|
+
// the ︙ menu with nothing queued. The relay refuses a request that is gone.
|
|
317
317
|
let nowTarget = { action: 'restart', requestedAt: null };
|
|
318
318
|
|
|
319
319
|
function targetFrom(state) {
|
|
@@ -379,7 +379,7 @@ async function loadPictureInto({ kind, title, subtitle, render }) {
|
|
|
379
379
|
renderSummaryModalContent({ title, subtitle, kind, bodyHtml: render(result || {}) });
|
|
380
380
|
}
|
|
381
381
|
|
|
382
|
-
/**
|
|
382
|
+
/** ︙ → Restart web relay: shows what the restart would wait for first. */
|
|
383
383
|
export function openRestartRelayConfirmation() {
|
|
384
384
|
nowTarget = { action: 'restart', requestedAt: null };
|
|
385
385
|
const title = 'Restart web relay';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// Relay schedules in the browser: the "
|
|
2
|
-
// sidebar row and the header's "N scheduled" chip with its list and Cancel
|
|
1
|
+
// Relay schedules in the browser: the "Scheduled" line in the ⓘ menu of a
|
|
2
|
+
// conversation's sidebar row and the header's "N scheduled" chip with its list and Cancel
|
|
3
3
|
// buttons.
|
|
4
4
|
//
|
|
5
5
|
// DOM-free on purpose, like relay-event-view.mjs: the callers own the
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
// cannot import (only server/public is served); relay-schedule-view.test.mjs
|
|
10
10
|
// holds the two together.
|
|
11
11
|
|
|
12
|
+
import { chatMetaLabelHtml } from './chat-header-meta.mjs';
|
|
13
|
+
|
|
12
14
|
export const RELAY_SCHEDULE_SOCKET_EVENT = 'relay_schedules';
|
|
13
15
|
export const RELAY_SCHEDULE_MESSAGE_KIND = 'relay-schedule';
|
|
14
16
|
|
|
@@ -76,18 +78,42 @@ export function scheduleChipText(schedules) {
|
|
|
76
78
|
return count ? `${count} scheduled` : '';
|
|
77
79
|
}
|
|
78
80
|
|
|
81
|
+
/** The phone's short chip text: the next time ("04:15"), else the count; '' for no chip. */
|
|
82
|
+
export function scheduleChipShortText(schedules, { nextText = '' } = {}) {
|
|
83
|
+
const count = activeList(schedules).length;
|
|
84
|
+
if (!count) return '';
|
|
85
|
+
return String(nextText || '').trim() || String(count);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The header's chip: a button that opens the list, with the long label
|
|
90
|
+
* ("⏰ 1 scheduled next 04:15 AM") and the phone's short one ("⏰ 04:15 AM").
|
|
91
|
+
* `nextText` is the next fire time as the caller formats it. '' for none.
|
|
92
|
+
*/
|
|
93
|
+
export function renderScheduleChipHtml(schedules, { panelOpen = false, nextText = '', escHtml = defaultEscape } = {}) {
|
|
94
|
+
const chipText = scheduleChipText(schedules);
|
|
95
|
+
if (!chipText) return '';
|
|
96
|
+
const longHtml = `⏰ ${escHtml(chipText)}${nextText ? ` <span class="relay-watch-hint">next ${escHtml(nextText)}</span>` : ''}`;
|
|
97
|
+
const shortHtml = `⏰ ${escHtml(scheduleChipShortText(schedules, { nextText }))}`;
|
|
98
|
+
return `<button type="button" class="relay-watch-chip relay-schedule-chip" aria-expanded="${panelOpen ? 'true' : 'false'}" aria-controls="relay-schedule-panel" title="Prompts the relay queues at set times (agents set them with relay_tool schedule)">${chatMetaLabelHtml(longHtml, shortHtml)}</button>`;
|
|
99
|
+
}
|
|
100
|
+
|
|
79
101
|
function conversationTitle(conversations, id, fallback = '') {
|
|
80
102
|
const title = String(conversations?.[id]?.title || fallback || '').trim();
|
|
81
103
|
return title || `session ${String(id).slice(0, 8)}`;
|
|
82
104
|
}
|
|
83
105
|
|
|
84
|
-
/**
|
|
85
|
-
|
|
106
|
+
/**
|
|
107
|
+
* The line of the sidebar row's ⓘ menu for a conversation schedules fire
|
|
108
|
+
* into: "Scheduled · next 04:15", "2 schedules · next 04:15"; '' for none.
|
|
109
|
+
* Plain text, for the menu to escape.
|
|
110
|
+
*/
|
|
111
|
+
export function scheduledInfoText(schedules, conversationId, { formatTime = defaultFormatTime } = {}) {
|
|
86
112
|
const into = schedulesInto(schedules, conversationId);
|
|
87
113
|
if (!into.length) return '';
|
|
88
114
|
const next = nextScheduledAt(into);
|
|
89
|
-
const
|
|
90
|
-
return
|
|
115
|
+
const what = into.length === 1 ? 'Scheduled' : `${into.length} schedules`;
|
|
116
|
+
return next ? `${what} · next ${formatTime(next)}` : what;
|
|
91
117
|
}
|
|
92
118
|
|
|
93
119
|
/**
|
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
// Relay schedules on the page: the header's "N scheduled" chip with its list
|
|
2
2
|
// and Cancel buttons (on a conversation a schedule fires into or that set
|
|
3
|
-
// one), and the "
|
|
4
|
-
// schedule fires into. The relay sends the whole active list on every change
|
|
3
|
+
// one), and the "Scheduled" line in the sidebar row's ⓘ menu of a
|
|
4
|
+
// conversation a schedule fires into. The relay sends the whole active list on every change
|
|
5
5
|
// (socket `relay_schedules`); the page loads it once at start.
|
|
6
6
|
|
|
7
7
|
import { apiFetch } from './api-client.js';
|
|
8
|
+
import { FONT_SCALE_CHANGE_EVENT, getUiScale } from './font-scaling.js';
|
|
9
|
+
import { isOpeningGestureClick } from './tap-actions.js';
|
|
8
10
|
import { conversations, currentConvId, escHtml, IS_SHARED_VIEW, showTransientRelayNotice } from './store.js';
|
|
9
11
|
import {
|
|
10
12
|
nextScheduledAt,
|
|
11
|
-
|
|
13
|
+
renderScheduleChipHtml,
|
|
12
14
|
renderSchedulePanelHtml,
|
|
13
15
|
scheduleChipText,
|
|
16
|
+
scheduledInfoText,
|
|
14
17
|
schedulesOf,
|
|
15
18
|
} from './relay-schedule-view.mjs';
|
|
16
19
|
|
|
@@ -18,8 +21,36 @@ let schedules = [];
|
|
|
18
21
|
let renderedChipKey = '';
|
|
19
22
|
let panelOpen = false;
|
|
20
23
|
let panelConversationId = '';
|
|
24
|
+
// What the open list hangs from when the chip has no box (phone landscape
|
|
25
|
+
// hides the meta line): the ︙ button its menu entry was picked from.
|
|
26
|
+
let panelAnchor = null;
|
|
27
|
+
// When the list last opened: the touch that opened it from ︙ still has its
|
|
28
|
+
// click to come.
|
|
29
|
+
let panelOpenedAt = 0;
|
|
21
30
|
const changeListeners = new Set();
|
|
22
31
|
|
|
32
|
+
/** The schedule chip's text for a conversation ("2 scheduled"), or '' when it has none. */
|
|
33
|
+
export function relayScheduleChipText(conversationId) {
|
|
34
|
+
const id = String(conversationId || '').trim();
|
|
35
|
+
if (IS_SHARED_VIEW || !id) return '';
|
|
36
|
+
return scheduleChipText(schedulesOf(schedules, id));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Opens the schedule chip's list, as a tap on the chip does, from elsewhere
|
|
41
|
+
* (the ︙ entry in phone landscape). `anchor` places it while the chip is
|
|
42
|
+
* not drawn.
|
|
43
|
+
*/
|
|
44
|
+
export function openRelaySchedulePanel({ anchor = null } = {}) {
|
|
45
|
+
ensurePanel();
|
|
46
|
+
setPanelOpen(true, { anchor });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Closes the schedule chip's list (back closes it before anything else). */
|
|
50
|
+
export function closeRelaySchedulePanel() {
|
|
51
|
+
if (panelOpen) setPanelOpen(false);
|
|
52
|
+
}
|
|
53
|
+
|
|
23
54
|
/** Runs after every change of the list (the sidebar re-renders its markers). */
|
|
24
55
|
export function onRelaySchedulesChanged(listener) {
|
|
25
56
|
changeListeners.add(listener);
|
|
@@ -40,10 +71,10 @@ export async function loadRelaySchedules() {
|
|
|
40
71
|
if (response && Array.isArray(response.schedules)) applyRelaySchedules(response.schedules);
|
|
41
72
|
}
|
|
42
73
|
|
|
43
|
-
/** The sidebar's
|
|
44
|
-
export function
|
|
74
|
+
/** The line of the sidebar row's ⓘ menu for one conversation, or ''. */
|
|
75
|
+
export function relayScheduleInfoText(conversationId) {
|
|
45
76
|
if (IS_SHARED_VIEW) return '';
|
|
46
|
-
return
|
|
77
|
+
return scheduledInfoText(schedules, conversationId);
|
|
47
78
|
}
|
|
48
79
|
|
|
49
80
|
async function cancelSchedule(scheduleId, button) {
|
|
@@ -60,7 +91,8 @@ async function cancelSchedule(scheduleId, button) {
|
|
|
60
91
|
}
|
|
61
92
|
|
|
62
93
|
// A fixed popover on <body>, placed under the chip, like the subscriptions'
|
|
63
|
-
// list: the title slot clips its overflow.
|
|
94
|
+
// list: the title slot clips its overflow. Opened from ︙ (phone landscape,
|
|
95
|
+
// no chip drawn), it hangs from ︙ instead.
|
|
64
96
|
function ensurePanel() {
|
|
65
97
|
let panel = document.getElementById('relay-schedule-panel');
|
|
66
98
|
if (panel) return panel;
|
|
@@ -76,50 +108,67 @@ function ensurePanel() {
|
|
|
76
108
|
void cancelSchedule(cancelButton.getAttribute('data-schedule-id'), cancelButton);
|
|
77
109
|
});
|
|
78
110
|
document.addEventListener('click', (event) => {
|
|
79
|
-
if (!panelOpen) return;
|
|
111
|
+
if (!panelOpen || isOpeningGestureClick(event, panelOpenedAt)) return;
|
|
80
112
|
// composedPath: a Cancel click may have re-rendered the panel already.
|
|
81
113
|
const path = event.composedPath();
|
|
82
|
-
if (path.includes(panel) || path.some((node) => node instanceof Element && node.classList.contains('relay-schedule-chip'))) return;
|
|
114
|
+
if (path.includes(panel) || (panelAnchor && path.includes(panelAnchor)) || path.some((node) => node instanceof Element && node.classList.contains('relay-schedule-chip'))) return;
|
|
83
115
|
setPanelOpen(false);
|
|
84
116
|
});
|
|
85
117
|
document.addEventListener('keydown', (event) => {
|
|
86
118
|
if (!panelOpen || event.key !== 'Escape') return;
|
|
87
119
|
event.stopPropagation();
|
|
120
|
+
const anchor = panelAnchor;
|
|
88
121
|
setPanelOpen(false);
|
|
89
|
-
document.querySelector('#chat-title-relay-schedules .relay-schedule-chip')
|
|
122
|
+
const chip = document.querySelector('#chat-title-relay-schedules .relay-schedule-chip');
|
|
123
|
+
(chip?.getClientRects?.().length ? chip : anchor || chip)?.focus?.();
|
|
90
124
|
}, true);
|
|
91
125
|
window.addEventListener('resize', () => { if (panelOpen) placePanel(); });
|
|
126
|
+
window.addEventListener(FONT_SCALE_CHANGE_EVENT, () => { if (panelOpen) placePanel(); });
|
|
92
127
|
return panel;
|
|
93
128
|
}
|
|
94
129
|
|
|
95
|
-
function setPanelOpen(open) {
|
|
130
|
+
function setPanelOpen(open, { anchor = null } = {}) {
|
|
131
|
+
if (open && !panelOpen) panelOpenedAt = Date.now();
|
|
96
132
|
panelOpen = Boolean(open);
|
|
133
|
+
panelAnchor = panelOpen ? anchor : null;
|
|
97
134
|
renderedChipKey = '';
|
|
98
135
|
syncRelayScheduleHeader();
|
|
99
136
|
}
|
|
100
137
|
|
|
138
|
+
// A box to place against, or null: an element that is not drawn has none.
|
|
139
|
+
function drawnRect(element) {
|
|
140
|
+
const rect = element?.getBoundingClientRect?.();
|
|
141
|
+
return rect && (rect.width || rect.height) ? rect : null;
|
|
142
|
+
}
|
|
143
|
+
|
|
101
144
|
function placePanel() {
|
|
102
145
|
const panel = document.getElementById('relay-schedule-panel');
|
|
103
146
|
const chip = document.querySelector('#chat-title-relay-schedules .relay-schedule-chip');
|
|
104
147
|
if (!panel || panel.hidden) return;
|
|
105
|
-
|
|
106
|
-
|
|
148
|
+
// Under the chip; with no chip drawn, under the anchor, right edges lined up.
|
|
149
|
+
const chipRect = drawnRect(chip);
|
|
150
|
+
const rect = chipRect || drawnRect(panelAnchor);
|
|
151
|
+
if (!rect) {
|
|
107
152
|
panel.hidden = true;
|
|
108
153
|
return;
|
|
109
154
|
}
|
|
110
|
-
|
|
155
|
+
// 8px edge, 4px gap and a 120px floor at 100 %, scaled with the zoom.
|
|
156
|
+
const scale = getUiScale();
|
|
157
|
+
const margin = 8 * scale;
|
|
111
158
|
const viewportWidth = document.documentElement.clientWidth || window.innerWidth;
|
|
112
|
-
const top = Math.round(rect.bottom + 4);
|
|
159
|
+
const top = Math.round(rect.bottom + 4 * scale);
|
|
113
160
|
panel.style.top = `${top}px`;
|
|
114
|
-
panel.style.maxHeight = `${Math.max(120, window.innerHeight - top - margin)}px`;
|
|
161
|
+
panel.style.maxHeight = `${Math.round(Math.max(120 * scale, window.innerHeight - top - margin))}px`;
|
|
115
162
|
panel.style.left = '0px';
|
|
116
163
|
const width = panel.offsetWidth;
|
|
117
|
-
const
|
|
164
|
+
const preferred = chipRect ? rect.left : rect.right - width;
|
|
165
|
+
const left = Math.max(margin, Math.min(preferred, viewportWidth - width - margin));
|
|
118
166
|
panel.style.left = `${Math.round(left)}px`;
|
|
119
167
|
}
|
|
120
168
|
|
|
121
169
|
function ensureHeaderElements() {
|
|
122
|
-
|
|
170
|
+
// Beside the other meta lines in #chat-title-meta; the slot is the fallback.
|
|
171
|
+
const slot = document.getElementById('chat-title-meta') || document.getElementById('chat-title-slot');
|
|
123
172
|
if (!slot) return null;
|
|
124
173
|
let line = document.getElementById('chat-title-relay-schedules');
|
|
125
174
|
if (!line) {
|
|
@@ -127,7 +176,7 @@ function ensureHeaderElements() {
|
|
|
127
176
|
line.id = 'chat-title-relay-schedules';
|
|
128
177
|
line.hidden = true;
|
|
129
178
|
const after = document.getElementById('chat-title-relay-events') || document.getElementById('chat-title-origin');
|
|
130
|
-
if (after?.nextSibling) slot.insertBefore(line, after.nextSibling);
|
|
179
|
+
if (after?.parentNode === slot && after.nextSibling) slot.insertBefore(line, after.nextSibling);
|
|
131
180
|
else slot.appendChild(line);
|
|
132
181
|
line.addEventListener('click', (event) => {
|
|
133
182
|
const target = event.target instanceof Element ? event.target : null;
|
|
@@ -164,7 +213,7 @@ export function syncRelayScheduleHeader() {
|
|
|
164
213
|
}
|
|
165
214
|
const next = nextScheduledAt(mine);
|
|
166
215
|
const nextText = next ? new Date(next).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
|
|
167
|
-
line.innerHTML =
|
|
216
|
+
line.innerHTML = renderScheduleChipHtml(mine, { panelOpen, nextText, escHtml });
|
|
168
217
|
line.hidden = false;
|
|
169
218
|
panel.innerHTML = panelOpen ? renderSchedulePanelHtml(schedules, id, { conversations, escHtml }) : '';
|
|
170
219
|
panel.hidden = !panelOpen;
|