@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
|
@@ -19,11 +19,13 @@ import {
|
|
|
19
19
|
resolveCwdMenuPlacement,
|
|
20
20
|
resolveTypeaheadIndex,
|
|
21
21
|
} from './cwd-menu-placement.mjs';
|
|
22
|
+
import { FONT_SCALE_CHANGE_EVENT, getUiScale } from './font-scaling.js';
|
|
22
23
|
import {
|
|
23
24
|
buildKnownCwdOptions as buildKnownCwdOptionsFromInputs,
|
|
24
25
|
normalizeKnownCwdPath,
|
|
25
26
|
} from './known-cwd-options.mjs';
|
|
26
27
|
import { buildCloudLineModel, cloudHeaderLabel, isClaudeCloudConversation } from './claude-cloud-ui.mjs';
|
|
28
|
+
import { CHAT_META_LONG_CLASS, CHAT_META_SHORT_CLASS, cwdFolderName } from './chat-header-meta.mjs';
|
|
27
29
|
|
|
28
30
|
export { normalizeKnownCwdPath };
|
|
29
31
|
|
|
@@ -163,6 +165,7 @@ function positionChangeCwdMenu() {
|
|
|
163
165
|
viewportWidth,
|
|
164
166
|
viewportHeight,
|
|
165
167
|
panelHeight: panel.scrollHeight,
|
|
168
|
+
scale: getUiScale(),
|
|
166
169
|
});
|
|
167
170
|
panel.style.left = `${Math.round(placement.left)}px`;
|
|
168
171
|
panel.style.top = `${Math.round(placement.top)}px`;
|
|
@@ -176,6 +179,8 @@ function bindRepositionListeners() {
|
|
|
176
179
|
// Capture phase so scrolling inside .summary-body (which does not bubble) counts.
|
|
177
180
|
window.addEventListener('scroll', positionChangeCwdMenu, true);
|
|
178
181
|
window.addEventListener('resize', positionChangeCwdMenu);
|
|
182
|
+
// A zoom moves the trigger and rescales the gap and the panel's sizes.
|
|
183
|
+
window.addEventListener(FONT_SCALE_CHANGE_EVENT, positionChangeCwdMenu);
|
|
179
184
|
window.visualViewport?.addEventListener?.('resize', positionChangeCwdMenu);
|
|
180
185
|
window.visualViewport?.addEventListener?.('scroll', positionChangeCwdMenu);
|
|
181
186
|
}
|
|
@@ -185,6 +190,7 @@ function unbindRepositionListeners() {
|
|
|
185
190
|
repositionBound = false;
|
|
186
191
|
window.removeEventListener('scroll', positionChangeCwdMenu, true);
|
|
187
192
|
window.removeEventListener('resize', positionChangeCwdMenu);
|
|
193
|
+
window.removeEventListener(FONT_SCALE_CHANGE_EVENT, positionChangeCwdMenu);
|
|
188
194
|
window.visualViewport?.removeEventListener?.('resize', positionChangeCwdMenu);
|
|
189
195
|
window.visualViewport?.removeEventListener?.('scroll', positionChangeCwdMenu);
|
|
190
196
|
}
|
|
@@ -231,7 +237,7 @@ function openChangeCwdMenu() {
|
|
|
231
237
|
bindRepositionListeners();
|
|
232
238
|
}
|
|
233
239
|
|
|
234
|
-
function closeChangeCwdMenu({ focusTrigger = false } = {}) {
|
|
240
|
+
export function closeChangeCwdMenu({ focusTrigger = false } = {}) {
|
|
235
241
|
const { trigger, panel, backdrop } = getPickerEls();
|
|
236
242
|
menuOpen = false;
|
|
237
243
|
typeaheadBuffer = '';
|
|
@@ -470,6 +476,7 @@ export function syncChatHeaderWorkspaceLabel() {
|
|
|
470
476
|
const label = cloudHeaderLabel(conversation.cloud);
|
|
471
477
|
labelEl.hidden = !label;
|
|
472
478
|
labelEl.textContent = label;
|
|
479
|
+
labelEl.classList.remove('chat-title-cwd-expanded');
|
|
473
480
|
if (label) labelEl.title = buildCloudLineModel(conversation.cloud)?.repoUrl || label;
|
|
474
481
|
else labelEl.removeAttribute('title');
|
|
475
482
|
return;
|
|
@@ -479,11 +486,34 @@ export function syncChatHeaderWorkspaceLabel() {
|
|
|
479
486
|
labelEl.hidden = true;
|
|
480
487
|
labelEl.textContent = '';
|
|
481
488
|
labelEl.removeAttribute('title');
|
|
489
|
+
labelEl.classList.remove('chat-title-cwd-expanded');
|
|
482
490
|
return;
|
|
483
491
|
}
|
|
484
492
|
labelEl.hidden = false;
|
|
485
|
-
labelEl.textContent = cwd;
|
|
486
493
|
labelEl.title = cwd;
|
|
494
|
+
// The whole path, and its last folder for the phone's meta row
|
|
495
|
+
// (chat-header-meta.mjs); a tap there shows the whole path.
|
|
496
|
+
const longEl = labelEl.querySelector(`.${CHAT_META_LONG_CLASS}`);
|
|
497
|
+
const shortEl = labelEl.querySelector(`.${CHAT_META_SHORT_CLASS}`);
|
|
498
|
+
const folder = cwdFolderName(cwd) || cwd;
|
|
499
|
+
if (longEl && shortEl && longEl.textContent === cwd && shortEl.textContent === folder) return;
|
|
500
|
+
if (longEl?.textContent !== cwd) labelEl.classList.remove('chat-title-cwd-expanded');
|
|
501
|
+
const long = document.createElement('span');
|
|
502
|
+
long.className = CHAT_META_LONG_CLASS;
|
|
503
|
+
long.textContent = cwd;
|
|
504
|
+
const short = document.createElement('span');
|
|
505
|
+
short.className = CHAT_META_SHORT_CLASS;
|
|
506
|
+
short.textContent = folder;
|
|
507
|
+
labelEl.replaceChildren(long, short);
|
|
508
|
+
if (labelEl.dataset.cwdToggleBound !== '1') {
|
|
509
|
+
labelEl.dataset.cwdToggleBound = '1';
|
|
510
|
+
labelEl.addEventListener('click', () => {
|
|
511
|
+
// Only the two-label folder line toggles (not a cloud repo or the
|
|
512
|
+
// status view's text); on a desktop the class changes nothing.
|
|
513
|
+
if (!labelEl.querySelector(`.${CHAT_META_SHORT_CLASS}`)) return;
|
|
514
|
+
labelEl.classList.toggle('chat-title-cwd-expanded');
|
|
515
|
+
});
|
|
516
|
+
}
|
|
487
517
|
}
|
|
488
518
|
|
|
489
519
|
function getCurrentLaunchableSessionId() {
|
|
@@ -513,14 +543,14 @@ export function openChangeCwdModal() {
|
|
|
513
543
|
subtitle: 'Select a known launch directory',
|
|
514
544
|
kind: 'change-cwd',
|
|
515
545
|
bodyHtml: `
|
|
516
|
-
<p style="margin-bottom:
|
|
546
|
+
<p style="margin-bottom:calc(5px * var(--ui-scale));color:var(--muted);line-height:1.45">
|
|
517
547
|
Pick the selected session's launch directory. An idle CLI restarts in the selected CWD; active turns must finish first.
|
|
518
548
|
</p>
|
|
519
|
-
<div style="display:grid;gap:4px;margin-bottom:
|
|
549
|
+
<div style="display:grid;gap:calc(4px * var(--ui-scale));margin-bottom:calc(5px * var(--ui-scale));font-size:0.78rem;color:var(--muted)">
|
|
520
550
|
<div><strong style="color:var(--text)">Current CWD:</strong> ${escHtml(currentCwd || 'Unknown')}</div>
|
|
521
551
|
<div><strong style="color:var(--text)">Next launch:</strong> ${escHtml(nextLaunchCwd || currentCwd || 'Unknown')}</div>
|
|
522
552
|
</div>
|
|
523
|
-
<div class="change-cwd-picker" style="margin-bottom:
|
|
553
|
+
<div class="change-cwd-picker" style="margin-bottom:calc(5px * var(--ui-scale))">
|
|
524
554
|
<label class="change-cwd-picker-label" for="change-cwd-manual-path">Manual path</label>
|
|
525
555
|
<div class="cwd-browse-row">
|
|
526
556
|
<input id="change-cwd-manual-path" class="change-cwd-manual-input" type="text" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" placeholder="Manual path">
|
|
@@ -539,7 +569,7 @@ export function openChangeCwdModal() {
|
|
|
539
569
|
${menuItemsHtml}
|
|
540
570
|
</div>
|
|
541
571
|
</div>
|
|
542
|
-
<div id="change-cwd-details" style="margin-top:
|
|
572
|
+
<div id="change-cwd-details" style="margin-top:calc(5px * var(--ui-scale));font-size:0.78rem;color:var(--muted);line-height:1.45;word-break:break-word"></div>
|
|
543
573
|
<div class="summary-modal-actions" id="change-cwd-actions">
|
|
544
574
|
<button class="summary-btn" type="button" onclick="confirmChangeCwd()">🗂️ Save next-launch CWD</button>
|
|
545
575
|
<button class="summary-btn" type="button" ${launchableSessionId ? 'onclick="confirmChangeCwdAndLaunch()"' : 'disabled data-keep-disabled="1"'} title="${escHtml(launchDisabledReason || 'Set the CWD and (re)launch the current session worker')}">🚀 Set new CWD and (re)launch</button>
|
|
@@ -23,7 +23,7 @@ function updateEmojiButton() {
|
|
|
23
23
|
button.textContent = getLastEmoji();
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
function closeEmojiPicker() {
|
|
26
|
+
export function closeEmojiPicker() {
|
|
27
27
|
const popup = document.getElementById('emoji-popup');
|
|
28
28
|
if (!popup) return;
|
|
29
29
|
popup.classList.remove('visible');
|
|
@@ -8,6 +8,9 @@ const FONT_SCALE_WHEEL_STEP_MIN = 0.01;
|
|
|
8
8
|
const FONT_SCALE_WHEEL_STEP_MAX = 0.2;
|
|
9
9
|
const FONT_SCALE_PILL_VISIBLE_MS = 900;
|
|
10
10
|
|
|
11
|
+
/** Fired on window after --font-scale changed; detail: { scale }. */
|
|
12
|
+
export const FONT_SCALE_CHANGE_EVENT = 'oar:font-scale-change';
|
|
13
|
+
|
|
11
14
|
let fontScaleValue = FONT_SCALE_DEFAULT;
|
|
12
15
|
let fontScaleIndicatorTimer = null;
|
|
13
16
|
let fontScalePinchState = {
|
|
@@ -46,6 +49,25 @@ function readStoredFontScale() {
|
|
|
46
49
|
return clampFontScale(readLocalStorage(FONT_SCALE_STORAGE_KEY));
|
|
47
50
|
}
|
|
48
51
|
|
|
52
|
+
/**
|
|
53
|
+
* The factor the stylesheet multiplies its px sizes by (--ui-scale, which
|
|
54
|
+
* follows --font-scale). JS that places popovers with px gaps and margins
|
|
55
|
+
* of its own multiplies them by this, so they match the scaled CSS. Read
|
|
56
|
+
* per call: the inline value (set before first paint and on every change),
|
|
57
|
+
* else the computed one; 1 outside a browser.
|
|
58
|
+
*/
|
|
59
|
+
export function getUiScale() {
|
|
60
|
+
if (typeof document === 'undefined' || !document.documentElement) return FONT_SCALE_DEFAULT;
|
|
61
|
+
let raw = '';
|
|
62
|
+
try { raw = document.documentElement.style.getPropertyValue('--font-scale'); } catch {}
|
|
63
|
+
if (!String(raw || '').trim() && typeof getComputedStyle === 'function') {
|
|
64
|
+
try { raw = getComputedStyle(document.documentElement).getPropertyValue('--font-scale'); } catch {}
|
|
65
|
+
}
|
|
66
|
+
const numeric = Number(String(raw || '').trim());
|
|
67
|
+
if (!Number.isFinite(numeric) || numeric <= 0) return FONT_SCALE_DEFAULT;
|
|
68
|
+
return clampFontScale(numeric);
|
|
69
|
+
}
|
|
70
|
+
|
|
49
71
|
function normalizeFontScaleSelectValue(raw = '') {
|
|
50
72
|
const text = String(raw || '').trim();
|
|
51
73
|
if (!text) return null;
|
|
@@ -90,6 +112,10 @@ export function setFontScale(nextScale, { persist = true, preserveMessageAnchor
|
|
|
90
112
|
document.documentElement.style.setProperty('--font-scale-percent', `${Math.round(normalized * 100)}%`);
|
|
91
113
|
if (persist) writeLocalStorage(FONT_SCALE_STORAGE_KEY, String(normalized));
|
|
92
114
|
syncFontScaleSelect();
|
|
115
|
+
// Layout that measures itself (the header's priority+ actions) re-runs.
|
|
116
|
+
if (typeof window !== 'undefined' && typeof window.dispatchEvent === 'function' && typeof CustomEvent === 'function') {
|
|
117
|
+
window.dispatchEvent(new CustomEvent(FONT_SCALE_CHANGE_EVENT, { detail: { scale: normalized } }));
|
|
118
|
+
}
|
|
93
119
|
if (anchor) {
|
|
94
120
|
requestAnimationFrame(() => {
|
|
95
121
|
restoreTranscriptAnchor(document.getElementById('messages'), anchor);
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// The chat header's priority+ actions: which icon buttons sit in the header
|
|
2
|
+
// and which fall back into the ︙ menu when the header runs out of room.
|
|
3
|
+
//
|
|
4
|
+
// Every action exists twice in the markup: an inline header button and a twin
|
|
5
|
+
// entry in #chat-actions-menu. Nothing moves in the DOM; a pass toggles
|
|
6
|
+
// `hidden` on the pair, so listeners are bound once and the menu keeps its
|
|
7
|
+
// order. This module is DOM-free: bootstrap.js measures, calls
|
|
8
|
+
// fitHeaderPriorityActions and writes the result.
|
|
9
|
+
//
|
|
10
|
+
// Order is priority: the first entry is the LAST to overflow. Once one action
|
|
11
|
+
// does not fit, it and every later one go into the menu, so a lower priority
|
|
12
|
+
// action never "jumps" a higher one back into the header.
|
|
13
|
+
|
|
14
|
+
export const HEADER_PRIORITY_ACTIONS = Object.freeze([
|
|
15
|
+
Object.freeze({ key: 'questions', inlineId: 'questions-btn', menuId: 'chat-menu-questions' }),
|
|
16
|
+
Object.freeze({ key: 'pins', inlineId: 'pinned-messages-btn', menuId: 'chat-menu-pins' }),
|
|
17
|
+
Object.freeze({ key: 'hidden', inlineId: 'hidden-messages-btn', menuId: 'chat-menu-hidden' }),
|
|
18
|
+
Object.freeze({ key: 'search', inlineId: 'message-search-btn', menuId: 'chat-menu-search' }),
|
|
19
|
+
Object.freeze({ key: 'context', inlineId: 'context-btn', menuId: 'chat-menu-context' }),
|
|
20
|
+
Object.freeze({ key: 'usage', inlineId: 'usage-btn', menuId: 'chat-menu-usage' }),
|
|
21
|
+
Object.freeze({ key: 'theme', inlineId: 'theme-toggle-btn', menuId: 'chat-menu-theme-toggle' }),
|
|
22
|
+
Object.freeze({ key: 'fullscreen', inlineId: 'fullscreen-btn', menuId: 'chat-menu-fullscreen' }),
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
export const HEADER_PRIORITY_ACTION_KEYS = Object.freeze(HEADER_PRIORITY_ACTIONS.map((action) => action.key));
|
|
26
|
+
|
|
27
|
+
/** The twin menu entries, in priority order. */
|
|
28
|
+
export const HEADER_PRIORITY_MENU_IDS = Object.freeze(HEADER_PRIORITY_ACTIONS.map((action) => action.menuId));
|
|
29
|
+
|
|
30
|
+
/** The width the title keeps before any action is shown inline, in rem. */
|
|
31
|
+
export const HEADER_TITLE_MIN_REM = 8;
|
|
32
|
+
|
|
33
|
+
/** The window event that asks for a new pass (counts, availability). */
|
|
34
|
+
export const HEADER_ACTIONS_CHANGE_EVENT = 'oar:header-actions-change';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Where the pending-question banner has a row of its own (the phone's header
|
|
38
|
+
* CSS): there it takes nothing from the first row.
|
|
39
|
+
*/
|
|
40
|
+
export const HEADER_BANNER_OWN_ROW_QUERY = '(max-width: 680px)';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The share of the header's content width the question banner may take on
|
|
44
|
+
* the first row (its `max-width: 40%` in index.html): the title keeps the rest.
|
|
45
|
+
*/
|
|
46
|
+
export const HEADER_BANNER_MAX_SHARE = 0.4;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The width of everything on the header's first row that never overflows:
|
|
50
|
+
* its padding, ☰, the title's minimum, ︙, the question banner and the flex
|
|
51
|
+
* gaps between them. Widths of 0 are elements that do not show. The banner
|
|
52
|
+
* counts only while it shares the first row (`bannerOwnRow` false), and only
|
|
53
|
+
* with its compact width ("❓ N"): the rest of it gives way before an action
|
|
54
|
+
* does (resolveQuestionBannerMode).
|
|
55
|
+
*
|
|
56
|
+
* @param {object} input
|
|
57
|
+
* @param {number} [input.padding] left plus right padding
|
|
58
|
+
* @param {number} [input.titleMin] the width the title keeps
|
|
59
|
+
* @param {number} [input.toggle] ☰
|
|
60
|
+
* @param {number} [input.banner] the question banner's compact chip
|
|
61
|
+
* @param {number} [input.menu] ︙ (its wrapper)
|
|
62
|
+
* @param {number} [input.gap] the header's column gap
|
|
63
|
+
* @param {boolean} [input.bannerOwnRow] the banner sits on a row of its own
|
|
64
|
+
* @returns {number}
|
|
65
|
+
*/
|
|
66
|
+
export function headerReservedWidth({
|
|
67
|
+
padding = 0,
|
|
68
|
+
titleMin = 0,
|
|
69
|
+
toggle = 0,
|
|
70
|
+
banner = 0,
|
|
71
|
+
menu = 0,
|
|
72
|
+
gap = 0,
|
|
73
|
+
bannerOwnRow = false,
|
|
74
|
+
} = {}) {
|
|
75
|
+
const size = (value) => (Number.isFinite(Number(value)) && Number(value) > 0 ? Number(value) : 0);
|
|
76
|
+
let width = size(padding) + size(titleMin) + size(menu);
|
|
77
|
+
let items = 2; // the title and #chat-header-actions
|
|
78
|
+
if (size(toggle)) {
|
|
79
|
+
width += size(toggle);
|
|
80
|
+
items += 1;
|
|
81
|
+
}
|
|
82
|
+
if (size(banner) && !bannerOwnRow) {
|
|
83
|
+
width += size(banner);
|
|
84
|
+
items += 1;
|
|
85
|
+
}
|
|
86
|
+
return width + size(gap) * (items - 1);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* How the question banner shows once the actions have their places:
|
|
91
|
+
* - 'hidden': no banner;
|
|
92
|
+
* - 'row': a row of its own (a phone held upright, or a header whose first
|
|
93
|
+
* row cannot hold even the chip next to ☰, the title's minimum and ︙),
|
|
94
|
+
* the whole text;
|
|
95
|
+
* - 'compact': the chip "❓ N" on the first row (always on a phone on its
|
|
96
|
+
* side; elsewhere where the room cannot even hold "❓ N open questions");
|
|
97
|
+
* - 'full': the whole text on the first row, ending in an ellipsis where
|
|
98
|
+
* the room is short.
|
|
99
|
+
* The room is what the first row leaves it (the compact width the fit
|
|
100
|
+
* reserved plus whatever the inline actions did not take), capped at
|
|
101
|
+
* `maxShare` of the header's content width.
|
|
102
|
+
*
|
|
103
|
+
* @param {object} input
|
|
104
|
+
* @param {boolean} [input.visible] the banner shows
|
|
105
|
+
* @param {boolean} [input.ownRow] it sits on a row of its own
|
|
106
|
+
* @param {boolean} [input.phoneLandscape] :root[data-phone-landscape]
|
|
107
|
+
* @param {number} [input.room] the width the first row leaves it
|
|
108
|
+
* @param {number} [input.compact] the chip's width: a room below it is 'row'
|
|
109
|
+
* @param {number} [input.contentWidth] the header's width minus its padding
|
|
110
|
+
* @param {number} [input.maxShare] HEADER_BANNER_MAX_SHARE
|
|
111
|
+
* @param {number} [input.fullMin] "❓ N open questions" with the banner's padding and border
|
|
112
|
+
* @returns {'hidden'|'row'|'compact'|'full'}
|
|
113
|
+
*/
|
|
114
|
+
export function resolveQuestionBannerMode({
|
|
115
|
+
visible = false,
|
|
116
|
+
ownRow = false,
|
|
117
|
+
phoneLandscape = false,
|
|
118
|
+
room = 0,
|
|
119
|
+
compact = 0,
|
|
120
|
+
contentWidth = Infinity,
|
|
121
|
+
maxShare = HEADER_BANNER_MAX_SHARE,
|
|
122
|
+
fullMin = 0,
|
|
123
|
+
} = {}) {
|
|
124
|
+
if (!visible) return 'hidden';
|
|
125
|
+
if (ownRow) return 'row';
|
|
126
|
+
if (phoneLandscape) return 'compact';
|
|
127
|
+
// Not even the chip fits on the first row: it would push ︙ off the
|
|
128
|
+
// screen, so the banner takes a row of its own (an unmeasured room does not).
|
|
129
|
+
if (Number.isFinite(Number(room)) && Number(room) < (Number(compact) || 0)) return 'row';
|
|
130
|
+
const share = Number(maxShare);
|
|
131
|
+
const width = Number(contentWidth);
|
|
132
|
+
const cap = Number.isFinite(share) && share > 0 && Number.isFinite(width) ? width * share : Infinity;
|
|
133
|
+
// An unmeasured room (NaN) is the chip: it is the width the fit reserved.
|
|
134
|
+
const space = Math.min(Number(room), cap);
|
|
135
|
+
return space >= Math.max(0, Number(fullMin) || 0) ? 'full' : 'compact';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Whether each action may show at all, in the header or in the menu. Distinct
|
|
140
|
+
* from the fit question, which is only about WHERE an available action shows.
|
|
141
|
+
*
|
|
142
|
+
* @param {object} input
|
|
143
|
+
* @param {boolean} [input.sharedMode] the shared read-only view: Day/Night only
|
|
144
|
+
* @param {number} [input.questionCount] pending questions (📓) of the open conversation
|
|
145
|
+
* @param {number} [input.pinCount] pins of the open conversation
|
|
146
|
+
* @param {number} [input.hiddenCount] messages hidden from shared viewers
|
|
147
|
+
* @param {boolean} [input.fullscreenAvailable] pwa-install.js's verdict (a phone, not installed)
|
|
148
|
+
* @returns {Record<string, boolean>}
|
|
149
|
+
*/
|
|
150
|
+
export function resolveHeaderActionAvailability({
|
|
151
|
+
sharedMode = false,
|
|
152
|
+
questionCount = 0,
|
|
153
|
+
pinCount = 0,
|
|
154
|
+
hiddenCount = 0,
|
|
155
|
+
fullscreenAvailable = false,
|
|
156
|
+
} = {}) {
|
|
157
|
+
if (sharedMode) {
|
|
158
|
+
return { questions: false, pins: false, hidden: false, search: false, context: false, usage: false, theme: true, fullscreen: false };
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
questions: Number(questionCount) > 0,
|
|
162
|
+
pins: Number(pinCount) > 0,
|
|
163
|
+
hidden: Number(hiddenCount) > 0,
|
|
164
|
+
search: true,
|
|
165
|
+
context: true,
|
|
166
|
+
usage: true,
|
|
167
|
+
theme: true,
|
|
168
|
+
fullscreen: !!fullscreenAvailable,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The fit decision. `available` is the width the inline actions may use in
|
|
174
|
+
* total; each inline action costs its width plus `gap`. An action whose width
|
|
175
|
+
* is unknown (not a finite number) counts as 0. An `available` that is not a
|
|
176
|
+
* finite number means the header could not be measured: every available
|
|
177
|
+
* action then goes into the menu, which always reaches all of them.
|
|
178
|
+
*
|
|
179
|
+
* @param {object} input
|
|
180
|
+
* @param {Record<string, number>} input.widths the inline buttons' widths, by key
|
|
181
|
+
* @param {number} [input.gap] the gap after each inline button
|
|
182
|
+
* @param {number} input.available the width left for the inline actions
|
|
183
|
+
* @param {Record<string, boolean>} input.availability from resolveHeaderActionAvailability
|
|
184
|
+
* @param {string[]} [input.order] priority order (default: HEADER_PRIORITY_ACTION_KEYS)
|
|
185
|
+
* @returns {{ inline: string[], menu: string[], unavailable: string[], used: number }}
|
|
186
|
+
* `used` is the width the inline actions take, their gaps included
|
|
187
|
+
*/
|
|
188
|
+
export function fitHeaderPriorityActions({
|
|
189
|
+
widths = {},
|
|
190
|
+
gap = 0,
|
|
191
|
+
available,
|
|
192
|
+
availability = {},
|
|
193
|
+
order = HEADER_PRIORITY_ACTION_KEYS,
|
|
194
|
+
} = {}) {
|
|
195
|
+
const inline = [];
|
|
196
|
+
const menu = [];
|
|
197
|
+
const unavailable = [];
|
|
198
|
+
const room = Number(available);
|
|
199
|
+
const step = Math.max(0, Number(gap) || 0);
|
|
200
|
+
let overflowing = !Number.isFinite(room);
|
|
201
|
+
let used = 0;
|
|
202
|
+
let inlineUsed = 0;
|
|
203
|
+
for (const key of order) {
|
|
204
|
+
if (!availability[key]) {
|
|
205
|
+
// Spends no width, so it never pushes an available action out.
|
|
206
|
+
unavailable.push(key);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
const width = Number(widths[key]);
|
|
210
|
+
used += (Number.isFinite(width) && width > 0 ? width : 0) + step;
|
|
211
|
+
if (!overflowing && used <= room) {
|
|
212
|
+
inline.push(key);
|
|
213
|
+
inlineUsed = used;
|
|
214
|
+
} else {
|
|
215
|
+
overflowing = true;
|
|
216
|
+
menu.push(key);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return { inline, menu, unavailable, used: inlineUsed };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** The menu ids of the keys a fit put into the menu. */
|
|
223
|
+
export function headerPriorityMenuIdsFor(keys) {
|
|
224
|
+
const wanted = new Set(keys || []);
|
|
225
|
+
return HEADER_PRIORITY_ACTIONS.filter((action) => wanted.has(action.key)).map((action) => action.menuId);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Asks for a new pass. Views that change an action's availability (pin and
|
|
230
|
+
* hidden counts, fullscreen) call this; a no-op outside a browser.
|
|
231
|
+
*/
|
|
232
|
+
export function notifyHeaderActionsChanged() {
|
|
233
|
+
const target = typeof window !== 'undefined' ? window : null;
|
|
234
|
+
if (!target || typeof target.dispatchEvent !== 'function' || typeof CustomEvent !== 'function') return;
|
|
235
|
+
try {
|
|
236
|
+
target.dispatchEvent(new CustomEvent(HEADER_ACTIONS_CHANGE_EVENT));
|
|
237
|
+
} catch {}
|
|
238
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// The live poll's diet: the page polls its conversation every 900 ms, and a
|
|
2
|
+
// finished reply's tool lines and thoughts do not change, so the poll names
|
|
3
|
+
// the replies whose details it already holds and the relay leaves those out.
|
|
4
|
+
// Pure: the relay's route uses the param side, the page the store side.
|
|
5
|
+
//
|
|
6
|
+
// The param (`heldDetails`) is `id~version,id~version,…`: an assistant
|
|
7
|
+
// message id and the version of its details the page holds. The version
|
|
8
|
+
// (`detailsVersion` on every assistant message of a full payload) changes
|
|
9
|
+
// with any row that arrives or changes after the turn (a late sub-agent
|
|
10
|
+
// line, a thought update, a history rebuild), and the relay then sends the
|
|
11
|
+
// details again. A held message comes back as `detailsHeld: true` with empty
|
|
12
|
+
// `activities` and `thoughts`, which the page fills from its store.
|
|
13
|
+
|
|
14
|
+
export const HELD_DETAILS_MAX_IDS = 200;
|
|
15
|
+
export const HELD_DETAILS_MAX_BYTES = 8 * 1024;
|
|
16
|
+
const ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
|
17
|
+
// The shape of a held reply's activities and thoughts. Bump it whenever the
|
|
18
|
+
// relay changes how those arrays are built for a finished reply (a cap, a
|
|
19
|
+
// sanitiser, a new field): every page then holds a stale version and gets
|
|
20
|
+
// each reply in full once.
|
|
21
|
+
export const HELD_DETAILS_SHAPE = 's1';
|
|
22
|
+
// `s1.<tool rows>.<their highest row id>.<thought rows>.<their highest row
|
|
23
|
+
// id>.<fingerprint of the thoughts' text and done>`. Anything else (a version
|
|
24
|
+
// from before this form) is dropped, so the reply goes out in full.
|
|
25
|
+
const VERSION_PATTERN = /^s\d{1,3}\.\d{1,12}\.\d{1,15}\.\d{1,12}\.\d{1,15}\.[0-9a-f]{8}$/;
|
|
26
|
+
|
|
27
|
+
const nonNegative = (value) => Math.max(0, Math.trunc(Number(value) || 0));
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A finished reply's details version. Tool rows are only ever inserted, so
|
|
31
|
+
* their count and highest row id see every change. A thought row is updated
|
|
32
|
+
* in place as its text streams in and when it is done, so the thoughts also
|
|
33
|
+
* carry a fingerprint of their content (`thoughtFingerprint`).
|
|
34
|
+
*/
|
|
35
|
+
export function detailsVersion({ activityCount = 0, activityMaxRowId = 0, thoughtCount = 0, thoughtMaxRowId = 0, thoughtHash = '00000000' } = {}) {
|
|
36
|
+
const hash = /^[0-9a-f]{8}$/.test(String(thoughtHash)) ? String(thoughtHash) : '00000000';
|
|
37
|
+
return [HELD_DETAILS_SHAPE, nonNegative(activityCount), nonNegative(activityMaxRowId), nonNegative(thoughtCount), nonNegative(thoughtMaxRowId), hash].join('.');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The fingerprint of one reply's thought rows: FNV-1a (32 bit) over each
|
|
42
|
+
* row's id, done and text, in row id order. Any change to a thought's text or
|
|
43
|
+
* done changes it, two in the same millisecond included.
|
|
44
|
+
*/
|
|
45
|
+
export function thoughtFingerprint(rows) {
|
|
46
|
+
let hash = 0x811c9dc5;
|
|
47
|
+
const mix = (text) => {
|
|
48
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
49
|
+
hash ^= text.charCodeAt(i);
|
|
50
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
const ordered = [...(Array.isArray(rows) ? rows : [])].sort((a, b) => Number(a?.rowid) - Number(b?.rowid));
|
|
54
|
+
for (const row of ordered) mix(`${nonNegative(row?.rowid)}\u0001${Number(row?.done) ? 1 : 0}\u0001${String(row?.text ?? '')}\u0002`);
|
|
55
|
+
return hash.toString(16).padStart(8, '0');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The relay's reading of the param: a Map id → version, or null when it
|
|
60
|
+
* names nothing usable or is too big (over 8 KB or 200 entries: the poll
|
|
61
|
+
* then gets the full payload). Entries that are not `id~version` with an id
|
|
62
|
+
* of [A-Za-z0-9_-]{1,64} are dropped.
|
|
63
|
+
*/
|
|
64
|
+
export function parseHeldDetailsParam(raw) {
|
|
65
|
+
if (raw === undefined || raw === null) return null;
|
|
66
|
+
const text = Array.isArray(raw) ? '' : String(raw);
|
|
67
|
+
if (!text || text.length > HELD_DETAILS_MAX_BYTES) return null;
|
|
68
|
+
const parts = text.split(',');
|
|
69
|
+
if (parts.length > HELD_DETAILS_MAX_IDS) return null;
|
|
70
|
+
const held = new Map();
|
|
71
|
+
for (const part of parts) {
|
|
72
|
+
const at = part.indexOf('~');
|
|
73
|
+
if (at <= 0) continue;
|
|
74
|
+
const id = part.slice(0, at);
|
|
75
|
+
const version = part.slice(at + 1);
|
|
76
|
+
if (!ID_PATTERN.test(id) || !VERSION_PATTERN.test(version)) continue;
|
|
77
|
+
held.set(id, version);
|
|
78
|
+
}
|
|
79
|
+
return held.size ? held : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The page's side: what it holds of one open conversation's finished
|
|
84
|
+
* replies. Filled only from conversation payloads fetched over HTTP (never
|
|
85
|
+
* from a socket's reply: the turn-end fallback carries the live list, not
|
|
86
|
+
* the finished one), and only with what it is shown.
|
|
87
|
+
*/
|
|
88
|
+
export function createHeldDetailsStore() {
|
|
89
|
+
let conversationId = '';
|
|
90
|
+
// id → { version, activities, thoughts }, in the payload's message order.
|
|
91
|
+
let held = new Map();
|
|
92
|
+
// A poll whose answer named a reply the page does not hold is dropped; the
|
|
93
|
+
// next poll goes without the param. Two such drops in a row and the param
|
|
94
|
+
// stays off until the next full load.
|
|
95
|
+
let fullNext = false;
|
|
96
|
+
let disabled = false;
|
|
97
|
+
let dropStreak = 0;
|
|
98
|
+
|
|
99
|
+
function forConversation(id) {
|
|
100
|
+
const key = String(id || '');
|
|
101
|
+
if (key !== conversationId) {
|
|
102
|
+
conversationId = key;
|
|
103
|
+
held = new Map();
|
|
104
|
+
fullNext = false;
|
|
105
|
+
disabled = false;
|
|
106
|
+
dropStreak = 0;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function record(messages) {
|
|
111
|
+
for (const message of Array.isArray(messages) ? messages : []) {
|
|
112
|
+
if (message?.role !== 'assistant' || message.detailsHeld) continue;
|
|
113
|
+
const id = String(message.id || '');
|
|
114
|
+
const version = String(message.detailsVersion || '');
|
|
115
|
+
if (!ID_PATTERN.test(id) || !VERSION_PATTERN.test(version)) continue;
|
|
116
|
+
held.delete(id);
|
|
117
|
+
held.set(id, {
|
|
118
|
+
version,
|
|
119
|
+
activities: Array.isArray(message.activities) ? message.activities : [],
|
|
120
|
+
thoughts: Array.isArray(message.thoughts) ? message.thoughts : [],
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
/**
|
|
127
|
+
* A payload was applied. `full`: a load of the whole window (opening,
|
|
128
|
+
* reconnecting, a reload), which replaces what is held; otherwise (a
|
|
129
|
+
* poll, a page of older history) its replies are added. `shownIds`, when
|
|
130
|
+
* given, is every message on screen now: entries for others go.
|
|
131
|
+
*/
|
|
132
|
+
noteApplied(id, payload, { full = false, shownIds = null } = {}) {
|
|
133
|
+
forConversation(id);
|
|
134
|
+
if (full) {
|
|
135
|
+
held = new Map();
|
|
136
|
+
fullNext = false;
|
|
137
|
+
disabled = false;
|
|
138
|
+
dropStreak = 0;
|
|
139
|
+
}
|
|
140
|
+
record(payload?.messages);
|
|
141
|
+
if (shownIds) {
|
|
142
|
+
const shown = new Set(Array.from(shownIds, (value) => String(value)));
|
|
143
|
+
for (const key of [...held.keys()]) if (!shown.has(key)) held.delete(key);
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
/** The param for the next poll of `id`, or '' to ask for everything. */
|
|
148
|
+
param(id) {
|
|
149
|
+
forConversation(id);
|
|
150
|
+
if (fullNext) {
|
|
151
|
+
fullNext = false;
|
|
152
|
+
return '';
|
|
153
|
+
}
|
|
154
|
+
if (disabled || !held.size) return '';
|
|
155
|
+
// The newest first, as many as fit in 8 KB and 200 entries.
|
|
156
|
+
const parts = [];
|
|
157
|
+
let bytes = 0;
|
|
158
|
+
for (const [key, entry] of [...held.entries()].reverse()) {
|
|
159
|
+
const part = `${key}~${entry.version}`;
|
|
160
|
+
if (parts.length >= HELD_DETAILS_MAX_IDS || bytes + part.length + 1 > HELD_DETAILS_MAX_BYTES) break;
|
|
161
|
+
parts.push(part);
|
|
162
|
+
bytes += part.length + 1;
|
|
163
|
+
}
|
|
164
|
+
return parts.join(',');
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Fill the held replies of a poll's answer from the store, in place.
|
|
169
|
+
* Returns false (the answer must be dropped) when it names one the store
|
|
170
|
+
* does not hold.
|
|
171
|
+
*/
|
|
172
|
+
restore(id, payload) {
|
|
173
|
+
forConversation(id);
|
|
174
|
+
const messages = Array.isArray(payload?.messages) ? payload.messages : [];
|
|
175
|
+
const missing = messages.some((message) => message?.detailsHeld && !held.has(String(message.id || '')));
|
|
176
|
+
if (missing) {
|
|
177
|
+
dropStreak += 1;
|
|
178
|
+
if (dropStreak >= 2) disabled = true;
|
|
179
|
+
else fullNext = true;
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
let restored = 0;
|
|
183
|
+
for (const message of messages) {
|
|
184
|
+
if (!message?.detailsHeld) continue;
|
|
185
|
+
const entry = held.get(String(message.id));
|
|
186
|
+
message.activities = entry.activities;
|
|
187
|
+
message.thoughts = entry.thoughts;
|
|
188
|
+
message.detailsVersion = entry.version;
|
|
189
|
+
delete message.detailsHeld;
|
|
190
|
+
restored += 1;
|
|
191
|
+
}
|
|
192
|
+
// Only a poll the store answered for ends a run of drops; the full
|
|
193
|
+
// poll after a drop does not.
|
|
194
|
+
if (restored) dropStreak = 0;
|
|
195
|
+
return true;
|
|
196
|
+
},
|
|
197
|
+
|
|
198
|
+
/** For tests and the page's own checks. */
|
|
199
|
+
size() { return held.size; },
|
|
200
|
+
has(messageId) { return held.has(String(messageId || '')); },
|
|
201
|
+
};
|
|
202
|
+
}
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
} from './store.js';
|
|
20
20
|
import { unhideAllMessagesFromShares, updateMessageShareVisibility } from './api-client.js';
|
|
21
21
|
import { jumpToPinnedMessage } from './pinned-messages-view.mjs';
|
|
22
|
+
import { notifyHeaderActionsChanged } from './header-priority-actions.mjs';
|
|
22
23
|
|
|
23
24
|
export const HIDDEN_MESSAGES_MODAL_KIND = 'hidden-messages';
|
|
24
25
|
|
|
@@ -113,12 +114,15 @@ function syncHiddenHeaderButton() {
|
|
|
113
114
|
const button = document.getElementById('hidden-messages-btn');
|
|
114
115
|
if (!button) return;
|
|
115
116
|
const count = getConversationHiddenMessages(currentConversationId).length;
|
|
117
|
+
// Available or not; the priority+ pass places it (header or ︙).
|
|
116
118
|
button.hidden = IS_SHARED_VIEW || !count;
|
|
119
|
+
button.dataset.count = IS_SHARED_VIEW ? '0' : String(count);
|
|
117
120
|
const countEl = button.querySelector('.header-icon-count');
|
|
118
121
|
if (countEl) countEl.textContent = count ? String(count) : '';
|
|
119
122
|
const label = count ? `Hidden from shared viewers (${count})` : 'Hidden from shared viewers';
|
|
120
123
|
button.setAttribute('title', label);
|
|
121
124
|
button.setAttribute('aria-label', label);
|
|
125
|
+
notifyHeaderActionsChanged();
|
|
122
126
|
}
|
|
123
127
|
|
|
124
128
|
function hiddenModalContent() {
|