@oar-sh/oar 0.9.20 → 0.9.22
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 +325 -0
- package/README.md +13 -13
- package/bin/oar.js +2 -2
- package/package.json +1 -1
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +231 -1
- package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +31 -2
- package/server/claude-cloud-worker/claude-cloud-worker-steering.mjs +34 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +149 -4
- package/server/claude-worker/claude-session-process.mjs +16 -4
- package/server/claude-worker/claude-session-worker-main.mjs +18 -2
- package/server/copilot-worker/copilot-sdk-session-process.mjs +75 -1
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +18 -2
- package/server/cursor-worker/cursor-session-worker.mjs +21 -2
- package/server/cursor-worker/cursor-worker-steering.mjs +20 -0
- package/server/db-schema.mjs +19 -0
- package/server/grok-worker/grok-background-tasks.mjs +10 -0
- package/server/grok-worker/grok-session-worker.mjs +22 -2
- package/server/grok-worker/grok-turn-runner.mjs +4 -0
- package/server/grok-worker/grok-worker-steering.mjs +23 -0
- package/server/migrations/0037-relay-asks-queue-id.mjs +66 -0
- package/server/migrations/0038-turn-seq.mjs +92 -0
- package/server/public/app/about-ui.js +1 -1
- package/server/public/app/about-view.mjs +19 -0
- package/server/public/app/action-confirmations.js +1 -1
- package/server/public/app/activity-replay-state.mjs +160 -42
- package/server/public/app/api-client.js +11 -2
- package/server/public/app/ask-user-view.js +24 -9
- package/server/public/app/attachments-view.js +1 -1
- package/server/public/app/back-guard.mjs +31 -5
- package/server/public/app/bootstrap.js +618 -72
- package/server/public/app/chat-actions-menu-state.mjs +141 -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-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 +68 -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 +309 -49
- 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 +238 -0
- package/server/public/app/held-details.mjs +202 -0
- package/server/public/app/hidden-messages-view.mjs +4 -0
- package/server/public/app/inbox-ui.js +88 -81
- package/server/public/app/inbox-view.mjs +35 -19
- package/server/public/app/journal-view.js +328 -58
- package/server/public/app/live-activity-cap.mjs +103 -0
- 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/push-settings.js +2 -2
- package/server/public/app/pwa-install.js +24 -3
- package/server/public/app/questions-panel-ui.js +634 -0
- package/server/public/app/questions-stub-view.mjs +40 -0
- package/server/public/app/questions-view.mjs +89 -0
- package/server/public/app/relay-board-view.js +12 -2
- package/server/public/app/relay-event-subscriptions-ui.js +71 -22
- package/server/public/app/relay-event-view.mjs +38 -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/relay-thoughts.mjs +15 -1
- package/server/public/app/remote-relay-origin-view.mjs +41 -23
- package/server/public/app/session-history-ui.js +251 -0
- package/server/public/app/session-history.mjs +599 -0
- package/server/public/app/settings-modal.js +67 -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 +47 -13
- package/server/public/app/status-view.mjs +18 -2
- package/server/public/app/store.js +87 -16
- package/server/public/app/stream-view-preference.mjs +60 -0
- package/server/public/app/subagent-fold.mjs +6 -3
- package/server/public/app/subagent-timeline-view.mjs +12 -5
- package/server/public/app/subagent-viewer.mjs +35 -8
- package/server/public/app/tab-strip-fade.mjs +101 -0
- package/server/public/app/tap-actions.js +66 -0
- package/server/public/app/thoughts-preference.mjs +79 -0
- package/server/public/app/turn-stream-view.mjs +124 -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 +2603 -1767
- package/server/relay-tools.md +1 -0
- package/server/repositories/message-repository.mjs +33 -0
- package/server/repositories/question-repository.mjs +51 -7
- package/server/repositories/relay-turn-rows.mjs +56 -0
- package/server/repositories/session-repository.mjs +19 -0
- package/server/routes/ask-user-routes.mjs +34 -5
- package/server/routes/claude-cloud-routes.mjs +19 -0
- package/server/routes/messages-routes.mjs +92 -15
- package/server/routes/relay-ask-routes.mjs +15 -5
- package/server/routes/sessions-routes.mjs +117 -12
- package/server/server-runtime.mjs +367 -170
- package/server/services/about-service.mjs +22 -53
- package/server/services/claude-billing-swap-service.mjs +7 -20
- package/server/services/claude-cloud-session-service.mjs +47 -5
- package/server/services/cloudflared-tunnel-service.mjs +7 -0
- package/server/services/idle-timers.mjs +184 -0
- package/server/services/queue-wake-hook.mjs +145 -0
- package/server/services/queue-waker.mjs +127 -0
- package/server/services/relay-ask-service.mjs +64 -5
- package/server/services/relay-event-service.mjs +4 -1
- package/server/services/remote-relay-dispatcher.mjs +84 -18
- package/server/services/session-activity-tracker.mjs +1 -1
- package/server/services/session-history-refresh-service.mjs +4 -0
- package/server/services/session-launch-mutex-service.mjs +23 -0
- package/server/services/session-worker-registry-service.mjs +36 -0
- package/server/services/session-worker-supervisor-service.mjs +36 -0
- package/server/services/session-worker-websocket-service.mjs +183 -31
- package/server/services/ssh-tunnel-manager-service.mjs +7 -0
- package/server/services/worker-recycle-service.mjs +772 -0
- package/server/services/worker-replacement.mjs +197 -0
- package/shared/ask-user-bridge.mjs +9 -6
- package/shared/build-id.mjs +128 -0
- package/shared/relay-asks.mjs +10 -0
- package/shared/relay-events.mjs +1 -0
- package/shared/worker-runtime/heartbeat.mjs +31 -0
- package/shared/worker-runtime/simple-steering-state.mjs +38 -0
- package/shared/worker-runtime/worker-keepalive.mjs +108 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +44 -10
- package/shared/worker-runtime/working-directory.mjs +1 -1
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// 📓 Questions of one conversation: the order the panel lists them in, the
|
|
2
|
+
// header icon's count and its labels. Pure: no DOM, no fetch.
|
|
3
|
+
//
|
|
4
|
+
// An ask (relay-ask-service.mjs shape) is pending, or closed: answered,
|
|
5
|
+
// dismissed, withdrawn or cancelled. Pending asks come first, the urgent ones
|
|
6
|
+
// (`urgency: 'now'`) ahead of the rest, each group newest first; then those
|
|
7
|
+
// closed in the last day, the latest close first.
|
|
8
|
+
|
|
9
|
+
export const QUESTIONS_CLOSED_KEEP_MS = 24 * 60 * 60_000;
|
|
10
|
+
|
|
11
|
+
function toText(value) {
|
|
12
|
+
return String(value ?? '').trim();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function timeOf(value) {
|
|
16
|
+
const ms = Date.parse(toText(value));
|
|
17
|
+
return Number.isFinite(ms) ? ms : 0;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function newestFirst(a, b) {
|
|
21
|
+
return timeOf(b.createdAt) - timeOf(a.createdAt) || toText(b.id).localeCompare(toText(a.id));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function closedAt(ask) {
|
|
25
|
+
return timeOf(ask.answeredAt) || timeOf(ask.updatedAt);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function isUrgentAsk(ask) {
|
|
29
|
+
return ask?.urgency === 'now';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The asks of `conversationId`, split and ordered for the panel:
|
|
34
|
+
* `{ pending, urgent, answered }` where `pending` lists every pending ask
|
|
35
|
+
* (urgent ones first), `urgent` counts them and `answered` holds the asks
|
|
36
|
+
* closed within the last day.
|
|
37
|
+
*/
|
|
38
|
+
export function questionsOfConversation(asks, conversationId, now = Date.now()) {
|
|
39
|
+
const id = toText(conversationId);
|
|
40
|
+
const mine = (Array.isArray(asks) ? asks : [])
|
|
41
|
+
.filter((ask) => ask && !ask.relayId && id && toText(ask.conversationId || ask.session?.id) === id);
|
|
42
|
+
const pending = mine.filter((ask) => ask.status === 'pending');
|
|
43
|
+
const urgentAsks = pending.filter(isUrgentAsk).sort(newestFirst);
|
|
44
|
+
const normalAsks = pending.filter((ask) => !isUrgentAsk(ask)).sort(newestFirst);
|
|
45
|
+
const since = now - QUESTIONS_CLOSED_KEEP_MS;
|
|
46
|
+
const answered = mine
|
|
47
|
+
.filter((ask) => ask.status !== 'pending' && closedAt(ask) >= since)
|
|
48
|
+
.sort((a, b) => closedAt(b) - closedAt(a) || toText(b.id).localeCompare(toText(a.id)));
|
|
49
|
+
return { pending: [...urgentAsks, ...normalAsks], urgent: urgentAsks.length, answered };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A count badge: nothing at zero; with an urgent one waiting the urgent
|
|
54
|
+
* count, marked; else the pending count.
|
|
55
|
+
*/
|
|
56
|
+
export function questionsBadge({ pending = 0, urgent = 0 } = {}) {
|
|
57
|
+
const open = Math.max(0, Number(pending) || 0);
|
|
58
|
+
const hot = Math.max(0, Number(urgent) || 0);
|
|
59
|
+
if (hot > 0) return { text: String(hot), urgent: true };
|
|
60
|
+
if (open > 0) return { text: String(open), urgent: false };
|
|
61
|
+
return { text: '', urgent: false };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The 📓 button's tooltip and label. */
|
|
65
|
+
export function questionsAria({ pending = 0, urgent = 0 } = {}) {
|
|
66
|
+
const open = Math.max(0, Number(pending) || 0);
|
|
67
|
+
const hot = Math.max(0, Number(urgent) || 0);
|
|
68
|
+
if (hot > 0) return `Questions (${hot} urgent of ${open} open)`;
|
|
69
|
+
if (open > 0) return `Questions (${open} open)`;
|
|
70
|
+
return 'Questions';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The ︙ twin's text: "📓 Questions (2 urgent)" / "(3 open)". */
|
|
74
|
+
export function questionsTwinLabel({ pending = 0, urgent = 0 } = {}) {
|
|
75
|
+
const open = Math.max(0, Number(pending) || 0);
|
|
76
|
+
const hot = Math.max(0, Number(urgent) || 0);
|
|
77
|
+
if (hot > 0) return `📓 Questions (${hot} urgent)`;
|
|
78
|
+
if (open > 0) return `📓 Questions (${open} open)`;
|
|
79
|
+
return '📓 Questions';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The panel's head line: "📓 Questions · 2 urgent · 5 open". */
|
|
83
|
+
export function questionsPanelTitle({ pending = 0, urgent = 0 } = {}) {
|
|
84
|
+
const open = Math.max(0, Number(pending) || 0);
|
|
85
|
+
const hot = Math.max(0, Number(urgent) || 0);
|
|
86
|
+
return ['📓 Questions', hot > 0 ? `${hot} urgent` : '', open > 0 ? `${open} open` : 'none open']
|
|
87
|
+
.filter(Boolean)
|
|
88
|
+
.join(' · ');
|
|
89
|
+
}
|
|
@@ -77,8 +77,18 @@ async function loadBoardHistory(conversationId) {
|
|
|
77
77
|
|
|
78
78
|
/** Where a board is shown: its turn's reply, the live bubble of its turn, or nowhere yet. */
|
|
79
79
|
function findBoardHost(el, board) {
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
return findTurnBubble(el, board?.messageId || board?.queueId);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The bubble of the turn that answers queue row `queueId`: its reply
|
|
85
|
+
* (`{ bubble, live: false }`), else the live bubble while it runs
|
|
86
|
+
* (`live: true`), else null (not loaded, or not there). Plan boards and 📓
|
|
87
|
+
* stubs both live there.
|
|
88
|
+
*/
|
|
89
|
+
export function findTurnBubble(el, queueId) {
|
|
90
|
+
const source = String(queueId || '').trim();
|
|
91
|
+
if (!el || !source) return null;
|
|
82
92
|
const replies = el.querySelectorAll(`.msg.assistant[data-source-message-id="${CSS.escape(source)}"]`);
|
|
83
93
|
// The turn's own reply is the last row that answers the message; a steer's
|
|
84
94
|
// marker above it is not a reply.
|
|
@@ -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,6 +8,8 @@
|
|
|
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';
|
|
@@ -213,6 +215,29 @@ export function watchingChipHint(summary) {
|
|
|
213
215
|
return [...new Set((summary?.subscriptions || []).flatMap(subscriptionOptionHints))].slice(0, 2).join(' · ');
|
|
214
216
|
}
|
|
215
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
|
+
|
|
216
241
|
function conversationTitle(conversations, id) {
|
|
217
242
|
const title = String(conversations?.[id]?.title || '').trim();
|
|
218
243
|
return title || `session ${String(id).slice(0, 8)}`;
|
|
@@ -261,6 +286,19 @@ function watcherName(id, conversations, subscriptions) {
|
|
|
261
286
|
return `${title} on ${relay}`;
|
|
262
287
|
}
|
|
263
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
|
+
|
|
264
302
|
export function renderWatchedBadgeHtml(watcherIds, { conversations = null, subscriptions = [], escHtml = defaultEscape } = {}) {
|
|
265
303
|
if (!watcherIds?.length) return '';
|
|
266
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;
|