@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,480 @@
1
+ // Settings → Sharing: every conversation with a public link, its access log,
2
+ // and the actions on a link (copy, renew, stop). The ⋯ menu's "Stop sharing"
3
+ // uses the same confirmation. The list lives in share-state.js; the socket's
4
+ // share events keep it current while the modal is open.
5
+
6
+ import { escHtml, openSummaryModal, closeSummaryModal, showTransientRelayNotice } from './store.js';
7
+ import {
8
+ countStoredViewerIps,
9
+ createConversationShareLink,
10
+ forgetStoredViewerIps,
11
+ loadConversationShareOpens,
12
+ loadSharePathPrivacySetting,
13
+ renewConversationShareLink,
14
+ stopSharingConversation,
15
+ updateSharePathPrivacySetting,
16
+ updateShareSettings,
17
+ } from './api-client.js';
18
+ import { copyTextToClipboard } from './router.js';
19
+ import {
20
+ applyShareSettings,
21
+ forgetShare,
22
+ getShareSettings,
23
+ getShares,
24
+ refreshShares,
25
+ sharesLoaded,
26
+ subscribeShares,
27
+ } from './share-state.js';
28
+ import { describeShare, describeShareOpen } from './share-format.mjs';
29
+
30
+ // conversation id -> { loading, error, opens, total, limit }
31
+ const expandedLogs = new Map();
32
+ const actionsInFlight = new Set();
33
+ let storeIpToggleBound = false;
34
+ let pathPrivacyBound = false;
35
+ // The mode on screen is the one last read or saved.
36
+ let savedPathPrivacyMode = '';
37
+
38
+ function settingsModalOpen() {
39
+ return !!document.getElementById('settings-modal')?.classList.contains('visible');
40
+ }
41
+
42
+ function setStatus(text) {
43
+ const status = document.getElementById('settings-shares-status');
44
+ if (status) status.textContent = String(text || '');
45
+ }
46
+
47
+ function actionButton(label, className, onClick, { disabled = false } = {}) {
48
+ const button = document.createElement('button');
49
+ button.type = 'button';
50
+ button.className = className;
51
+ button.textContent = label;
52
+ button.disabled = disabled;
53
+ button.addEventListener('click', (event) => {
54
+ event.preventDefault();
55
+ onClick(button);
56
+ });
57
+ return button;
58
+ }
59
+
60
+ function buildOpensList(conversationId) {
61
+ const state = expandedLogs.get(conversationId);
62
+ const container = document.createElement('div');
63
+ container.className = 'share-opens';
64
+ container.dataset.conversationId = conversationId;
65
+ if (!state || (state.loading && !state.opens)) {
66
+ container.textContent = 'Loading the access log…';
67
+ return container;
68
+ }
69
+ if (state.error) {
70
+ container.textContent = `Could not load the access log: ${state.error}`;
71
+ return container;
72
+ }
73
+ const opens = state.opens || [];
74
+ if (!opens.length) {
75
+ container.textContent = 'Nobody has opened this link yet.';
76
+ return container;
77
+ }
78
+ for (const open of opens) {
79
+ const row = document.createElement('div');
80
+ row.className = 'share-open';
81
+ const meta = document.createElement('div');
82
+ meta.className = 'share-open-meta';
83
+ meta.textContent = describeShareOpen(open);
84
+ row.appendChild(meta);
85
+ // A viewer's user agent is whatever their client sent: text only.
86
+ const agent = document.createElement('div');
87
+ agent.className = 'share-open-agent';
88
+ agent.textContent = open.userAgent || 'no user agent';
89
+ row.appendChild(agent);
90
+ container.appendChild(row);
91
+ }
92
+ if (Number(state.total) > opens.length) {
93
+ const more = document.createElement('div');
94
+ more.className = 'settings-help';
95
+ more.textContent = `The newest ${opens.length} of ${state.total} opens.`;
96
+ container.appendChild(more);
97
+ }
98
+ return container;
99
+ }
100
+
101
+ function buildShareRow(share) {
102
+ const id = share.conversationId;
103
+ const busy = actionsInFlight.has(id);
104
+ const row = document.createElement('div');
105
+ row.className = 'share-row';
106
+ row.dataset.conversationId = id;
107
+
108
+ const head = document.createElement('div');
109
+ head.className = 'share-row-head';
110
+ row.appendChild(head);
111
+
112
+ const main = document.createElement('div');
113
+ main.className = 'share-row-main';
114
+ head.appendChild(main);
115
+
116
+ const title = document.createElement('button');
117
+ title.type = 'button';
118
+ title.className = 'share-row-title';
119
+ title.textContent = share.title || 'Untitled conversation';
120
+ title.title = 'Open this conversation';
121
+ title.addEventListener('click', (event) => {
122
+ event.preventDefault();
123
+ window.closeSettingsModal?.();
124
+ void window.openConversation?.(id, { restoreScroll: true });
125
+ });
126
+ main.appendChild(title);
127
+
128
+ const detail = document.createElement('span');
129
+ detail.className = 'preview-detail share-row-detail';
130
+ detail.textContent = describeShare(share);
131
+ main.appendChild(detail);
132
+
133
+ const actions = document.createElement('div');
134
+ actions.className = 'share-row-actions';
135
+ head.appendChild(actions);
136
+ actions.appendChild(actionButton('Copy link', 'share-copy', (button) => {
137
+ void copyShareLink(share.shareUrl, button);
138
+ }));
139
+ actions.appendChild(actionButton('Renew link', 'share-renew', () => {
140
+ openRenewShareConfirmation(id, share.title);
141
+ }, { disabled: busy }));
142
+ actions.appendChild(actionButton('Stop sharing', 'share-stop', () => {
143
+ openStopSharingConfirmation(id, share.title);
144
+ }, { disabled: busy }));
145
+ const expanded = expandedLogs.has(id);
146
+ const toggle = actionButton(expanded ? 'Hide access log' : 'Access log', 'share-log-toggle', () => {
147
+ toggleAccessLog(id);
148
+ });
149
+ toggle.setAttribute('aria-expanded', expanded ? 'true' : 'false');
150
+ actions.appendChild(toggle);
151
+
152
+ if (expanded) row.appendChild(buildOpensList(id));
153
+ return row;
154
+ }
155
+
156
+ export function renderSharedConversationsSection() {
157
+ const list = document.getElementById('settings-shares-list');
158
+ const empty = document.getElementById('settings-shares-empty');
159
+ if (!list) return;
160
+ const shares = getShares();
161
+ // Logs of shares that are gone are dropped with them.
162
+ for (const id of expandedLogs.keys()) {
163
+ if (!shares.some((share) => share.conversationId === id)) expandedLogs.delete(id);
164
+ }
165
+ list.replaceChildren(...shares.map(buildShareRow));
166
+ if (empty) {
167
+ empty.hidden = shares.length > 0;
168
+ empty.textContent = sharesLoaded() ? 'No conversation is shared right now.' : 'Loading shared conversations…';
169
+ }
170
+ const toggle = document.getElementById('settings-shares-store-ip');
171
+ if (toggle) toggle.checked = getShareSettings().storeViewerIp;
172
+ }
173
+
174
+ async function loadAccessLog(conversationId) {
175
+ const previous = expandedLogs.get(conversationId) || {};
176
+ expandedLogs.set(conversationId, { ...previous, loading: true, error: '' });
177
+ try {
178
+ const payload = await loadConversationShareOpens(conversationId);
179
+ if (!expandedLogs.has(conversationId)) return;
180
+ expandedLogs.set(conversationId, {
181
+ loading: false,
182
+ error: '',
183
+ opens: Array.isArray(payload?.opens) ? payload.opens : [],
184
+ total: Number(payload?.total || 0),
185
+ limit: Number(payload?.limit || 0),
186
+ });
187
+ } catch (error) {
188
+ if (!expandedLogs.has(conversationId)) return;
189
+ expandedLogs.set(conversationId, { ...previous, loading: false, error: error?.message || 'unknown error' });
190
+ }
191
+ renderSharedConversationsSection();
192
+ }
193
+
194
+ function toggleAccessLog(conversationId) {
195
+ if (expandedLogs.has(conversationId)) {
196
+ expandedLogs.delete(conversationId);
197
+ renderSharedConversationsSection();
198
+ return;
199
+ }
200
+ expandedLogs.set(conversationId, { loading: true });
201
+ renderSharedConversationsSection();
202
+ void loadAccessLog(conversationId);
203
+ }
204
+
205
+ async function copyShareLink(url, button) {
206
+ const link = String(url || '').trim();
207
+ if (!link) return;
208
+ try {
209
+ const copied = await copyTextToClipboard(link);
210
+ if (!copied) throw new Error('not copied');
211
+ const previous = button.textContent;
212
+ button.textContent = 'Copied';
213
+ setTimeout(() => { button.textContent = previous; }, 1500);
214
+ } catch {
215
+ showTransientRelayNotice(link, 8000);
216
+ }
217
+ }
218
+
219
+ function bindStoreIpToggle() {
220
+ if (storeIpToggleBound) return;
221
+ const toggle = document.getElementById('settings-shares-store-ip');
222
+ if (!toggle) return;
223
+ storeIpToggleBound = true;
224
+ toggle.addEventListener('change', async () => {
225
+ const wanted = toggle.checked;
226
+ toggle.disabled = true;
227
+ setStatus('Saving…');
228
+ try {
229
+ const result = await updateShareSettings({ storeViewerIp: wanted });
230
+ if (result) applyShareSettings(result);
231
+ setStatus(wanted ? 'New log entries keep the viewer\'s IP address.' : 'New log entries keep no IP address.');
232
+ if (!wanted) void offerToForgetStoredViewerIps();
233
+ } catch (error) {
234
+ toggle.checked = !wanted;
235
+ setStatus(`Not saved: ${error?.message || 'unknown error'}`);
236
+ } finally {
237
+ toggle.disabled = false;
238
+ }
239
+ });
240
+ }
241
+
242
+ // "Paths and file names": what every shared link shows of the relay's paths
243
+ // (shared/share-path-privacy.mjs). The relay filters on its side; this only
244
+ // reads and writes the mode.
245
+ function pathPrivacyRadios() {
246
+ return [...document.querySelectorAll('input[name="settings-shares-path-privacy"]')];
247
+ }
248
+
249
+ function setPathPrivacyStatus(text) {
250
+ const status = document.getElementById('settings-shares-path-privacy-status');
251
+ if (status) status.textContent = String(text || '');
252
+ }
253
+
254
+ function showPathPrivacyMode(mode) {
255
+ for (const radio of pathPrivacyRadios()) radio.checked = radio.value === mode;
256
+ }
257
+
258
+ function bindPathPrivacy() {
259
+ if (pathPrivacyBound) return;
260
+ const radios = pathPrivacyRadios();
261
+ if (!radios.length) return;
262
+ pathPrivacyBound = true;
263
+ for (const radio of radios) {
264
+ radio.addEventListener('change', async () => {
265
+ if (!radio.checked) return;
266
+ const wanted = radio.value;
267
+ const previous = savedPathPrivacyMode;
268
+ for (const entry of radios) entry.disabled = true;
269
+ setPathPrivacyStatus('Saving…');
270
+ try {
271
+ const result = await updateSharePathPrivacySetting(wanted);
272
+ savedPathPrivacyMode = String(result?.mode || wanted);
273
+ showPathPrivacyMode(savedPathPrivacyMode);
274
+ setPathPrivacyStatus('Saved. Shared links show it from their next refresh.');
275
+ } catch (error) {
276
+ showPathPrivacyMode(previous);
277
+ setPathPrivacyStatus(`Not saved: ${error?.message || 'unknown error'}`);
278
+ } finally {
279
+ for (const entry of radios) entry.disabled = false;
280
+ }
281
+ });
282
+ }
283
+ }
284
+
285
+ async function refreshPathPrivacy() {
286
+ bindPathPrivacy();
287
+ try {
288
+ const result = await loadSharePathPrivacySetting();
289
+ const mode = String(result?.mode || '');
290
+ if (!mode) return;
291
+ savedPathPrivacyMode = mode;
292
+ showPathPrivacyMode(mode);
293
+ } catch (error) {
294
+ setPathPrivacyStatus(`Could not read the setting: ${error?.message || 'unknown error'}`);
295
+ }
296
+ }
297
+
298
+ // IP storage just went off: the addresses logged before can go too. Asked
299
+ // only when there are any; No leaves them.
300
+ async function offerToForgetStoredViewerIps() {
301
+ let count = 0;
302
+ try {
303
+ count = Number((await countStoredViewerIps())?.count || 0);
304
+ } catch {
305
+ return;
306
+ }
307
+ if (!(count > 0)) return;
308
+ openSummaryModal({
309
+ title: 'Stored IP addresses',
310
+ kind: 'share-forget-ips',
311
+ bodyHtml: confirmationBody({
312
+ question: `Also remove the ${count} IP address${count === 1 ? '' : 'es'} already stored?`,
313
+ detail: 'They go from every access log entry and from the status log of shared reads. The entries stay, and viewers are still counted apart.',
314
+ confirmLabel: 'Remove them',
315
+ cancelLabel: 'Keep them',
316
+ confirmId: 'share-forget-ips-confirm',
317
+ danger: true,
318
+ }),
319
+ });
320
+ bindConfirmation('share-forget-ips-confirm', async () => {
321
+ try {
322
+ await forgetStoredViewerIps();
323
+ setStatus('New log entries keep no IP address, and the stored ones are removed.');
324
+ } catch (error) {
325
+ setStatus(`The stored addresses were not removed: ${error?.message || 'unknown error'}`);
326
+ }
327
+ reloadExpandedAccessLogs();
328
+ });
329
+ }
330
+
331
+ function reloadExpandedAccessLogs() {
332
+ for (const id of expandedLogs.keys()) void loadAccessLog(id);
333
+ }
334
+
335
+ /** The stored addresses were removed (here or on another device). */
336
+ export function handleViewerIpsForgotten() {
337
+ if (settingsModalOpen()) reloadExpandedAccessLogs();
338
+ }
339
+
340
+ /** Settings opened: read the list again (the socket may have missed events). */
341
+ export async function refreshSharedConversationsSection() {
342
+ bindStoreIpToggle();
343
+ setPathPrivacyStatus('');
344
+ void refreshPathPrivacy();
345
+ setStatus('');
346
+ renderSharedConversationsSection();
347
+ try {
348
+ await refreshShares();
349
+ } catch (error) {
350
+ const empty = document.getElementById('settings-shares-empty');
351
+ if (empty && !getShares().length) {
352
+ empty.hidden = false;
353
+ empty.textContent = `Could not load the shared conversations: ${error?.message || 'unknown error'}`;
354
+ }
355
+ return;
356
+ }
357
+ for (const id of expandedLogs.keys()) void loadAccessLog(id);
358
+ }
359
+
360
+ /** A share was opened somewhere: new counts, and the open log if shown. */
361
+ export function handleShareOpened(conversationId) {
362
+ void refreshShares().catch(() => {});
363
+ const id = String(conversationId || '').trim();
364
+ if (id && expandedLogs.has(id) && settingsModalOpen()) void loadAccessLog(id);
365
+ }
366
+
367
+ function confirmationBody({ question, detail, confirmLabel, confirmId, danger, cancelLabel = 'Cancel' }) {
368
+ return `
369
+ <p>${question}</p>
370
+ <p>${detail}</p>
371
+ <div class="summary-modal-actions">
372
+ <button id="${confirmId}" class="chat-title-action-btn${danger ? ' danger-btn' : ''}" type="button">${confirmLabel}</button>
373
+ <button class="chat-title-action-btn" type="button" data-share-confirm-cancel>${cancelLabel}</button>
374
+ </div>
375
+ `;
376
+ }
377
+
378
+ function bindConfirmation(confirmId, run) {
379
+ const body = document.getElementById('summary-modal-body');
380
+ const confirm = document.getElementById(confirmId);
381
+ body?.querySelector('[data-share-confirm-cancel]')?.addEventListener('click', () => closeSummaryModal());
382
+ confirm?.addEventListener('click', async () => {
383
+ if (confirm.disabled) return;
384
+ confirm.disabled = true;
385
+ try {
386
+ await run();
387
+ } finally {
388
+ closeSummaryModal();
389
+ }
390
+ });
391
+ }
392
+
393
+ async function runShareAction(conversationId, action) {
394
+ if (actionsInFlight.has(conversationId)) return;
395
+ actionsInFlight.add(conversationId);
396
+ renderSharedConversationsSection();
397
+ try {
398
+ await action();
399
+ } finally {
400
+ actionsInFlight.delete(conversationId);
401
+ renderSharedConversationsSection();
402
+ }
403
+ }
404
+
405
+ export function openStopSharingConfirmation(conversationId, title = '') {
406
+ const id = String(conversationId || '').trim();
407
+ if (!id) return;
408
+ const name = escHtml(String(title || '').trim() || 'this conversation');
409
+ openSummaryModal({
410
+ title: 'Stop sharing',
411
+ kind: 'share-stop',
412
+ bodyHtml: confirmationBody({
413
+ question: `Stop sharing <strong>${name}</strong>?`,
414
+ detail: 'The link stops working at once, also for anyone reading it right now, and its access log is deleted.',
415
+ confirmLabel: '⛔ Stop sharing',
416
+ confirmId: 'share-stop-confirm',
417
+ danger: true,
418
+ }),
419
+ });
420
+ bindConfirmation('share-stop-confirm', () => runShareAction(id, async () => {
421
+ try {
422
+ await stopSharingConversation(id);
423
+ forgetShare(id);
424
+ expandedLogs.delete(id);
425
+ showTransientRelayNotice('Sharing stopped. The link no longer works.');
426
+ } catch (error) {
427
+ showTransientRelayNotice(`Could not stop sharing: ${error?.message || 'unknown error'}`);
428
+ }
429
+ await refreshShares().catch(() => {});
430
+ }));
431
+ }
432
+
433
+ export function openRenewShareConfirmation(conversationId, title = '') {
434
+ const id = String(conversationId || '').trim();
435
+ if (!id) return;
436
+ const name = escHtml(String(title || '').trim() || 'this conversation');
437
+ openSummaryModal({
438
+ title: 'Renew link',
439
+ kind: 'share-renew',
440
+ bodyHtml: confirmationBody({
441
+ question: `Give <strong>${name}</strong> a new link?`,
442
+ detail: 'The current link stops working at once. The access log carries over to the new link.',
443
+ confirmLabel: '🔁 Renew link',
444
+ confirmId: 'share-renew-confirm',
445
+ danger: false,
446
+ }),
447
+ });
448
+ bindConfirmation('share-renew-confirm', () => runShareAction(id, async () => {
449
+ try {
450
+ const result = await renewConversationShareLink(id);
451
+ const url = String(result?.shareUrl || '').trim();
452
+ let copied = false;
453
+ try { copied = url ? await copyTextToClipboard(url) : false; } catch {}
454
+ showTransientRelayNotice(copied
455
+ ? 'New link copied to clipboard. The old link no longer works.'
456
+ : `New link: ${url}`, copied ? 4000 : 10000);
457
+ } catch (error) {
458
+ showTransientRelayNotice(`Could not renew the link: ${error?.message || 'unknown error'}`);
459
+ }
460
+ await refreshShares().catch(() => {});
461
+ }));
462
+ }
463
+
464
+ /** ⋯ → Share conversation: the link, made if there is none, to the clipboard. */
465
+ export async function shareConversationFromMenu(conversationId) {
466
+ const result = await createConversationShareLink(conversationId);
467
+ const shareUrl = String(result?.shareUrl || '').trim();
468
+ if (!shareUrl) {
469
+ showTransientRelayNotice(result?.error || 'Could not create share link.');
470
+ return;
471
+ }
472
+ const copied = await copyTextToClipboard(shareUrl).catch(() => false);
473
+ showTransientRelayNotice(copied ? 'Share link copied to clipboard.' : shareUrl);
474
+ void refreshShares().catch(() => {});
475
+ }
476
+
477
+ subscribeShares(() => {
478
+ if (!settingsModalOpen()) return;
479
+ renderSharedConversationsSection();
480
+ });