@oar-sh/oar 0.9.10 → 0.9.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/CHANGELOG.md +440 -1
  2. package/README.md +156 -972
  3. package/package.json +13 -13
  4. package/server/README.md +112 -25
  5. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  6. package/server/claude-worker/claude-sdk-adapter.mjs +57 -4
  7. package/server/claude-worker/claude-session-process.mjs +217 -11
  8. package/server/claude-worker/claude-session-worker.mjs +26 -1
  9. package/server/claude-worker/task-output-discovery.mjs +126 -0
  10. package/server/copilot-worker/copilot-sdk-session-process.mjs +235 -14
  11. package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -0
  12. package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
  13. package/server/copilot-worker/copilot-task-settle.mjs +96 -0
  14. package/server/db-schema.mjs +68 -2
  15. package/server/grok-worker/acp-client.mjs +2 -1
  16. package/server/grok-worker/acp-host-services.mjs +46 -6
  17. package/server/grok-worker/grok-background-tasks.mjs +241 -0
  18. package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
  19. package/server/grok-worker/grok-session-worker.mjs +33 -1
  20. package/server/grok-worker/grok-turn-runner.mjs +11 -0
  21. package/server/migrations/0013-previews.mjs +130 -0
  22. package/server/migrations/0014-conversation-groups.mjs +89 -0
  23. package/server/migrations/0015-conversation-group-position.mjs +76 -0
  24. package/server/migrations/0016-share-access-log.mjs +110 -0
  25. package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
  26. package/server/public/app/about-ui.js +73 -0
  27. package/server/public/app/about-view.mjs +234 -0
  28. package/server/public/app/action-confirmations.js +31 -14
  29. package/server/public/app/agent-exchange-fold-view.mjs +40 -101
  30. package/server/public/app/api-client.js +264 -16
  31. package/server/public/app/ask-user-view.js +17 -15
  32. package/server/public/app/background-tasks-view.mjs +285 -16
  33. package/server/public/app/bootstrap.js +253 -37
  34. package/server/public/app/chat-actions-menu-state.mjs +20 -10
  35. package/server/public/app/claude-background-command-limit-options.mjs +21 -0
  36. package/server/public/app/context-menu.mjs +90 -6
  37. package/server/public/app/conversation-group-drag.mjs +191 -0
  38. package/server/public/app/conversation-group-order.mjs +265 -0
  39. package/server/public/app/conversation-group-state.mjs +100 -0
  40. package/server/public/app/conversation-list-drag.mjs +449 -0
  41. package/server/public/app/conversation-payload-cache.mjs +357 -0
  42. package/server/public/app/conversation-turn-state.mjs +81 -0
  43. package/server/public/app/conversation-view.js +567 -94
  44. package/server/public/app/conversation-window-retention.mjs +96 -0
  45. package/server/public/app/cwd-picker.js +1 -1
  46. package/server/public/app/hidden-messages-view.mjs +312 -0
  47. package/server/public/app/host-suspend-ui.js +7 -1
  48. package/server/public/app/journal-view.js +1033 -82
  49. package/server/public/app/preview-cards.mjs +117 -31
  50. package/server/public/app/preview-command.mjs +10 -1
  51. package/server/public/app/relay-board-view.js +19 -6
  52. package/server/public/app/relay-prompt-sanitizer.mjs +11 -4
  53. package/server/public/app/relay-restart-error.mjs +16 -0
  54. package/server/public/app/settings-modal.js +150 -10
  55. package/server/public/app/settings-tabs.js +1 -1
  56. package/server/public/app/share-format.mjs +49 -0
  57. package/server/public/app/share-preview-marks.mjs +167 -0
  58. package/server/public/app/share-state.js +109 -0
  59. package/server/public/app/shared-conversations-settings.js +480 -0
  60. package/server/public/app/socket-handlers.js +138 -20
  61. package/server/public/app/store.js +121 -30
  62. package/server/public/app/subagent-row.mjs +3 -3
  63. package/server/public/app/subagent-viewer.mjs +37 -7
  64. package/server/public/app/task-console-render.mjs +237 -0
  65. package/server/public/app/task-console-watch.mjs +42 -0
  66. package/server/public/app/task-console.mjs +630 -0
  67. package/server/public/app/task-output-text.mjs +234 -0
  68. package/server/public/app/thinking-follow.js +0 -4
  69. package/server/public/app/transcript-island.mjs +73 -0
  70. package/server/public/app/transcript-jump-bottom-view.mjs +230 -26
  71. package/server/public/app/transcript-jump-bottom.mjs +170 -22
  72. package/server/public/app/transcript-reading-anchor.mjs +256 -0
  73. package/server/public/app/transcript-reload-freshness.mjs +17 -1
  74. package/server/public/app/transcript-viewport-anchor.mjs +159 -0
  75. package/server/public/index.html +625 -21
  76. package/server/relay-tools.md +3 -1
  77. package/server/relay.mjs +2 -1
  78. package/server/repositories/conversation-group-repository.mjs +444 -0
  79. package/server/repositories/message-repository.mjs +9 -0
  80. package/server/repositories/question-repository.mjs +4 -0
  81. package/server/repositories/session-repository.mjs +12 -37
  82. package/server/repositories/share-repository.mjs +322 -0
  83. package/server/repositories/share-visibility-statements.mjs +10 -0
  84. package/server/routes/about-routes.mjs +15 -0
  85. package/server/routes/cache-routes.mjs +4 -0
  86. package/server/routes/conversation-group-routes.mjs +171 -0
  87. package/server/routes/messages-routes.mjs +433 -68
  88. package/server/routes/preview-routes.mjs +9 -1
  89. package/server/routes/sessions-routes.mjs +901 -253
  90. package/server/server-runtime.mjs +392 -99
  91. package/server/services/about-service.mjs +461 -0
  92. package/server/services/background-task-store.mjs +365 -0
  93. package/server/services/background-work-view.mjs +228 -0
  94. package/server/services/cache-rebuild-service.mjs +30 -0
  95. package/server/services/cli-process-runner.mjs +4 -21
  96. package/server/services/conversation-history-page.mjs +199 -0
  97. package/server/services/delete-archive-service.mjs +4 -1
  98. package/server/services/detached-worker-watch-service.mjs +150 -0
  99. package/server/services/hidden-message-list.mjs +21 -0
  100. package/server/services/host-suspend-command.mjs +2 -1
  101. package/server/services/message-pin-list.mjs +1 -1
  102. package/server/services/preview-health-probe.mjs +8 -1
  103. package/server/services/preview-proxy-server.mjs +8 -4
  104. package/server/services/preview-registry-service.mjs +676 -26
  105. package/server/services/preview-static-handler.mjs +40 -7
  106. package/server/services/preview-store.mjs +274 -0
  107. package/server/services/rate-limit-service.mjs +12 -1
  108. package/server/services/relay-auth.mjs +183 -0
  109. package/server/services/relay-cli-launcher-service.mjs +3 -1
  110. package/server/services/relay-event-service.mjs +17 -0
  111. package/server/services/relay-prompt-sanitizer.mjs +11 -4
  112. package/server/services/remote-relay-dispatcher.mjs +187 -7
  113. package/server/services/request-boundary.mjs +183 -0
  114. package/server/services/session-activity-tracker.mjs +231 -19
  115. package/server/services/session-worker-launch-service.mjs +190 -6
  116. package/server/services/session-worker-process-service.mjs +10 -6
  117. package/server/services/share-access-log-service.mjs +123 -0
  118. package/server/services/share-path-redaction.mjs +424 -0
  119. package/server/services/status-event-service.mjs +7 -3
  120. package/server/services/task-output-rooms.mjs +259 -0
  121. package/server/services/task-output-service.mjs +562 -0
  122. package/server/services/tunnel-worker-path-guard.mjs +24 -41
  123. package/server/services/windows-autostart-service.mjs +6 -1
  124. package/server/services/windows-boot-autostart-service.mjs +207 -23
  125. package/shared/claude-background-command-limit.mjs +83 -0
  126. package/shared/preview-tool-core.mjs +15 -5
  127. package/shared/remote-relay-contract.mjs +35 -3
  128. package/shared/share-path-privacy.mjs +30 -0
  129. package/shared/task-output-text.mjs +737 -0
  130. package/shared/windows-system-tools.mjs +106 -0
  131. package/shared/worker-runtime/background-task-digest.mjs +30 -0
  132. package/shared/worker-runtime/heartbeat.mjs +24 -0
  133. package/shared/worker-runtime/kept-posts.mjs +83 -0
  134. package/shared/worker-runtime/task-output-spool.mjs +181 -0
  135. package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
