@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.
- package/CHANGELOG.md +440 -1
- package/README.md +156 -972
- package/package.json +13 -13
- package/server/README.md +112 -25
- package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +57 -4
- package/server/claude-worker/claude-session-process.mjs +217 -11
- package/server/claude-worker/claude-session-worker.mjs +26 -1
- package/server/claude-worker/task-output-discovery.mjs +126 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +235 -14
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -0
- package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
- package/server/copilot-worker/copilot-task-settle.mjs +96 -0
- package/server/db-schema.mjs +68 -2
- package/server/grok-worker/acp-client.mjs +2 -1
- package/server/grok-worker/acp-host-services.mjs +46 -6
- package/server/grok-worker/grok-background-tasks.mjs +241 -0
- package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
- package/server/grok-worker/grok-session-worker.mjs +33 -1
- package/server/grok-worker/grok-turn-runner.mjs +11 -0
- package/server/migrations/0013-previews.mjs +130 -0
- package/server/migrations/0014-conversation-groups.mjs +89 -0
- package/server/migrations/0015-conversation-group-position.mjs +76 -0
- package/server/migrations/0016-share-access-log.mjs +110 -0
- package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
- package/server/public/app/about-ui.js +73 -0
- package/server/public/app/about-view.mjs +234 -0
- package/server/public/app/action-confirmations.js +31 -14
- package/server/public/app/agent-exchange-fold-view.mjs +40 -101
- package/server/public/app/api-client.js +264 -16
- package/server/public/app/ask-user-view.js +17 -15
- package/server/public/app/background-tasks-view.mjs +285 -16
- package/server/public/app/bootstrap.js +253 -37
- package/server/public/app/chat-actions-menu-state.mjs +20 -10
- package/server/public/app/claude-background-command-limit-options.mjs +21 -0
- package/server/public/app/context-menu.mjs +90 -6
- package/server/public/app/conversation-group-drag.mjs +191 -0
- package/server/public/app/conversation-group-order.mjs +265 -0
- package/server/public/app/conversation-group-state.mjs +100 -0
- package/server/public/app/conversation-list-drag.mjs +449 -0
- package/server/public/app/conversation-payload-cache.mjs +357 -0
- package/server/public/app/conversation-turn-state.mjs +81 -0
- package/server/public/app/conversation-view.js +567 -94
- package/server/public/app/conversation-window-retention.mjs +96 -0
- package/server/public/app/cwd-picker.js +1 -1
- package/server/public/app/hidden-messages-view.mjs +312 -0
- package/server/public/app/host-suspend-ui.js +7 -1
- package/server/public/app/journal-view.js +1033 -82
- package/server/public/app/preview-cards.mjs +117 -31
- package/server/public/app/preview-command.mjs +10 -1
- package/server/public/app/relay-board-view.js +19 -6
- package/server/public/app/relay-prompt-sanitizer.mjs +11 -4
- package/server/public/app/relay-restart-error.mjs +16 -0
- package/server/public/app/settings-modal.js +150 -10
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/share-format.mjs +49 -0
- package/server/public/app/share-preview-marks.mjs +167 -0
- package/server/public/app/share-state.js +109 -0
- package/server/public/app/shared-conversations-settings.js +480 -0
- package/server/public/app/socket-handlers.js +138 -20
- package/server/public/app/store.js +121 -30
- package/server/public/app/subagent-row.mjs +3 -3
- package/server/public/app/subagent-viewer.mjs +37 -7
- package/server/public/app/task-console-render.mjs +237 -0
- package/server/public/app/task-console-watch.mjs +42 -0
- package/server/public/app/task-console.mjs +630 -0
- package/server/public/app/task-output-text.mjs +234 -0
- package/server/public/app/thinking-follow.js +0 -4
- package/server/public/app/transcript-island.mjs +73 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +230 -26
- package/server/public/app/transcript-jump-bottom.mjs +170 -22
- package/server/public/app/transcript-reading-anchor.mjs +256 -0
- package/server/public/app/transcript-reload-freshness.mjs +17 -1
- package/server/public/app/transcript-viewport-anchor.mjs +159 -0
- package/server/public/index.html +625 -21
- package/server/relay-tools.md +3 -1
- package/server/relay.mjs +2 -1
- package/server/repositories/conversation-group-repository.mjs +444 -0
- package/server/repositories/message-repository.mjs +9 -0
- package/server/repositories/question-repository.mjs +4 -0
- package/server/repositories/session-repository.mjs +12 -37
- package/server/repositories/share-repository.mjs +322 -0
- package/server/repositories/share-visibility-statements.mjs +10 -0
- package/server/routes/about-routes.mjs +15 -0
- package/server/routes/cache-routes.mjs +4 -0
- package/server/routes/conversation-group-routes.mjs +171 -0
- package/server/routes/messages-routes.mjs +433 -68
- package/server/routes/preview-routes.mjs +9 -1
- package/server/routes/sessions-routes.mjs +901 -253
- package/server/server-runtime.mjs +392 -99
- package/server/services/about-service.mjs +461 -0
- package/server/services/background-task-store.mjs +365 -0
- package/server/services/background-work-view.mjs +228 -0
- package/server/services/cache-rebuild-service.mjs +30 -0
- package/server/services/cli-process-runner.mjs +4 -21
- package/server/services/conversation-history-page.mjs +199 -0
- package/server/services/delete-archive-service.mjs +4 -1
- package/server/services/detached-worker-watch-service.mjs +150 -0
- package/server/services/hidden-message-list.mjs +21 -0
- package/server/services/host-suspend-command.mjs +2 -1
- package/server/services/message-pin-list.mjs +1 -1
- package/server/services/preview-health-probe.mjs +8 -1
- package/server/services/preview-proxy-server.mjs +8 -4
- package/server/services/preview-registry-service.mjs +676 -26
- package/server/services/preview-static-handler.mjs +40 -7
- package/server/services/preview-store.mjs +274 -0
- package/server/services/rate-limit-service.mjs +12 -1
- package/server/services/relay-auth.mjs +183 -0
- package/server/services/relay-cli-launcher-service.mjs +3 -1
- package/server/services/relay-event-service.mjs +17 -0
- package/server/services/relay-prompt-sanitizer.mjs +11 -4
- package/server/services/remote-relay-dispatcher.mjs +187 -7
- package/server/services/request-boundary.mjs +183 -0
- package/server/services/session-activity-tracker.mjs +231 -19
- package/server/services/session-worker-launch-service.mjs +190 -6
- package/server/services/session-worker-process-service.mjs +10 -6
- package/server/services/share-access-log-service.mjs +123 -0
- package/server/services/share-path-redaction.mjs +424 -0
- package/server/services/status-event-service.mjs +7 -3
- package/server/services/task-output-rooms.mjs +259 -0
- package/server/services/task-output-service.mjs +562 -0
- package/server/services/tunnel-worker-path-guard.mjs +24 -41
- package/server/services/windows-autostart-service.mjs +6 -1
- package/server/services/windows-boot-autostart-service.mjs +207 -23
- package/shared/claude-background-command-limit.mjs +83 -0
- package/shared/preview-tool-core.mjs +15 -5
- package/shared/remote-relay-contract.mjs +35 -3
- package/shared/share-path-privacy.mjs +30 -0
- package/shared/task-output-text.mjs +737 -0
- package/shared/windows-system-tools.mjs +106 -0
- package/shared/worker-runtime/background-task-digest.mjs +30 -0
- package/shared/worker-runtime/heartbeat.mjs +24 -0
- package/shared/worker-runtime/kept-posts.mjs +83 -0
- package/shared/worker-runtime/task-output-spool.mjs +181 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
|
@@ -29,6 +29,8 @@ import {
|
|
|
29
29
|
updateTurnCeilingSetting as requestTurnCeilingSetting,
|
|
30
30
|
loadBackgroundTaskTimeoutSetting,
|
|
31
31
|
updateBackgroundTaskTimeoutSetting as requestBackgroundTaskTimeoutSetting,
|
|
32
|
+
loadTaskStallMinutesSetting,
|
|
33
|
+
updateTaskStallMinutesSetting as requestTaskStallMinutesSetting,
|
|
32
34
|
loadSubagentRecordRetentionSetting,
|
|
33
35
|
updateSubagentRecordRetentionSetting as requestSubagentRecordRetentionSetting,
|
|
34
36
|
loadFeatureFlagSettings,
|
|
@@ -36,6 +38,13 @@ import {
|
|
|
36
38
|
requestRelayRestart,
|
|
37
39
|
} from './api-client.js';
|
|
38
40
|
import { DEFAULT_BACKGROUND_TASK_TIMEOUT_MINUTES } from './background-task-timeout-options.mjs';
|
|
41
|
+
import {
|
|
42
|
+
DEFAULT_CLAUDE_BACKGROUND_COMMAND_LIMIT_MINUTES,
|
|
43
|
+
formatClaudeBackgroundCommandLimitLabel,
|
|
44
|
+
} from './claude-background-command-limit-options.mjs';
|
|
45
|
+
import { DEFAULT_TASK_STALL_MINUTES } from './task-console-render.mjs';
|
|
46
|
+
import { setTaskStallThreshold } from './background-tasks-view.mjs';
|
|
47
|
+
import { describeRelayRestartError } from './relay-restart-error.mjs';
|
|
39
48
|
import { syncFontScaleSelect } from './font-scaling.js';
|
|
40
49
|
import { syncInstalledAppUiState, syncPwaAppNameInput } from './pwa-install.js';
|
|
41
50
|
import { normalizeKnownCwdPath } from './cwd-picker.js';
|
|
@@ -48,6 +57,9 @@ import { refreshCliInstallSections } from './cli-install-ui.js';
|
|
|
48
57
|
import { refreshUpdateSection } from './update-ui.js';
|
|
49
58
|
import { refreshRemoteRelaysSection } from './remote-relays-settings.js';
|
|
50
59
|
import { refreshAgentSessionsSection } from './agent-sessions-settings-ui.js';
|
|
60
|
+
import { refreshSharedConversationsSection } from './shared-conversations-settings.js';
|
|
61
|
+
import { syncScrollOnSendToggle } from './transcript-jump-bottom.mjs';
|
|
62
|
+
import { syncWindowRetentionSlider } from './conversation-window-retention.mjs';
|
|
51
63
|
import {
|
|
52
64
|
getPreviews,
|
|
53
65
|
renderPreviewRowsInto,
|
|
@@ -199,6 +211,74 @@ export async function refreshBackgroundTaskTimeoutSetting() {
|
|
|
199
211
|
}
|
|
200
212
|
}
|
|
201
213
|
|
|
214
|
+
// Minutes without output after which a background task's row turns amber.
|
|
215
|
+
// Starts at the relay's default; bounds come from the API response.
|
|
216
|
+
let taskStallMinutes = DEFAULT_TASK_STALL_MINUTES;
|
|
217
|
+
let taskStallMinutesUpdateInFlight = false;
|
|
218
|
+
|
|
219
|
+
export function formatTaskStallMinutesLabel(value) {
|
|
220
|
+
const minutes = Math.max(1, Math.round(Number(value) || DEFAULT_TASK_STALL_MINUTES));
|
|
221
|
+
return `${minutes} min`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function syncTaskStallMinutesSlider() {
|
|
225
|
+
const slider = document.getElementById('task-stall-minutes-slider');
|
|
226
|
+
const label = document.getElementById('task-stall-minutes-value');
|
|
227
|
+
if (slider instanceof HTMLInputElement) {
|
|
228
|
+
slider.value = String(taskStallMinutes);
|
|
229
|
+
slider.disabled = taskStallMinutesUpdateInFlight;
|
|
230
|
+
}
|
|
231
|
+
if (label) label.textContent = formatTaskStallMinutesLabel(taskStallMinutes);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Live label feedback while dragging; nothing is persisted until change fires. */
|
|
235
|
+
export function previewTaskStallMinutesSetting(value) {
|
|
236
|
+
const label = document.getElementById('task-stall-minutes-value');
|
|
237
|
+
if (label) label.textContent = formatTaskStallMinutesLabel(value);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export async function refreshTaskStallMinutesSetting() {
|
|
241
|
+
syncTaskStallMinutesSlider();
|
|
242
|
+
try {
|
|
243
|
+
const result = await loadTaskStallMinutesSetting();
|
|
244
|
+
if (!result || !Number.isFinite(Number(result.minutes))) return;
|
|
245
|
+
taskStallMinutes = Number(result.minutes);
|
|
246
|
+
setTaskStallThreshold(taskStallMinutes);
|
|
247
|
+
const slider = document.getElementById('task-stall-minutes-slider');
|
|
248
|
+
if (slider instanceof HTMLInputElement) {
|
|
249
|
+
if (Number.isFinite(Number(result.min))) slider.min = String(result.min);
|
|
250
|
+
if (Number.isFinite(Number(result.max))) slider.max = String(result.max);
|
|
251
|
+
}
|
|
252
|
+
} catch {
|
|
253
|
+
// Keep the last known value; the setting is not critical.
|
|
254
|
+
} finally {
|
|
255
|
+
syncTaskStallMinutesSlider();
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export async function updateTaskStallMinutesSetting(value) {
|
|
260
|
+
if (taskStallMinutesUpdateInFlight) {
|
|
261
|
+
syncTaskStallMinutesSlider();
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
const requested = Math.max(1, Math.round(Number(value) || DEFAULT_TASK_STALL_MINUTES));
|
|
265
|
+
taskStallMinutesUpdateInFlight = true;
|
|
266
|
+
syncTaskStallMinutesSlider();
|
|
267
|
+
try {
|
|
268
|
+
const result = await requestTaskStallMinutesSetting(requested);
|
|
269
|
+
if (!result) throw new Error('Failed to update when a background task counts as quiet.');
|
|
270
|
+
taskStallMinutes = Number.isFinite(Number(result.minutes)) ? Number(result.minutes) : requested;
|
|
271
|
+
// The open conversation's rows follow at once, not on the next load.
|
|
272
|
+
setTaskStallThreshold(taskStallMinutes);
|
|
273
|
+
showTransientRelayNotice(`Background tasks are marked as quiet after ${formatTaskStallMinutesLabel(taskStallMinutes)} without output.`);
|
|
274
|
+
} catch (error) {
|
|
275
|
+
alert(error?.message || 'Failed to update when a background task counts as quiet.');
|
|
276
|
+
} finally {
|
|
277
|
+
taskStallMinutesUpdateInFlight = false;
|
|
278
|
+
syncTaskStallMinutesSlider();
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
202
282
|
// Mirrors shared/subagent-record-retention.mjs (the browser cannot import it):
|
|
203
283
|
// the select starts at the relay's default until the relay's value loads.
|
|
204
284
|
const DEFAULT_SUBAGENT_RECORD_RETENTION_DAYS = 90;
|
|
@@ -476,6 +556,7 @@ let claudeSettingsState = {
|
|
|
476
556
|
enabled: false,
|
|
477
557
|
model: 'claude-sonnet-5',
|
|
478
558
|
models: [],
|
|
559
|
+
backgroundCommandLimitMinutes: DEFAULT_CLAUDE_BACKGROUND_COMMAND_LIMIT_MINUTES,
|
|
479
560
|
};
|
|
480
561
|
let claudeSettingsInputsDirty = false;
|
|
481
562
|
|
|
@@ -489,7 +570,7 @@ function ensureClaudeSettingsInputTracking() {
|
|
|
489
570
|
}
|
|
490
571
|
|
|
491
572
|
function setClaudeSettingsControlsDisabled(disabled) {
|
|
492
|
-
for (const id of ['claude-model-input', 'claude-enabled-toggle', 'claude-attribution-select', 'claude-save-btn']) {
|
|
573
|
+
for (const id of ['claude-model-input', 'claude-enabled-toggle', 'claude-attribution-select', 'claude-background-command-limit-slider', 'claude-save-btn']) {
|
|
493
574
|
const element = document.getElementById(id);
|
|
494
575
|
if (element) element.disabled = disabled;
|
|
495
576
|
}
|
|
@@ -504,6 +585,9 @@ export function applyClaudeSettingsState(settings = {}, { resetInputs = false }
|
|
|
504
585
|
models: Array.isArray(settings?.models) ? settings.models : claudeSettingsState.models,
|
|
505
586
|
attributionMode: ['oar', 'vanilla', 'off'].includes(settings?.attributionMode) ? settings.attributionMode : (claudeSettingsState.attributionMode || 'oar'),
|
|
506
587
|
attributionExample: String(settings?.attributionExample || claudeSettingsState.attributionExample || '').trim(),
|
|
588
|
+
backgroundCommandLimitMinutes: Number(settings?.backgroundCommandLimitMinutes) > 0
|
|
589
|
+
? Number(settings.backgroundCommandLimitMinutes)
|
|
590
|
+
: (claudeSettingsState.backgroundCommandLimitMinutes || DEFAULT_CLAUDE_BACKGROUND_COMMAND_LIMIT_MINUTES),
|
|
507
591
|
};
|
|
508
592
|
const modelInput = document.getElementById('claude-model-input');
|
|
509
593
|
const toggle = document.getElementById('claude-enabled-toggle');
|
|
@@ -511,6 +595,15 @@ export function applyClaudeSettingsState(settings = {}, { resetInputs = false }
|
|
|
511
595
|
const attributionSelect = document.getElementById('claude-attribution-select');
|
|
512
596
|
if (attributionSelect && (!claudeSettingsInputsDirty || resetInputs)) attributionSelect.value = claudeSettingsState.attributionMode;
|
|
513
597
|
renderClaudeAttributionExample(attributionSelect?.value || claudeSettingsState.attributionMode);
|
|
598
|
+
const limitSlider = document.getElementById('claude-background-command-limit-slider');
|
|
599
|
+
if (limitSlider instanceof HTMLInputElement) {
|
|
600
|
+
// Bounds come from the relay; the markup's are only the pre-load copy.
|
|
601
|
+
for (const [attribute, field] of [['min', 'backgroundCommandLimitMinMinutes'], ['max', 'backgroundCommandLimitMaxMinutes'], ['step', 'backgroundCommandLimitStepMinutes']]) {
|
|
602
|
+
if (Number(settings?.[field]) > 0) limitSlider[attribute] = String(settings[field]);
|
|
603
|
+
}
|
|
604
|
+
if (!claudeSettingsInputsDirty || resetInputs) limitSlider.value = String(claudeSettingsState.backgroundCommandLimitMinutes);
|
|
605
|
+
}
|
|
606
|
+
renderClaudeBackgroundCommandLimitLabel(limitSlider?.value || claudeSettingsState.backgroundCommandLimitMinutes);
|
|
514
607
|
if (modelInput && (!claudeSettingsInputsDirty || resetInputs)) modelInput.value = claudeSettingsState.model;
|
|
515
608
|
if (resetInputs) claudeSettingsInputsDirty = false;
|
|
516
609
|
if (toggle) {
|
|
@@ -558,6 +651,19 @@ function renderClaudeAttributionExample(mode) {
|
|
|
558
651
|
example.textContent = claudeAttributionExampleText(mode, claudeSettingsState.attributionExample);
|
|
559
652
|
}
|
|
560
653
|
|
|
654
|
+
function renderClaudeBackgroundCommandLimitLabel(minutes) {
|
|
655
|
+
const label = document.getElementById('claude-background-command-limit-value');
|
|
656
|
+
if (label) label.textContent = formatClaudeBackgroundCommandLimitLabel(minutes);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/** Live label feedback while dragging; saved with the other Claude settings. */
|
|
660
|
+
export function previewClaudeBackgroundCommandLimit(value) {
|
|
661
|
+
claudeSettingsInputsDirty = true;
|
|
662
|
+
renderClaudeBackgroundCommandLimitLabel(value);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
window.previewClaudeBackgroundCommandLimit = previewClaudeBackgroundCommandLimit;
|
|
666
|
+
|
|
561
667
|
window.previewClaudeAttribution = (mode) => {
|
|
562
668
|
claudeSettingsInputsDirty = true;
|
|
563
669
|
renderClaudeAttributionExample(mode);
|
|
@@ -569,10 +675,12 @@ export async function saveClaudeSettings() {
|
|
|
569
675
|
const model = String(modelInput?.value || '').trim() || 'claude-sonnet-5';
|
|
570
676
|
const attributionSelect = document.getElementById('claude-attribution-select');
|
|
571
677
|
const attributionMode = ['oar', 'vanilla', 'off'].includes(attributionSelect?.value) ? attributionSelect.value : undefined;
|
|
678
|
+
const limitSlider = document.getElementById('claude-background-command-limit-slider');
|
|
679
|
+
const backgroundCommandLimitMinutes = Number(limitSlider?.value) > 0 ? Number(limitSlider.value) : undefined;
|
|
572
680
|
claudeSettingsUpdateInFlight = true;
|
|
573
681
|
setClaudeSettingsControlsDisabled(true);
|
|
574
682
|
try {
|
|
575
|
-
const result = await updateClaudeSettings({ model, attributionMode });
|
|
683
|
+
const result = await updateClaudeSettings({ model, attributionMode, backgroundCommandLimitMinutes });
|
|
576
684
|
if (!result) throw new Error('Failed to save Claude settings.');
|
|
577
685
|
applyClaudeSettingsState(result, { resetInputs: true });
|
|
578
686
|
showTransientRelayNotice(
|
|
@@ -1337,15 +1445,33 @@ export async function removeOpenAISettings() {
|
|
|
1337
1445
|
}
|
|
1338
1446
|
}
|
|
1339
1447
|
|
|
1448
|
+
const WINDOWS_BOOT_CUSTOMISATION_LABELS = Object.freeze({
|
|
1449
|
+
action: 'what it runs',
|
|
1450
|
+
'working-directory': 'working folder',
|
|
1451
|
+
'logon-type': 'logon type',
|
|
1452
|
+
trigger: 'trigger',
|
|
1453
|
+
'boot-delay': 'boot delay',
|
|
1454
|
+
launcher: 'launcher edited or from an older OAR',
|
|
1455
|
+
'launcher-missing': 'launcher missing',
|
|
1456
|
+
});
|
|
1457
|
+
|
|
1340
1458
|
function windowsAutostartStatusText() {
|
|
1341
1459
|
const boot = windowsAutostartState?.boot;
|
|
1342
1460
|
if (boot?.pendingElevation) {
|
|
1343
1461
|
return 'Confirm the admin prompt on the PC… (it only appears on the physical desktop)';
|
|
1344
1462
|
}
|
|
1345
1463
|
if (boot?.lastError) return boot.lastError;
|
|
1464
|
+
if (boot?.readError) return `The boot task could not be read: ${boot.readError}`;
|
|
1346
1465
|
if (windowsAutostartState?.mode === 'boot' && windowsAutostartState?.signinEnabled) {
|
|
1347
1466
|
return 'A sign-in Startup entry also exists and can double-start the relay. Re-select "At system startup" to clean it up.';
|
|
1348
1467
|
}
|
|
1468
|
+
if (windowsAutostartState?.mode === 'boot' && boot?.customised) {
|
|
1469
|
+
const labels = (boot.customisations || []).map((name) => WINDOWS_BOOT_CUSTOMISATION_LABELS[name] || name);
|
|
1470
|
+
return `The boot task or its launcher differs from what OAR generates (${labels.join(', ')}). Re-selecting "At system startup" regenerates both and keeps a changed launcher as a .bak copy.`;
|
|
1471
|
+
}
|
|
1472
|
+
if (windowsAutostartState?.mode === 'boot' && boot?.launcherBackupPath) {
|
|
1473
|
+
return `The previous launcher was replaced and kept as ${boot.launcherBackupPath}.`;
|
|
1474
|
+
}
|
|
1349
1475
|
if (boot?.taskStatus === 'foreign') {
|
|
1350
1476
|
return `A task named "${boot.taskName}" exists but was not created by OAR. Selecting "At system startup" replaces it.`;
|
|
1351
1477
|
}
|
|
@@ -1425,7 +1551,7 @@ export async function refreshWindowsAutostartSetting() {
|
|
|
1425
1551
|
}
|
|
1426
1552
|
windowsAutostartState = result;
|
|
1427
1553
|
} catch (error) {
|
|
1428
|
-
alert(error?.message
|
|
1554
|
+
alert(error?.message ? `Failed to read the Windows autostart setting: ${error.message}` : 'Failed to read the Windows autostart setting.');
|
|
1429
1555
|
} finally {
|
|
1430
1556
|
windowsAutostartUpdateInFlight = false;
|
|
1431
1557
|
syncWindowsAutostartSetting();
|
|
@@ -1459,7 +1585,7 @@ export async function updateWindowsAutostartMode(mode) {
|
|
|
1459
1585
|
);
|
|
1460
1586
|
}
|
|
1461
1587
|
} catch (error) {
|
|
1462
|
-
alert(error?.message
|
|
1588
|
+
alert(error?.message ? `Failed to update the Windows autostart setting: ${error.message}` : 'Failed to update the Windows autostart setting.');
|
|
1463
1589
|
} finally {
|
|
1464
1590
|
windowsAutostartUpdateInFlight = false;
|
|
1465
1591
|
syncWindowsAutostartSetting();
|
|
@@ -1579,9 +1705,17 @@ async function restartRelayForFeatureFlags() {
|
|
|
1579
1705
|
featureFlagsUpdateInFlight = true;
|
|
1580
1706
|
syncFeatureFlagsSection();
|
|
1581
1707
|
try {
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1708
|
+
let result = null;
|
|
1709
|
+
try {
|
|
1710
|
+
result = await requestRelayRestart({ reason: 'feature-flags-changed' });
|
|
1711
|
+
} catch (error) {
|
|
1712
|
+
const { kind, text } = describeRelayRestartError(error);
|
|
1713
|
+
if (kind === 'refused') alert(text);
|
|
1714
|
+
else showTransientRelayNotice(text, 9000);
|
|
1715
|
+
return;
|
|
1716
|
+
}
|
|
1717
|
+
if (!result?.ok) {
|
|
1718
|
+
alert('Failed to request a relay restart.');
|
|
1585
1719
|
return;
|
|
1586
1720
|
}
|
|
1587
1721
|
showTransientRelayNotice('Relay restart requested. It will restart once the running turns finish; queued messages wait for it.');
|
|
@@ -1684,14 +1818,15 @@ export async function updateDefaultSessionWorkspaceRootSetting(rawValue) {
|
|
|
1684
1818
|
}
|
|
1685
1819
|
|
|
1686
1820
|
// "Live previews": every preview across every session, not just this
|
|
1687
|
-
// conversation's. Previews never expire
|
|
1688
|
-
// close a link left behind in a conversation
|
|
1821
|
+
// conversation's. Previews never expire and survive a relay restart, so this
|
|
1822
|
+
// list is the one place that can close a link left behind in a conversation
|
|
1823
|
+
// you have moved on from; age and last use show which ones are stale.
|
|
1689
1824
|
export function renderPreviewsSection() {
|
|
1690
1825
|
const list = document.getElementById('settings-previews-list');
|
|
1691
1826
|
const empty = document.getElementById('settings-previews-empty');
|
|
1692
1827
|
if (!list) return;
|
|
1693
1828
|
const previews = getPreviews();
|
|
1694
|
-
renderPreviewRowsInto(list, previews, { withConversation: true });
|
|
1829
|
+
renderPreviewRowsInto(list, previews, { withConversation: true, withAge: true });
|
|
1695
1830
|
if (empty) {
|
|
1696
1831
|
empty.hidden = previews.length > 0;
|
|
1697
1832
|
empty.textContent = 'No previews are published right now.';
|
|
@@ -1726,6 +1861,8 @@ export function openSettingsModal(tab, providerTab) {
|
|
|
1726
1861
|
themeSelect.value = readLocalStorage(THEME_STORAGE_KEY) === 'light' ? 'light' : 'dark';
|
|
1727
1862
|
}
|
|
1728
1863
|
syncSuspendHostVisibility();
|
|
1864
|
+
syncScrollOnSendToggle();
|
|
1865
|
+
syncWindowRetentionSlider();
|
|
1729
1866
|
syncFontScaleSelect();
|
|
1730
1867
|
syncPwaAppNameInput();
|
|
1731
1868
|
syncDefaultSessionWorkspaceRootInput();
|
|
@@ -1758,10 +1895,13 @@ export function openSettingsModal(tab, providerTab) {
|
|
|
1758
1895
|
void refreshTurnCeilingSetting();
|
|
1759
1896
|
syncBackgroundTaskTimeoutSlider();
|
|
1760
1897
|
void refreshBackgroundTaskTimeoutSetting();
|
|
1898
|
+
syncTaskStallMinutesSlider();
|
|
1899
|
+
void refreshTaskStallMinutesSetting();
|
|
1761
1900
|
syncSubagentRecordRetentionSelect();
|
|
1762
1901
|
void refreshSubagentRecordRetentionSetting();
|
|
1763
1902
|
void refreshPushSettingsSection();
|
|
1764
1903
|
void refreshPreviewsSection();
|
|
1904
|
+
void refreshSharedConversationsSection();
|
|
1765
1905
|
void refreshRemoteRelaysSection();
|
|
1766
1906
|
void refreshAgentSessionsSection();
|
|
1767
1907
|
void refreshFeatureFlagsSection();
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
const SETTINGS_TAB_STORAGE_KEY = 'copilot_settings_tab';
|
|
2
2
|
const SETTINGS_PROVIDER_TAB_STORAGE_KEY = 'copilot_settings_provider_tab';
|
|
3
3
|
|
|
4
|
-
const SETTINGS_TABS = ['general', 'providers', 'relays', 'previews', 'notifications', 'features'];
|
|
4
|
+
const SETTINGS_TABS = ['general', 'providers', 'relays', 'previews', 'shares', 'notifications', 'features'];
|
|
5
5
|
// Copilot leads: it is the default provider, and `SETTINGS_PROVIDER_TABS[0]`
|
|
6
6
|
// is also the sub-tab a first-time visitor lands on.
|
|
7
7
|
const SETTINGS_PROVIDER_TABS = ['copilot', 'openai', 'claude', 'claude-cloud', 'grok', 'cursor'];
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Text for Settings → Sharing: a share row's detail line and an access log
|
|
2
|
+
// entry. DOM-free so it is unit-testable.
|
|
3
|
+
|
|
4
|
+
// The same steps as a preview's age (preview-cards.mjs), which this module
|
|
5
|
+
// cannot import without the API client.
|
|
6
|
+
function age(iso, now) {
|
|
7
|
+
const at = Date.parse(String(iso || ''));
|
|
8
|
+
if (!Number.isFinite(at) || at <= 0) return '';
|
|
9
|
+
const minutes = Math.floor(Math.max(0, now - at) / 60_000);
|
|
10
|
+
if (minutes < 1) return 'just now';
|
|
11
|
+
if (minutes < 60) return `${minutes} min ago`;
|
|
12
|
+
const hours = Math.floor(minutes / 60);
|
|
13
|
+
if (hours < 48) return `${hours} h ago`;
|
|
14
|
+
return `${Math.floor(hours / 24)} d ago`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function plural(count, one, many) {
|
|
18
|
+
return `${count} ${count === 1 ? one : many}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** "shared 3 d ago · new link 2 h ago · last opened 5 min ago · 4 opens · 2 viewers" */
|
|
22
|
+
export function describeShare(share, now = Date.now()) {
|
|
23
|
+
const parts = [];
|
|
24
|
+
const since = age(share?.sharedSince, now);
|
|
25
|
+
if (since) parts.push(`shared ${since}`);
|
|
26
|
+
const renewed = age(share?.renewedAt, now);
|
|
27
|
+
if (renewed) parts.push(`new link ${renewed}`);
|
|
28
|
+
const opened = age(share?.lastOpenedAt, now);
|
|
29
|
+
parts.push(opened ? `last opened ${opened}` : 'never opened');
|
|
30
|
+
const opens = Math.max(0, Math.trunc(Number(share?.openCount) || 0));
|
|
31
|
+
const viewers = Math.max(0, Math.trunc(Number(share?.viewerCount) || 0));
|
|
32
|
+
parts.push(plural(opens, 'open', 'opens'));
|
|
33
|
+
parts.push(plural(viewers, 'viewer', 'viewers'));
|
|
34
|
+
if (share?.archived) parts.push('archived');
|
|
35
|
+
return parts.join(' · ');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** "16 Oct 2026, 14:05 · DE · 203.0.113.7 · viewer 1a2b3c4d" */
|
|
39
|
+
export function describeShareOpen(open, { locale } = {}) {
|
|
40
|
+
const parts = [];
|
|
41
|
+
const at = Date.parse(String(open?.openedAt || ''));
|
|
42
|
+
if (Number.isFinite(at)) {
|
|
43
|
+
parts.push(new Date(at).toLocaleString(locale, { dateStyle: 'medium', timeStyle: 'short' }));
|
|
44
|
+
}
|
|
45
|
+
if (open?.country) parts.push(String(open.country));
|
|
46
|
+
parts.push(open?.ip ? String(open.ip) : 'IP not stored');
|
|
47
|
+
if (open?.viewer) parts.push(`viewer ${open.viewer}`);
|
|
48
|
+
return parts.join(' · ');
|
|
49
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// "Preview as shared": the relay marks, in the payload of its preview routes,
|
|
2
|
+
// every redaction (U+E000 … U+E001, the text a viewer gets in between) and
|
|
3
|
+
// every leftover that still looks like a path (U+E002 … U+E003). See
|
|
4
|
+
// server/services/share-path-redaction.mjs. This module turns the marks into
|
|
5
|
+
// highlights wherever the transcript put the text: reply markdown, thoughts,
|
|
6
|
+
// tool lines, code blocks, the sub-agent viewer. A highlight can be split by
|
|
7
|
+
// the elements a renderer put inside it (syntax highlighting); each piece is
|
|
8
|
+
// marked on its own. Marks inside attributes (a title, a link) are dropped.
|
|
9
|
+
|
|
10
|
+
export const PREVIEW_MARK_CHARS = Object.freeze({
|
|
11
|
+
redactedStart: '',
|
|
12
|
+
redactedEnd: '',
|
|
13
|
+
suspectStart: '',
|
|
14
|
+
suspectEnd: '',
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const ANY_MARK_RE = /[-]/;
|
|
18
|
+
const ALL_MARKS_RE = /[-]/g;
|
|
19
|
+
const BLOCK_SELECTOR = 'p, li, pre, div, td, th, h1, h2, h3, h4, h5, h6, blockquote, summary';
|
|
20
|
+
const MARK_KINDS = {
|
|
21
|
+
'': { open: 'redacted' },
|
|
22
|
+
'': { close: 'redacted' },
|
|
23
|
+
'': { open: 'suspect' },
|
|
24
|
+
'': { close: 'suspect' },
|
|
25
|
+
};
|
|
26
|
+
const CLASS_BY_KIND = {
|
|
27
|
+
redacted: 'share-preview-redacted',
|
|
28
|
+
suspect: 'share-preview-suspect',
|
|
29
|
+
};
|
|
30
|
+
const TITLE_BY_KIND = {
|
|
31
|
+
redacted: 'Hidden from shared viewers: they see this instead',
|
|
32
|
+
suspect: 'Still looks like a path: shared viewers see it as it is',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** The text without any mark. */
|
|
36
|
+
export function stripPreviewMarks(text) {
|
|
37
|
+
return String(text ?? '').replace(ALL_MARKS_RE, '');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The pieces of a text between marks, each with the highlight it stands in
|
|
42
|
+
* (null for plain text). `state` carries an open highlight from the text
|
|
43
|
+
* before (a highlight split across nodes) and is updated in place.
|
|
44
|
+
*/
|
|
45
|
+
export function splitPreviewMarks(text, state = { kind: null }) {
|
|
46
|
+
const pieces = [];
|
|
47
|
+
let buffer = '';
|
|
48
|
+
const flush = () => {
|
|
49
|
+
if (buffer) pieces.push({ text: buffer, kind: state.kind });
|
|
50
|
+
buffer = '';
|
|
51
|
+
};
|
|
52
|
+
for (const ch of String(text ?? '')) {
|
|
53
|
+
const mark = MARK_KINDS[ch];
|
|
54
|
+
if (!mark) {
|
|
55
|
+
buffer += ch;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
flush();
|
|
59
|
+
if (mark.open) state.kind = mark.open;
|
|
60
|
+
else if (mark.close && state.kind === mark.close) state.kind = null;
|
|
61
|
+
}
|
|
62
|
+
flush();
|
|
63
|
+
return pieces;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function markElement(doc, piece) {
|
|
67
|
+
const element = doc.createElement('mark');
|
|
68
|
+
element.className = CLASS_BY_KIND[piece.kind];
|
|
69
|
+
element.title = TITLE_BY_KIND[piece.kind];
|
|
70
|
+
element.textContent = piece.text;
|
|
71
|
+
return element;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function rewriteTextNode(node, state) {
|
|
75
|
+
const value = node.nodeValue || '';
|
|
76
|
+
if (!ANY_MARK_RE.test(value) && !state.kind) return;
|
|
77
|
+
const pieces = splitPreviewMarks(value, state);
|
|
78
|
+
const doc = node.ownerDocument;
|
|
79
|
+
const fragment = doc.createDocumentFragment();
|
|
80
|
+
for (const piece of pieces) {
|
|
81
|
+
fragment.appendChild(piece.kind ? markElement(doc, piece) : doc.createTextNode(piece.text));
|
|
82
|
+
}
|
|
83
|
+
node.parentNode?.replaceChild(fragment, node);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function stripAttributes(root) {
|
|
87
|
+
const elements = root.nodeType === 1 ? [root, ...root.querySelectorAll('*')] : [...(root.querySelectorAll?.('*') || [])];
|
|
88
|
+
for (const element of elements) {
|
|
89
|
+
for (const attribute of [...(element.attributes || [])]) {
|
|
90
|
+
if (ANY_MARK_RE.test(attribute.value)) element.setAttribute(attribute.name, stripPreviewMarks(attribute.value));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Turn every mark under `root` into a highlight. Idempotent. */
|
|
96
|
+
export function applyPreviewMarks(root) {
|
|
97
|
+
if (!root) return;
|
|
98
|
+
if (root.nodeType === 3) {
|
|
99
|
+
if (root.parentElement) applyPreviewMarks(root.parentElement);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const doc = root.ownerDocument || root;
|
|
103
|
+
if (ANY_MARK_RE.test(root.textContent || '')) {
|
|
104
|
+
const walker = doc.createTreeWalker(root, 4 /* NodeFilter.SHOW_TEXT */, {
|
|
105
|
+
acceptNode(node) {
|
|
106
|
+
const tag = String(node.parentElement?.tagName || '').toUpperCase();
|
|
107
|
+
// Mark characters are never part of a script or a style; leave them.
|
|
108
|
+
return tag === 'SCRIPT' || tag === 'STYLE' ? 2 /* FILTER_REJECT */ : 1 /* FILTER_ACCEPT */;
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
const nodes = [];
|
|
112
|
+
while (walker.nextNode()) nodes.push(walker.currentNode);
|
|
113
|
+
// An open highlight runs on in document order; a block element ends it.
|
|
114
|
+
let state = { kind: null };
|
|
115
|
+
let block = null;
|
|
116
|
+
for (const node of nodes) {
|
|
117
|
+
const nodeBlock = node.parentElement?.closest?.(BLOCK_SELECTOR) || null;
|
|
118
|
+
if (nodeBlock !== block) {
|
|
119
|
+
state = { kind: null };
|
|
120
|
+
block = nodeBlock;
|
|
121
|
+
}
|
|
122
|
+
rewriteTextNode(node, state);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
stripAttributes(root);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Keep marking whatever the page renders under `root` (the transcript is
|
|
130
|
+
* rebuilt as the preview polls, the sub-agent viewer fills in). Only what
|
|
131
|
+
* changed is looked at. Returns a stop function.
|
|
132
|
+
*/
|
|
133
|
+
export function watchPreviewMarks(root, { schedule = (fn) => requestAnimationFrame(fn) } = {}) {
|
|
134
|
+
if (!root || typeof MutationObserver !== 'function') return () => {};
|
|
135
|
+
const changed = new Set();
|
|
136
|
+
let pending = false;
|
|
137
|
+
let observer = null;
|
|
138
|
+
const observe = () => observer.observe(root, {
|
|
139
|
+
childList: true, subtree: true, characterData: true, attributes: true,
|
|
140
|
+
});
|
|
141
|
+
const run = () => {
|
|
142
|
+
pending = false;
|
|
143
|
+
observer.disconnect();
|
|
144
|
+
try {
|
|
145
|
+
for (const node of changed) {
|
|
146
|
+
if (node.isConnected) applyPreviewMarks(node);
|
|
147
|
+
}
|
|
148
|
+
changed.clear();
|
|
149
|
+
if (ANY_MARK_RE.test(document.title || '')) document.title = stripPreviewMarks(document.title);
|
|
150
|
+
} finally {
|
|
151
|
+
observe();
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
observer = new MutationObserver((records) => {
|
|
155
|
+
for (const record of records) {
|
|
156
|
+
if (record.type === 'childList') record.addedNodes.forEach((node) => changed.add(node));
|
|
157
|
+
else if (record.type === 'characterData') changed.add(record.target);
|
|
158
|
+
else if (record.type === 'attributes' && ANY_MARK_RE.test(record.target.getAttribute?.(record.attributeName) || '')) changed.add(record.target);
|
|
159
|
+
}
|
|
160
|
+
if (pending || !changed.size) return;
|
|
161
|
+
pending = true;
|
|
162
|
+
schedule(run);
|
|
163
|
+
});
|
|
164
|
+
applyPreviewMarks(root);
|
|
165
|
+
observe();
|
|
166
|
+
return () => observer.disconnect();
|
|
167
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// The relay's active public shares, as it last reported them: one entry per
|
|
2
|
+
// shared conversation (Settings → Shared conversations) and whether the open
|
|
3
|
+
// conversation has one (the ⋯ menu's "Stop sharing"). Read from GET /api/shares
|
|
4
|
+
// when the socket connects and again on every share event; no DOM here.
|
|
5
|
+
|
|
6
|
+
import { listConversationShares } from './api-client.js';
|
|
7
|
+
|
|
8
|
+
let shares = [];
|
|
9
|
+
let settings = { storeViewerIp: true, accessLog: true };
|
|
10
|
+
let loaded = false;
|
|
11
|
+
let refreshInFlight = null;
|
|
12
|
+
let refreshAgain = false;
|
|
13
|
+
const listeners = new Set();
|
|
14
|
+
|
|
15
|
+
function notify() {
|
|
16
|
+
for (const listener of listeners) {
|
|
17
|
+
try { listener(); } catch (error) { console.error('share listener failed', error); }
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function normalizeShare(entry) {
|
|
22
|
+
const conversationId = String(entry?.conversationId || '').trim();
|
|
23
|
+
if (!conversationId) return null;
|
|
24
|
+
const count = (value) => (Number.isFinite(Number(value)) ? Math.max(0, Math.trunc(Number(value))) : 0);
|
|
25
|
+
return {
|
|
26
|
+
conversationId,
|
|
27
|
+
title: String(entry?.title || '').trim(),
|
|
28
|
+
archived: entry?.archived === true,
|
|
29
|
+
shareUrl: String(entry?.shareUrl || '').trim(),
|
|
30
|
+
sharedSince: entry?.sharedSince || null,
|
|
31
|
+
renewedAt: entry?.renewedAt || null,
|
|
32
|
+
lastOpenedAt: entry?.lastOpenedAt || null,
|
|
33
|
+
openCount: count(entry?.openCount),
|
|
34
|
+
viewerCount: count(entry?.viewerCount),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Takes a GET /api/shares body (or the same shape). */
|
|
39
|
+
export function applySharesSnapshot(payload) {
|
|
40
|
+
if (!payload || typeof payload !== 'object') return;
|
|
41
|
+
shares = (Array.isArray(payload.shares) ? payload.shares : []).map(normalizeShare).filter(Boolean);
|
|
42
|
+
settings = {
|
|
43
|
+
storeViewerIp: payload.storeViewerIp !== false,
|
|
44
|
+
accessLog: payload.accessLog !== false,
|
|
45
|
+
};
|
|
46
|
+
loaded = true;
|
|
47
|
+
notify();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function applyShareSettings(next) {
|
|
51
|
+
if (!next || typeof next.storeViewerIp !== 'boolean') return;
|
|
52
|
+
settings = { ...settings, storeViewerIp: next.storeViewerIp };
|
|
53
|
+
notify();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A share this page just stopped: gone before the relay's event arrives. */
|
|
57
|
+
export function forgetShare(conversationId) {
|
|
58
|
+
const id = String(conversationId || '').trim();
|
|
59
|
+
const next = shares.filter((share) => share.conversationId !== id);
|
|
60
|
+
if (next.length === shares.length) return;
|
|
61
|
+
shares = next;
|
|
62
|
+
notify();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function getShares() {
|
|
66
|
+
return shares.slice();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function getShareSettings() {
|
|
70
|
+
return { ...settings };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function sharesLoaded() {
|
|
74
|
+
return loaded;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function hasActiveShare(conversationId) {
|
|
78
|
+
const id = String(conversationId || '').trim();
|
|
79
|
+
return !!id && shares.some((share) => share.conversationId === id);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Reads the list again. Calls during a read are folded into one more read
|
|
84
|
+
* after it, so the last event always wins. Rejects with the relay's reason.
|
|
85
|
+
*/
|
|
86
|
+
export async function refreshShares() {
|
|
87
|
+
if (refreshInFlight) {
|
|
88
|
+
refreshAgain = true;
|
|
89
|
+
return refreshInFlight;
|
|
90
|
+
}
|
|
91
|
+
refreshInFlight = (async () => {
|
|
92
|
+
try {
|
|
93
|
+
do {
|
|
94
|
+
refreshAgain = false;
|
|
95
|
+
const payload = await listConversationShares();
|
|
96
|
+
if (payload) applySharesSnapshot(payload);
|
|
97
|
+
} while (refreshAgain);
|
|
98
|
+
} finally {
|
|
99
|
+
refreshInFlight = null;
|
|
100
|
+
}
|
|
101
|
+
})();
|
|
102
|
+
return refreshInFlight;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function subscribeShares(listener) {
|
|
106
|
+
if (typeof listener !== 'function') return () => {};
|
|
107
|
+
listeners.add(listener);
|
|
108
|
+
return () => listeners.delete(listener);
|
|
109
|
+
}
|