@@ -0,0 +1,96 @@
1
+ // "Messages kept per conversation for switching back" (Settings › General):
2
+ // how many loaded messages the page keeps of a conversation it leaves, so a
3
+ // switch back shows the same rows. A setting of this browser, kept in
4
+ // localStorage like the scroll-on-send one. The slider runs from 100 to 1,000
5
+ // by 100 and has one more step at the far end, no limit. The document and
6
+ // the storage are passed in by the tests.
7
+
8
+ export const WINDOW_RETENTION_STORAGE_KEY = 'copilot_conversation_window_retention';
9
+ export const WINDOW_RETENTION_SLIDER_ID = 'conversation-window-retention-slider';
10
+ export const WINDOW_RETENTION_VALUE_ID = 'conversation-window-retention-value';
11
+ export const WINDOW_RETENTION_DEFAULT = 200;
12
+ export const WINDOW_RETENTION_MIN = 100;
13
+ export const WINDOW_RETENTION_MAX = 1000;
14
+ export const WINDOW_RETENTION_STEP = 100;
15
+ /** The slider's last step: no limit. */
16
+ export const WINDOW_RETENTION_UNLIMITED_POSITION = WINDOW_RETENTION_MAX + WINDOW_RETENTION_STEP;
17
+ const UNLIMITED_TOKEN = 'none';
18
+
19
+ function clampToStep(value) {
20
+ const stepped = Math.round(value / WINDOW_RETENTION_STEP) * WINDOW_RETENTION_STEP;
21
+ return Math.min(WINDOW_RETENTION_MAX, Math.max(WINDOW_RETENTION_MIN, stepped));
22
+ }
23
+
24
+ /** The cap a stored value stands for: a count, or Infinity for no limit. */
25
+ export function parseWindowRetention(stored) {
26
+ const text = String(stored ?? '').trim().toLowerCase();
27
+ if (!text) return WINDOW_RETENTION_DEFAULT;
28
+ if (text === UNLIMITED_TOKEN) return Infinity;
29
+ const value = Number(text);
30
+ if (!Number.isFinite(value) || value <= 0) return WINDOW_RETENTION_DEFAULT;
31
+ return clampToStep(value);
32
+ }
33
+
34
+ export function serializeWindowRetention(cap) {
35
+ return Number.isFinite(Number(cap)) ? String(clampToStep(Number(cap))) : UNLIMITED_TOKEN;
36
+ }
37
+
38
+ /** The cap at a slider position; the far end is no limit. */
39
+ export function windowRetentionFromSlider(position) {
40
+ const value = Number(position);
41
+ if (!Number.isFinite(value)) return WINDOW_RETENTION_DEFAULT;
42
+ if (value >= WINDOW_RETENTION_UNLIMITED_POSITION) return Infinity;
43
+ return clampToStep(value);
44
+ }
45
+
46
+ export function sliderPositionForWindowRetention(cap) {
47
+ return Number.isFinite(Number(cap)) ? clampToStep(Number(cap)) : WINDOW_RETENTION_UNLIMITED_POSITION;
48
+ }
49
+
50
+ export function formatWindowRetention(cap) {
51
+ if (!Number.isFinite(Number(cap))) return 'No limit';
52
+ return `${clampToStep(Number(cap)).toLocaleString('en-US')} messages`;
53
+ }
54
+
55
+ function storage() {
56
+ try { return globalThis.localStorage || null; } catch { return null; }
57
+ }
58
+
59
+ export function readWindowRetention(store = storage()) {
60
+ try {
61
+ return parseWindowRetention(store?.getItem(WINDOW_RETENTION_STORAGE_KEY));
62
+ } catch {
63
+ return WINDOW_RETENTION_DEFAULT;
64
+ }
65
+ }
66
+
67
+ function showValue(doc, cap) {
68
+ const text = formatWindowRetention(cap);
69
+ const label = doc?.getElementById?.(WINDOW_RETENTION_VALUE_ID);
70
+ if (label) label.textContent = text;
71
+ doc?.getElementById?.(WINDOW_RETENTION_SLIDER_ID)?.setAttribute?.('aria-valuetext', text);
72
+ }
73
+
74
+ export function syncWindowRetentionSlider(doc = globalThis.document, store = storage()) {
75
+ const cap = readWindowRetention(store);
76
+ const slider = doc?.getElementById?.(WINDOW_RETENTION_SLIDER_ID);
77
+ if (slider) slider.value = String(sliderPositionForWindowRetention(cap));
78
+ showValue(doc, cap);
79
+ }
80
+
81
+ /** While the thumb moves: the label follows, nothing is saved. */
82
+ export function previewWindowRetention(position, { doc = globalThis.document } = {}) {
83
+ showValue(doc, windowRetentionFromSlider(position));
84
+ }
85
+
86
+ /**
87
+ * Saves the slider's value and hands the new cap to `onChange` (the kept
88
+ * conversations are cut down to it). Returns the cap.
89
+ */
90
+ export function updateWindowRetention(position, { doc = globalThis.document, store = storage(), onChange = null } = {}) {
91
+ const cap = windowRetentionFromSlider(position);
92
+ try { store?.setItem(WINDOW_RETENTION_STORAGE_KEY, serializeWindowRetention(cap)); } catch {}
93
+ syncWindowRetentionSlider(doc, store);
94
+ if (typeof onChange === 'function') onChange(cap);
95
+ return cap;
96
+ }
@@ -608,7 +608,7 @@ async function submitChangeCwd(launchAfterChange = false) {
608
608
  ? `Next launch CWD saved as ${updatedPath}. The running CLI keeps its current CWD.`
609
609
  : `Next launch CWD saved as ${updatedPath}.`);
610
610
  } catch (error) {
611
- alert(error?.message || 'Failed to update the launch CWD');
611
+ alert(error?.message ? `Failed to update the launch CWD: ${error.message}` : 'Failed to update the launch CWD');
612
612
  } finally {
613
613
  changeCwdInFlight = false;
614
614
  setSummaryModalLoading(false);
@@ -0,0 +1,312 @@
1
+ // Messages hidden from shared viewers: the per-conversation list, the 🙈
2
+ // header button with its count, and the list modal (the shared summary modal,
3
+ // kind 'hidden-messages') with Jump, Unhide and Unhide all. Fed by the
4
+ // conversation payload's `hiddenMessages` on load and by the
5
+ // `conversation_hidden_messages_updated` socket event (REPLACE semantics per
6
+ // conversation), so a message hidden on another device shows here without a
7
+ // reload. Once a conversation's list is known it is what a bubble's Hide /
8
+ // Unhide label is drawn from. Owner-only: nothing of it in the shared view.
9
+
10
+ import {
11
+ IS_SHARED_VIEW,
12
+ closeSummaryModal,
13
+ escHtml,
14
+ fmtDate,
15
+ openSummaryModal,
16
+ renderSummaryModalContent,
17
+ showTransientRelayNotice,
18
+ summaryModalState,
19
+ } from './store.js';
20
+ import { unhideAllMessagesFromShares, updateMessageShareVisibility } from './api-client.js';
21
+ import { jumpToPinnedMessage } from './pinned-messages-view.mjs';
22
+
23
+ export const HIDDEN_MESSAGES_MODAL_KIND = 'hidden-messages';
24
+
25
+ const hiddenByConversation = new Map();
26
+ // Ordered like the pin lists: an older list (a load that left before the
27
+ // change) must not win over the change's own socket event.
28
+ const hiddenRevisionByConversation = new Map();
29
+ // Conversations whose list arrived at least once (an empty one included).
30
+ const knownConversations = new Set();
31
+ const unhidesInFlight = new Set();
32
+ let unhideAllInFlight = false;
33
+ let confirmingUnhideAll = false;
34
+ let currentConversationId = null;
35
+ let modalBodyBound = false;
36
+ // conversation-view.js imports this module; it hands its side in through
37
+ // initHiddenMessagesView.
38
+ let handlers = {
39
+ syncRenderedShareVisibility: null,
40
+ };
41
+
42
+ function normalizeHiddenMessages(list) {
43
+ return (Array.isArray(list) ? list : [])
44
+ .map((item) => ({ ...item, messageId: String(item?.messageId || '').trim() }))
45
+ .filter((item) => item.messageId);
46
+ }
47
+
48
+ export function getConversationHiddenMessages(conversationId) {
49
+ return hiddenByConversation.get(String(conversationId || '').trim()) || [];
50
+ }
51
+
52
+ // null while the conversation's list has not arrived: the caller falls back
53
+ // to what the message itself says.
54
+ export function isMessageHiddenFromShares(conversationId, messageId) {
55
+ const conversationKey = String(conversationId || '').trim();
56
+ const id = String(messageId || '').trim();
57
+ if (!conversationKey || !id || !knownConversations.has(conversationKey)) return null;
58
+ return getConversationHiddenMessages(conversationKey).some((item) => item.messageId === id);
59
+ }
60
+
61
+ export function hiddenCountLabel(count) {
62
+ const total = Number(count) || 0;
63
+ return total ? `${total} hidden from shared viewers` : '';
64
+ }
65
+
66
+ function roleLabel(role) {
67
+ return String(role || '').trim().toLowerCase() === 'user' ? 'You' : 'Agent';
68
+ }
69
+
70
+ export function renderHiddenListHtml(list, { confirming = false, busy = false } = {}) {
71
+ const items = normalizeHiddenMessages(list);
72
+ if (!items.length) {
73
+ return '<div class="pinned-empty">No hidden messages. Shared viewers see the whole conversation.</div>';
74
+ }
75
+ const count = items.length;
76
+ const actionsHtml = confirming
77
+ ? `<div class="hidden-unhide-all-confirm">
78
+ <p>Show ${count === 1 ? 'this message' : `all ${count} messages`} to shared viewers again?</p>
79
+ <div class="summary-modal-actions">
80
+ <button type="button" class="chat-title-action-btn" data-hidden-unhide-all-confirm="1"${busy ? ' disabled' : ''}>${busy ? 'Unhiding…' : 'Unhide all'}</button>
81
+ <button type="button" class="chat-title-action-btn" data-hidden-unhide-all-cancel="1"${busy ? ' disabled' : ''}>Cancel</button>
82
+ </div>
83
+ </div>`
84
+ : `<div class="hidden-list-actions">
85
+ <button type="button" class="chat-title-action-btn" data-hidden-unhide-all="1">Unhide all</button>
86
+ </div>`;
87
+ return `${actionsHtml}<div class="pinned-list">${items.map((item) => {
88
+ const id = escHtml(item.messageId);
89
+ const when = fmtDate(item.timestamp);
90
+ const attachmentCount = Number(item.attachmentCount) || 0;
91
+ const meta = [
92
+ escHtml(roleLabel(item.role)),
93
+ when ? escHtml(when) : '',
94
+ attachmentCount ? `📎 ${attachmentCount}` : '',
95
+ ].filter(Boolean).join(' · ');
96
+ const preview = String(item.preview || '').trim();
97
+ const previewHtml = preview
98
+ ? escHtml(preview)
99
+ : '<span class="pinned-row-no-text">(no text)</span>';
100
+ const unhiding = unhidesInFlight.has(`${currentConversationId}:${item.messageId}`);
101
+ return `
102
+ <div class="pinned-row">
103
+ <button type="button" class="pinned-row-jump" data-hidden-jump="${id}">
104
+ <span class="pinned-row-meta">${meta}</span>
105
+ <span class="pinned-row-preview">${previewHtml}</span>
106
+ </button>
107
+ <button type="button" class="hidden-row-unhide" data-hidden-unhide="${id}" title="Show this message to shared viewers again"${unhiding ? ' disabled' : ''}>${unhiding ? 'Unhiding…' : 'Unhide'}</button>
108
+ </div>`;
109
+ }).join('')}</div>`;
110
+ }
111
+
112
+ function syncHiddenHeaderButton() {
113
+ const button = document.getElementById('hidden-messages-btn');
114
+ if (!button) return;
115
+ const count = getConversationHiddenMessages(currentConversationId).length;
116
+ button.hidden = IS_SHARED_VIEW || !count;
117
+ const countEl = button.querySelector('.header-icon-count');
118
+ if (countEl) countEl.textContent = count ? String(count) : '';
119
+ const label = count ? `Hidden from shared viewers (${count})` : 'Hidden from shared viewers';
120
+ button.setAttribute('title', label);
121
+ button.setAttribute('aria-label', label);
122
+ }
123
+
124
+ function hiddenModalContent() {
125
+ const list = getConversationHiddenMessages(currentConversationId);
126
+ return {
127
+ title: '🙈 Hidden messages',
128
+ subtitle: hiddenCountLabel(list.length),
129
+ bodyHtml: renderHiddenListHtml(list, { confirming: confirmingUnhideAll, busy: unhideAllInFlight }),
130
+ kind: HIDDEN_MESSAGES_MODAL_KIND,
131
+ };
132
+ }
133
+
134
+ function rerenderOpenModal() {
135
+ if (summaryModalState.kind === HIDDEN_MESSAGES_MODAL_KIND) {
136
+ renderSummaryModalContent(hiddenModalContent());
137
+ }
138
+ }
139
+
140
+ function syncCurrentConversationHiddenMessages() {
141
+ syncHiddenHeaderButton();
142
+ if (!getConversationHiddenMessages(currentConversationId).length) confirmingUnhideAll = false;
143
+ rerenderOpenModal();
144
+ if (knownConversations.has(String(currentConversationId || ''))) {
145
+ handlers.syncRenderedShareVisibility?.(
146
+ new Set(getConversationHiddenMessages(currentConversationId).map((item) => item.messageId)),
147
+ );
148
+ }
149
+ }
150
+
151
+ export function setConversationHiddenMessages(conversationId, list, revision = 0) {
152
+ const id = String(conversationId || '').trim();
153
+ if (!id) return;
154
+ const nextRevision = Number(revision) || 0;
155
+ if (nextRevision) {
156
+ if (nextRevision < (hiddenRevisionByConversation.get(id) || 0)) return;
157
+ hiddenRevisionByConversation.set(id, nextRevision);
158
+ }
159
+ const normalized = normalizeHiddenMessages(list);
160
+ const wasKnown = knownConversations.has(id);
161
+ knownConversations.add(id);
162
+ // The list arrives with every conversation load, the live poll included: an
163
+ // unchanged one must not redraw the open modal under the user's finger.
164
+ if (wasKnown && JSON.stringify(normalized) === JSON.stringify(getConversationHiddenMessages(id))) return;
165
+ if (normalized.length) hiddenByConversation.set(id, normalized);
166
+ else hiddenByConversation.delete(id);
167
+ if (id === currentConversationId) syncCurrentConversationHiddenMessages();
168
+ }
169
+
170
+ export function setHiddenMessagesConversation(conversationId) {
171
+ const id = String(conversationId || '').trim() || null;
172
+ if (id === currentConversationId) return;
173
+ currentConversationId = id;
174
+ confirmingUnhideAll = false;
175
+ // The conversation was closed (deleted, new chat): its list goes with it.
176
+ if (!id && summaryModalState.kind === HIDDEN_MESSAGES_MODAL_KIND) closeSummaryModal();
177
+ syncCurrentConversationHiddenMessages();
178
+ }
179
+
180
+ export function openHiddenMessagesModal() {
181
+ if (IS_SHARED_VIEW) return;
182
+ confirmingUnhideAll = false;
183
+ openSummaryModal(hiddenModalContent());
184
+ }
185
+
186
+ function refusalText(result, fallback) {
187
+ return String(result?.error || '').trim() || fallback;
188
+ }
189
+
190
+ // The answer carries the conversation's whole hidden list, stored under that
191
+ // conversation's id: if the user has switched meanwhile, the open one is not
192
+ // touched.
193
+ export async function unhideHiddenMessage(conversationId, messageId) {
194
+ const conversationKey = String(conversationId || '').trim();
195
+ const targetMessageId = String(messageId || '').trim();
196
+ if (!conversationKey || !targetMessageId) return;
197
+ const flightKey = `${conversationKey}:${targetMessageId}`;
198
+ if (unhidesInFlight.has(flightKey)) return;
199
+
200
+ unhidesInFlight.add(flightKey);
201
+ if (conversationKey === currentConversationId) rerenderOpenModal();
202
+ const result = await updateMessageShareVisibility(conversationKey, targetMessageId, false);
203
+ unhidesInFlight.delete(flightKey);
204
+ if (!result?.ok) {
205
+ if (conversationKey === currentConversationId) rerenderOpenModal();
206
+ showTransientRelayNotice(refusalText(result, 'Could not unhide the message.'));
207
+ return;
208
+ }
209
+ showTransientRelayNotice('Message visible to shared viewers.');
210
+ if ('hiddenMessages' in result) {
211
+ setConversationHiddenMessages(conversationKey, result.hiddenMessages, result.hiddenMessagesRevision);
212
+ }
213
+ if (conversationKey === currentConversationId) rerenderOpenModal();
214
+ }
215
+
216
+ function skippedPreview(conversationKey, messageId) {
217
+ const item = getConversationHiddenMessages(conversationKey).find((entry) => entry.messageId === messageId);
218
+ const preview = String(item?.preview || '').trim();
219
+ if (!preview) return '';
220
+ const points = Array.from(preview);
221
+ return points.length > 40 ? `${points.slice(0, 39).join('').trimEnd()}…` : preview;
222
+ }
223
+
224
+ export async function unhideAllHiddenMessages(conversationId) {
225
+ const conversationKey = String(conversationId || '').trim();
226
+ if (!conversationKey || unhideAllInFlight) return;
227
+ unhideAllInFlight = true;
228
+ if (conversationKey === currentConversationId) rerenderOpenModal();
229
+ const result = await unhideAllMessagesFromShares(conversationKey);
230
+ unhideAllInFlight = false;
231
+ if (conversationKey === currentConversationId) confirmingUnhideAll = false;
232
+ if (!result?.ok) {
233
+ if (conversationKey === currentConversationId) rerenderOpenModal();
234
+ showTransientRelayNotice(refusalText(result, 'Could not unhide the messages.'));
235
+ return;
236
+ }
237
+ const unhidden = Array.isArray(result.unhidden) ? result.unhidden.length : 0;
238
+ const skipped = Array.isArray(result.skipped) ? result.skipped : [];
239
+ if (skipped.length) {
240
+ // Named before the new list replaces the old one: the preview is in the old.
241
+ const names = skipped
242
+ .map((entry) => skippedPreview(conversationKey, String(entry?.messageId || '').trim()))
243
+ .map((preview) => (preview ? `“${preview}”` : 'one message'))
244
+ .join(', ');
245
+ const lead = unhidden
246
+ ? `${unhidden} message${unhidden === 1 ? '' : 's'} unhidden. `
247
+ : '';
248
+ showTransientRelayNotice(`${lead}Still hidden until its turn finishes: ${names}.`, 8000);
249
+ } else {
250
+ showTransientRelayNotice(unhidden
251
+ ? `${unhidden} message${unhidden === 1 ? '' : 's'} visible to shared viewers.`
252
+ : 'No hidden messages left.');
253
+ }
254
+ if ('hiddenMessages' in result) {
255
+ setConversationHiddenMessages(conversationKey, result.hiddenMessages, result.hiddenMessagesRevision);
256
+ }
257
+ if (conversationKey === currentConversationId) rerenderOpenModal();
258
+ }
259
+
260
+ function handleHiddenModalClick(event) {
261
+ if (summaryModalState.kind !== HIDDEN_MESSAGES_MODAL_KIND) return;
262
+ const target = event.target instanceof Element ? event.target : null;
263
+ const unhideBtn = target?.closest('[data-hidden-unhide]');
264
+ if (unhideBtn) {
265
+ event.preventDefault();
266
+ void unhideHiddenMessage(currentConversationId, unhideBtn.getAttribute('data-hidden-unhide'));
267
+ return;
268
+ }
269
+ if (target?.closest('[data-hidden-unhide-all]')) {
270
+ event.preventDefault();
271
+ confirmingUnhideAll = true;
272
+ rerenderOpenModal();
273
+ return;
274
+ }
275
+ if (target?.closest('[data-hidden-unhide-all-cancel]')) {
276
+ event.preventDefault();
277
+ confirmingUnhideAll = false;
278
+ rerenderOpenModal();
279
+ return;
280
+ }
281
+ if (target?.closest('[data-hidden-unhide-all-confirm]')) {
282
+ event.preventDefault();
283
+ void unhideAllHiddenMessages(currentConversationId);
284
+ return;
285
+ }
286
+ const jumpBtn = target?.closest('[data-hidden-jump]');
287
+ if (jumpBtn) {
288
+ event.preventDefault();
289
+ // The pin list's jump: scroll there, or load the window around it.
290
+ void jumpToPinnedMessage(jumpBtn.getAttribute('data-hidden-jump'));
291
+ }
292
+ }
293
+
294
+ export function initHiddenMessagesView(nextHandlers = {}) {
295
+ handlers = { ...handlers, ...nextHandlers };
296
+ const button = document.getElementById('hidden-messages-btn');
297
+ if (button && button.dataset.bound !== '1') {
298
+ button.dataset.bound = '1';
299
+ button.addEventListener('click', (event) => {
300
+ event.preventDefault();
301
+ openHiddenMessagesModal();
302
+ });
303
+ }
304
+ // #summary-modal-body outlives every modal, so it is bound exactly once; the
305
+ // handler no-ops unless the hidden list is the modal currently mounted.
306
+ const modalBody = document.getElementById('summary-modal-body');
307
+ if (modalBody && !modalBodyBound) {
308
+ modalBodyBound = true;
309
+ modalBody.addEventListener('click', handleHiddenModalClick);
310
+ }
311
+ syncHiddenHeaderButton();
312
+ }
@@ -170,7 +170,13 @@ async function runCancelRelayShutdown() {
170
170
  cancelInFlight = true;
171
171
  renderPendingActionBanner();
172
172
  try {
173
- const result = await cancelRelayRestart();
173
+ let result = null;
174
+ try {
175
+ result = await cancelRelayRestart();
176
+ } catch (error) {
177
+ showTransientRelayNotice(`Failed to cancel the queued relay restart: ${error?.message || 'unknown error'}`, 6000);
178
+ return;
179
+ }
174
180
  if (!result?.ok) {
175
181
  showTransientRelayNotice('Failed to cancel the queued relay restart.', 5000);
176
182
  return;