@oar-sh/oar 0.9.20 → 0.9.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/CHANGELOG.md +325 -0
  2. package/README.md +13 -13
  3. package/bin/oar.js +2 -2
  4. package/package.json +1 -1
  5. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +231 -1
  6. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +31 -2
  7. package/server/claude-cloud-worker/claude-cloud-worker-steering.mjs +34 -0
  8. package/server/claude-worker/claude-sdk-adapter.mjs +149 -4
  9. package/server/claude-worker/claude-session-process.mjs +16 -4
  10. package/server/claude-worker/claude-session-worker-main.mjs +18 -2
  11. package/server/copilot-worker/copilot-sdk-session-process.mjs +75 -1
  12. package/server/copilot-worker/copilot-sdk-session-worker.mjs +18 -2
  13. package/server/cursor-worker/cursor-session-worker.mjs +21 -2
  14. package/server/cursor-worker/cursor-worker-steering.mjs +20 -0
  15. package/server/db-schema.mjs +19 -0
  16. package/server/grok-worker/grok-background-tasks.mjs +10 -0
  17. package/server/grok-worker/grok-session-worker.mjs +22 -2
  18. package/server/grok-worker/grok-turn-runner.mjs +4 -0
  19. package/server/grok-worker/grok-worker-steering.mjs +23 -0
  20. package/server/migrations/0037-relay-asks-queue-id.mjs +66 -0
  21. package/server/migrations/0038-turn-seq.mjs +92 -0
  22. package/server/public/app/about-ui.js +1 -1
  23. package/server/public/app/about-view.mjs +19 -0
  24. package/server/public/app/action-confirmations.js +1 -1
  25. package/server/public/app/activity-replay-state.mjs +160 -42
  26. package/server/public/app/api-client.js +11 -2
  27. package/server/public/app/ask-user-view.js +24 -9
  28. package/server/public/app/attachments-view.js +1 -1
  29. package/server/public/app/back-guard.mjs +31 -5
  30. package/server/public/app/bootstrap.js +618 -72
  31. package/server/public/app/chat-actions-menu-state.mjs +141 -4
  32. package/server/public/app/chat-header-meta.mjs +27 -0
  33. package/server/public/app/claude-billing-toggle-ui.js +2 -1
  34. package/server/public/app/claude-cloud-conversation-ui.js +2 -2
  35. package/server/public/app/composer-settings-label.mjs +90 -0
  36. package/server/public/app/composer-settings-ui.js +442 -0
  37. package/server/public/app/context-menu.mjs +36 -5
  38. package/server/public/app/context-usage-view.mjs +68 -0
  39. package/server/public/app/conversation-deep-link.mjs +15 -11
  40. package/server/public/app/conversation-group-drag.mjs +12 -2
  41. package/server/public/app/conversation-group-order.mjs +9 -2
  42. package/server/public/app/conversation-group-state.mjs +47 -10
  43. package/server/public/app/conversation-origin-ui.js +17 -1
  44. package/server/public/app/conversation-view.js +309 -49
  45. package/server/public/app/cwd-menu-placement.mjs +18 -6
  46. package/server/public/app/cwd-picker.js +36 -6
  47. package/server/public/app/emoji-view.js +1 -1
  48. package/server/public/app/font-scaling.js +26 -0
  49. package/server/public/app/header-priority-actions.mjs +238 -0
  50. package/server/public/app/held-details.mjs +202 -0
  51. package/server/public/app/hidden-messages-view.mjs +4 -0
  52. package/server/public/app/inbox-ui.js +88 -81
  53. package/server/public/app/inbox-view.mjs +35 -19
  54. package/server/public/app/journal-view.js +328 -58
  55. package/server/public/app/live-activity-cap.mjs +103 -0
  56. package/server/public/app/phone-landscape-ui.js +225 -0
  57. package/server/public/app/phone-landscape.mjs +99 -0
  58. package/server/public/app/phone-typing-state.mjs +104 -0
  59. package/server/public/app/pinned-messages-view.mjs +5 -0
  60. package/server/public/app/push-settings.js +2 -2
  61. package/server/public/app/pwa-install.js +24 -3
  62. package/server/public/app/questions-panel-ui.js +634 -0
  63. package/server/public/app/questions-stub-view.mjs +40 -0
  64. package/server/public/app/questions-view.mjs +89 -0
  65. package/server/public/app/relay-board-view.js +12 -2
  66. package/server/public/app/relay-event-subscriptions-ui.js +71 -22
  67. package/server/public/app/relay-event-view.mjs +38 -0
  68. package/server/public/app/relay-restart-ui.js +2 -2
  69. package/server/public/app/relay-schedule-view.mjs +32 -6
  70. package/server/public/app/relay-schedules-ui.js +69 -20
  71. package/server/public/app/relay-thoughts.mjs +15 -1
  72. package/server/public/app/remote-relay-origin-view.mjs +41 -23
  73. package/server/public/app/session-history-ui.js +251 -0
  74. package/server/public/app/session-history.mjs +599 -0
  75. package/server/public/app/settings-modal.js +67 -6
  76. package/server/public/app/settings-tabs.js +12 -0
  77. package/server/public/app/share-state.js +1 -1
  78. package/server/public/app/shared-conversations-settings.js +2 -2
  79. package/server/public/app/socket-handlers.js +47 -13
  80. package/server/public/app/status-view.mjs +18 -2
  81. package/server/public/app/store.js +87 -16
  82. package/server/public/app/stream-view-preference.mjs +60 -0
  83. package/server/public/app/subagent-fold.mjs +6 -3
  84. package/server/public/app/subagent-timeline-view.mjs +12 -5
  85. package/server/public/app/subagent-viewer.mjs +35 -8
  86. package/server/public/app/tab-strip-fade.mjs +101 -0
  87. package/server/public/app/tap-actions.js +66 -0
  88. package/server/public/app/thoughts-preference.mjs +79 -0
  89. package/server/public/app/turn-stream-view.mjs +124 -0
  90. package/server/public/app/usage-limit-ui.js +63 -2
  91. package/server/public/app/viewer-history.mjs +13 -2
  92. package/server/public/index.html +2603 -1767
  93. package/server/relay-tools.md +1 -0
  94. package/server/repositories/message-repository.mjs +33 -0
  95. package/server/repositories/question-repository.mjs +51 -7
  96. package/server/repositories/relay-turn-rows.mjs +56 -0
  97. package/server/repositories/session-repository.mjs +19 -0
  98. package/server/routes/ask-user-routes.mjs +34 -5
  99. package/server/routes/claude-cloud-routes.mjs +19 -0
  100. package/server/routes/messages-routes.mjs +92 -15
  101. package/server/routes/relay-ask-routes.mjs +15 -5
  102. package/server/routes/sessions-routes.mjs +117 -12
  103. package/server/server-runtime.mjs +367 -170
  104. package/server/services/about-service.mjs +22 -53
  105. package/server/services/claude-billing-swap-service.mjs +7 -20
  106. package/server/services/claude-cloud-session-service.mjs +47 -5
  107. package/server/services/cloudflared-tunnel-service.mjs +7 -0
  108. package/server/services/idle-timers.mjs +184 -0
  109. package/server/services/queue-wake-hook.mjs +145 -0
  110. package/server/services/queue-waker.mjs +127 -0
  111. package/server/services/relay-ask-service.mjs +64 -5
  112. package/server/services/relay-event-service.mjs +4 -1
  113. package/server/services/remote-relay-dispatcher.mjs +84 -18
  114. package/server/services/session-activity-tracker.mjs +1 -1
  115. package/server/services/session-history-refresh-service.mjs +4 -0
  116. package/server/services/session-launch-mutex-service.mjs +23 -0
  117. package/server/services/session-worker-registry-service.mjs +36 -0
  118. package/server/services/session-worker-supervisor-service.mjs +36 -0
  119. package/server/services/session-worker-websocket-service.mjs +183 -31
  120. package/server/services/ssh-tunnel-manager-service.mjs +7 -0
  121. package/server/services/worker-recycle-service.mjs +772 -0
  122. package/server/services/worker-replacement.mjs +197 -0
  123. package/shared/ask-user-bridge.mjs +9 -6
  124. package/shared/build-id.mjs +128 -0
  125. package/shared/relay-asks.mjs +10 -0
  126. package/shared/relay-events.mjs +1 -0
  127. package/shared/worker-runtime/heartbeat.mjs +31 -0
  128. package/shared/worker-runtime/simple-steering-state.mjs +38 -0
  129. package/shared/worker-runtime/worker-keepalive.mjs +108 -0
  130. package/shared/worker-runtime/worker-websocket-link.mjs +44 -10
  131. package/shared/worker-runtime/working-directory.mjs +1 -1
@@ -11,6 +11,13 @@ function normalizeTimestampMs(value) {
11
11
  return Number.isFinite(parsed) ? parsed : null;
12
12
  }
13
13
 
14
+ // A thought's place in its turn's order (migration 0038), or null.
15
+ function normalizeTurnSeq(value) {
16
+ if (value === null || value === undefined || value === '') return null;
17
+ const num = Number(value);
18
+ return Number.isInteger(num) && num > 0 ? num : null;
19
+ }
20
+
14
21
  function toThoughtEntry(value, order) {
15
22
  const text = String(value?.text || '');
16
23
  if (!text.trim()) return null;
@@ -19,6 +26,7 @@ function toThoughtEntry(value, order) {
19
26
  const subagentRunId = String(value?.subagentRunId || '').trim() || null;
20
27
  return {
21
28
  reasoningId,
29
+ turnSeq: normalizeTurnSeq(value?.turnSeq),
22
30
  seq: normalizeSeq(value?.seq),
23
31
  text,
24
32
  done: !!value?.done,
@@ -75,7 +83,12 @@ export function normalizeRelayThoughtList(thoughts = []) {
75
83
  }
76
84
  const existing = byReasoningId.get(entry.reasoningId);
77
85
  if (!existing || isCandidateNewer(entry, existing)) {
78
- byReasoningId.set(entry.reasoningId, entry);
86
+ // The text goes to the newer copy; the turn order stays the first one
87
+ // known (a thought sits where its first chunk arrived).
88
+ const turnSeq = existing?.turnSeq ?? entry.turnSeq;
89
+ byReasoningId.set(entry.reasoningId, { ...entry, turnSeq });
90
+ } else if (existing.turnSeq === null && entry.turnSeq !== null) {
91
+ existing.turnSeq = entry.turnSeq;
79
92
  }
80
93
  }
81
94
 
@@ -87,6 +100,7 @@ export function normalizeRelayThoughtList(thoughts = []) {
87
100
  done: !!entry.done,
88
101
  timestamp: entry.timestamp || null,
89
102
  subagentRunId: entry.subagentRunId || null,
103
+ ...(entry.turnSeq !== null ? { turnSeq: entry.turnSeq } : {}),
90
104
  }));
91
105
  }
92
106
 
@@ -1,16 +1,17 @@
1
1
  // Provenance of work another relay's agent started here: the badge on a user
2
2
  // bubble ("↗ from win-test · “report builder” · claude-sonnet-5", linking back
3
- // to the source session) and the "↗ win-test" marker on the sidebar row of a
3
+ // to the source session) and the line in the sidebar row's ⓘ menu of a
4
4
  // conversation such an agent created. Both read the `origin` the relay stores
5
5
  // with the message or conversation (docs/plans/2026-09-27-remote-relays.md §4).
6
6
  //
7
7
  // An agent on THIS relay can start sessions too. Such an origin carries
8
- // `local: true` and names the conversation the agent runs in: the marker then
9
- // reads "via agent · “title”" and opens that conversation inside the app
8
+ // `local: true` and names the conversation the agent runs in: the header
9
+ // marker then reads "via agent · “title”" and opens that conversation inside the app
10
10
  // (window.openOriginConversation, journal-view.js) instead of linking to
11
11
  // another relay. An origin without `local` renders exactly as before.
12
12
 
13
13
  import { remoteConversationUrl } from './remote-relay-shared.mjs';
14
+ import { chatMetaLabelHtml } from './chat-header-meta.mjs';
14
15
 
15
16
  function escapeHtml(value) {
16
17
  return String(value ?? '')
@@ -81,8 +82,8 @@ export function localAgentOriginModel(origin, {
81
82
 
82
83
  // A button while the orchestrating conversation can be opened, plain text
83
84
  // otherwise. `tooltip` is a full sentence.
84
- function localAgentElementHtml(model, { className, text = model.text, tooltip }) {
85
- const label = escapeHtml(text);
85
+ function localAgentElementHtml(model, { className, text = model.text, tooltip, labelHtml = null }) {
86
+ const label = labelHtml ?? escapeHtml(text);
86
87
  if (model.linkable) {
87
88
  return `<button type="button" class="${className}" data-origin-conversation-id="${escapeHtml(model.conversationId)}" title="${escapeHtml(`${tooltip} Opens that conversation.`)}" onclick="openOriginConversation(event, this)">${label}</button>`;
88
89
  }
@@ -159,34 +160,51 @@ export function renderRemoteOriginBadgeHtml(origin, { linkable = true, conversat
159
160
  }
160
161
 
161
162
  /**
162
- * The sidebar marker for a conversation an agent created: "↗ win-test" for
163
- * another relay's agent, "via agent · “title”" for one on this relay.
163
+ * The line of the sidebar row's ⓘ menu for a conversation an agent created,
164
+ * or null. Plain text, for the menu to escape:
165
+ * - an agent on this relay: `{ kind: 'local', label: 'Started by an agent in
166
+ * “title”', conversationId, linkable }` with the whole title, never cut;
167
+ * while linkable, the line opens that conversation;
168
+ * - another relay's agent: `{ kind: 'remote', label: 'Started by an agent on
169
+ * relay “name”', href }`; with an href, the line opens its session there.
170
+ * `linkable: false` (the shared view) gives lines without an action.
164
171
  */
165
- export function renderConversationOriginMarkerHtml(conversation, { conversations = null, linkable = true } = {}) {
166
- const local = localAgentOriginModel(conversation?.origin, {
167
- conversations,
168
- linkable,
169
- ownId: conversation?.id,
170
- maxTitleLength: ORIGIN_TITLE_MAX_SIDEBAR,
171
- });
172
+ export function conversationOriginInfo(conversation, { conversations = null, linkable = true } = {}) {
173
+ const local = localAgentOriginModel(conversation?.origin, { conversations, linkable, ownId: conversation?.id });
172
174
  if (local) {
173
- return localAgentElementHtml(local, {
174
- className: 'conv-origin-marker conv-origin-agent',
175
- tooltip: startedByTooltip(local),
176
- });
175
+ return {
176
+ kind: 'local',
177
+ // The whole title: the menu wraps its lines, and nothing else in the
178
+ // list shows it.
179
+ label: local.fullTitle ? `Started by an agent in “${local.fullTitle}”` : 'Started by an agent in another conversation',
180
+ conversationId: local.conversationId,
181
+ linkable: local.linkable,
182
+ };
177
183
  }
178
184
  const normalized = normalizeRemoteOrigin(conversation?.origin);
179
- if (!normalized) return '';
180
- const name = escapeHtml(normalized.relayName);
181
- return `<span class="conv-origin-marker" title="Started by an agent on relay ${name}">↗ ${name}</span>`;
185
+ if (!normalized) return null;
186
+ return {
187
+ kind: 'remote',
188
+ label: `Started by an agent on relay “${normalized.relayName}”`,
189
+ href: linkable !== false ? remoteOriginLink(normalized) : '',
190
+ };
191
+ }
192
+
193
+ /** The phone's short header label: "↗ title" (the sidebar's length), or "↗ agent". */
194
+ export function conversationHeaderOriginShortText(model) {
195
+ if (!model) return '';
196
+ const title = truncateOriginTitle(model.fullTitle, ORIGIN_TITLE_MAX_SIDEBAR);
197
+ return title ? `↗ ${title}` : '↗ agent';
182
198
  }
183
199
 
184
200
  /**
185
201
  * The same marker for the conversation header, with room for more of the
186
- * title. Only a local origin has one; '' for everything else.
202
+ * title. Only a local origin has one; '' for everything else. It carries a
203
+ * long and a short label (chat-header-meta.mjs); CSS shows one of them.
187
204
  */
188
205
  export function renderConversationHeaderOriginHtml(conversation, { conversations = null, linkable = true } = {}) {
189
206
  const local = localAgentOriginModel(conversation?.origin, { conversations, linkable, ownId: conversation?.id });
190
207
  if (!local) return '';
191
- return localAgentElementHtml(local, { className: 'conv-origin-agent', tooltip: startedByTooltip(local) });
208
+ const labelHtml = chatMetaLabelHtml(escapeHtml(local.text), escapeHtml(conversationHeaderOriginShortText(local)));
209
+ return localAgentElementHtml(local, { className: 'conv-origin-agent', tooltip: startedByTooltip(local), labelHtml });
192
210
  }
@@ -0,0 +1,251 @@
1
+ // Wires session-history.mjs to the page: the popstate listener, the
2
+ // overlays it watches, the document title, and the hooks the open path calls
3
+ // (journal-view.js). Before initSessionHistory() runs (the shared reader, the
4
+ // unit tests) every hook here does nothing.
5
+
6
+ import { conversations } from './store.js';
7
+ import { closeContextMenu } from './context-menu.mjs';
8
+ import { isStatusViewActive, toggleStatusView } from './status-view.mjs';
9
+ import {
10
+ armVisitBaseOnFirstInteraction,
11
+ createGoneList,
12
+ createOverlayWatch,
13
+ createSessionHistory,
14
+ } from './session-history.mjs';
15
+
16
+ const PHONE_QUERY = '(max-width: 680px)';
17
+
18
+ let sessionHistory = null;
19
+ let baseTitle = '';
20
+ // The relay knew no conversation the page tried to open, and said so: the
21
+ // empty screen stays until the reader opens one (no question jumps in).
22
+ let autoOpenHeld = false;
23
+
24
+ function tabStorage() {
25
+ try {
26
+ return typeof sessionStorage === 'undefined' ? null : sessionStorage;
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ // Kept in the tab across reloads, as its history is.
33
+ const goneConversationIds = createGoneList({ storage: tabStorage() });
34
+
35
+ function isPhone() {
36
+ try {
37
+ return !!window.matchMedia?.(PHONE_QUERY)?.matches;
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ function titleFor(id) {
44
+ if (!id) return baseTitle;
45
+ return String(conversations[id]?.title || '').trim() || baseTitle;
46
+ }
47
+
48
+ function setDocumentTitle(id) {
49
+ const next = titleFor(id);
50
+ if (document.title !== next) document.title = next;
51
+ }
52
+
53
+ const isVisible = (element) => !!element?.classList?.contains('visible');
54
+ const isShown = (element) => !!element && !element.hidden;
55
+ const byId = (id) => () => document.getElementById(id);
56
+
57
+ /**
58
+ * The Inbox's and Status's closer, told where to go back to: the session the
59
+ * history stands on. One view opened from the other has no session of its
60
+ * own to return to (the first one had already left it).
61
+ */
62
+ function backToSession(close) {
63
+ if (typeof close !== 'function') return undefined;
64
+ return () => close({ returnTo: sessionHistory?.conversation || '' });
65
+ }
66
+
67
+ /**
68
+ * Each overlay back closes before it switches sessions: `find()` names its
69
+ * element (null while there is none: some are built on first use), `isOpen`
70
+ * reads it. `closers` come from bootstrap.js, which holds most of them.
71
+ * Opened in the same moment, the earlier one in this list lies below.
72
+ */
73
+ function overlayDescriptors(closers) {
74
+ return [
75
+ { name: 'sidebar', find: byId('sidebar'), isOpen: (el) => isPhone() && !!el?.classList?.contains('open'), close: closers.closeSidebar },
76
+ // The Inbox takes the transcript's place: back leaves it for the session it came from.
77
+ { name: 'inbox', find: () => document.body, isOpen: (el) => !!el?.classList?.contains('inbox-view-active'), close: backToSession(closers.closeInboxView) },
78
+ // Status the same way.
79
+ { name: 'status', find: () => document.body, isOpen: (el) => !!el?.classList?.contains('status-view-active'), close: backToSession(closers.closeStatusView) },
80
+ { name: 'composer-settings', find: byId('composer-settings'), isOpen: isShown, close: closers.closeComposerSettings },
81
+ { name: 'chat-actions-menu', find: byId('chat-actions-menu'), isOpen: isShown, close: closers.closeChatActionsMenu },
82
+ { name: 'summary-modal', find: byId('summary-modal'), isOpen: isVisible, close: closers.closeSummaryModal },
83
+ // The Change CWD dialog's list of known folders (inside the summary modal).
84
+ {
85
+ name: 'change-cwd-menu',
86
+ find: byId('change-cwd-menu'),
87
+ isOpen: (el) => isShown(el) && isVisible(document.getElementById('summary-modal')),
88
+ close: closers.closeChangeCwdMenu,
89
+ },
90
+ { name: 'settings-modal', find: byId('settings-modal'), isOpen: isVisible, close: closers.closeSettingsModal },
91
+ { name: 'message-search-modal', find: byId('message-search-modal'), isOpen: isVisible, close: closers.closeMessageSearchModal },
92
+ { name: 'repo-browser-modal', find: byId('repo-browser-modal'), isOpen: isVisible, close: closers.closeRepoBrowser },
93
+ { name: 'new-conversation-model-modal', find: byId('new-conversation-model-modal'), isOpen: isVisible, close: closers.closeNewConversationModelModal },
94
+ { name: 'git-changes-modal', find: byId('git-changes-modal'), isOpen: isVisible, close: closers.closeGitChangesModal },
95
+ { name: 'git-diff-modal', find: byId('git-diff-modal'), isOpen: isVisible, close: closers.closeGitDiffViewer },
96
+ { name: 'tmux-inspector-modal', find: byId('tmux-inspector-modal'), isOpen: isVisible, close: closers.closeTmuxInspector },
97
+ { name: 'image-annotate-modal', find: byId('image-annotate-modal'), isOpen: isVisible, close: closers.closeAnnotateEditor },
98
+ { name: 'relay-watch-panel', find: byId('relay-watch-panel'), isOpen: isShown, close: closers.closeRelayWatchPanel },
99
+ { name: 'questions-panel', find: byId('questions-panel'), isOpen: isShown, close: closers.closeQuestionsPanel },
100
+ { name: 'relay-schedule-panel', find: byId('relay-schedule-panel'), isOpen: isShown, close: closers.closeRelaySchedulePanel },
101
+ { name: 'emoji-popup', find: byId('emoji-popup'), isOpen: isVisible, close: closers.closeEmojiPicker },
102
+ // A row's menu (right-click, long press, ⓘ): on <body> while open.
103
+ { name: 'context-menu', find: () => document.querySelector('body > .context-menu'), isOpen: (el) => !!el, close: closeContextMenu },
104
+ ];
105
+ }
106
+
107
+ // Where the overlays built on first use appear: the panels and the row menu
108
+ // on <body>, the folder list inside the summary modal.
109
+ const OVERLAY_HOSTS = [
110
+ ['body', { attributes: true, attributeFilter: ['class'], childList: true }],
111
+ ['summary-modal', { attributes: true, attributeFilter: ['class', 'hidden'], childList: true, subtree: true }],
112
+ ];
113
+
114
+ function watchOverlays(closers) {
115
+ const overlays = overlayDescriptors(closers)
116
+ .filter((descriptor) => typeof descriptor.close === 'function')
117
+ .map((descriptor) => ({
118
+ name: descriptor.name,
119
+ find: descriptor.find,
120
+ isOpen: () => {
121
+ const element = descriptor.find();
122
+ return !!element && descriptor.isOpen(element);
123
+ },
124
+ close: () => descriptor.close(),
125
+ }));
126
+ const watch = createOverlayWatch({
127
+ overlays,
128
+ onOpened: (overlay) => sessionHistory?.overlayOpened(overlay),
129
+ onClosed: (name) => sessionHistory?.overlayClosed(name),
130
+ });
131
+ if (typeof MutationObserver === 'function') {
132
+ const observed = new WeakSet();
133
+ let observer = null;
134
+ const observe = (element, options = { attributes: true, attributeFilter: ['class', 'hidden'] }) => {
135
+ if (!element || observed.has(element)) return;
136
+ observed.add(element);
137
+ observer.observe(element, options);
138
+ };
139
+ // An overlay's element built since: watched from now on.
140
+ const observeOverlays = () => {
141
+ for (const overlay of overlays) {
142
+ const element = overlay.find();
143
+ if (element && element !== document.body) observe(element);
144
+ }
145
+ };
146
+ observer = new MutationObserver(() => {
147
+ observeOverlays();
148
+ watch.check();
149
+ });
150
+ for (const [id, options] of OVERLAY_HOSTS) {
151
+ observe(id === 'body' ? document.body : document.getElementById(id), options);
152
+ }
153
+ observeOverlays();
154
+ }
155
+ // The sidebar is an overlay on a phone only.
156
+ try {
157
+ window.matchMedia?.(PHONE_QUERY)?.addEventListener?.('change', () => watch.check());
158
+ } catch {}
159
+ }
160
+
161
+ // Leaving Status goes back to the conversation it was opened from, or else
162
+ // to `returnTo`.
163
+ function closeStatusViewDefault({ returnTo = '' } = {}) {
164
+ if (isStatusViewActive()) void toggleStatusView({ returnTo });
165
+ }
166
+
167
+ export function initSessionHistory({ openConversation, ...given } = {}) {
168
+ if (sessionHistory || typeof window === 'undefined' || !window.history) return sessionHistory;
169
+ const closers = { closeStatusView: closeStatusViewDefault, ...given };
170
+ baseTitle = document.title;
171
+ sessionHistory = createSessionHistory({
172
+ history: window.history,
173
+ location: window.location,
174
+ open: (id) => {
175
+ void Promise.resolve(openConversation?.(id, { history: 'none' })).catch(() => {});
176
+ },
177
+ isGone: (id) => goneConversationIds.has(id),
178
+ // Only where the list is an overlay: on a wide screen (a phone turned)
179
+ // openSidebar would un-collapse the desktop sidebar for good.
180
+ onList: () => { if (isPhone()) closers.openSidebar?.(); },
181
+ onLeaveList: () => closers.closeSidebar?.(),
182
+ setTitle: setDocumentTitle,
183
+ schedule: (fn, ms) => window.setTimeout(fn, ms),
184
+ cancel: (handle) => window.clearTimeout(handle),
185
+ });
186
+ window.addEventListener('popstate', () => sessionHistory.onPopState());
187
+ watchOverlays(closers);
188
+ return sessionHistory;
189
+ }
190
+
191
+ /** After the back guard's arming, so the list entry lies above its floor. */
192
+ export function armSessionHistoryOnFirstInteraction(target = window) {
193
+ if (!sessionHistory) return null;
194
+ return armVisitBaseOnFirstInteraction({ target, sessionHistory, isPhone });
195
+ }
196
+
197
+ /** The entry the back guard pushes again above its floor. */
198
+ export function sessionHistoryEntryAgain() {
199
+ return sessionHistory ? sessionHistory.entryAgain() : null;
200
+ }
201
+
202
+ /** openConversation's hook: `mode` is 'push' (default), 'replace' or 'none'. */
203
+ export function noteConversationShown(id, mode = 'push') {
204
+ if (String(id || '').trim()) autoOpenHeld = false;
205
+ sessionHistory?.shown(id, { mode: mode || 'push' });
206
+ }
207
+
208
+ /** Nothing is open any more (the open one was deleted). */
209
+ export function noteConversationCleared() {
210
+ sessionHistory?.shown(null, { mode: 'replace' });
211
+ }
212
+
213
+ /** A deleted conversation: back and forward step over its entries. */
214
+ export function noteConversationGone(id) {
215
+ goneConversationIds.add(id);
216
+ }
217
+
218
+ /**
219
+ * The relay knows no conversation `id` (deleted elsewhere, a wrong link):
220
+ * its entries are stepped over from now on. True when back or forward led
221
+ * onto it and history steps on past it; false when the caller clears the
222
+ * view (and with it the entry and the address).
223
+ */
224
+ export function noteConversationMissing(id) {
225
+ goneConversationIds.add(id);
226
+ // Nothing opens by itself after the notice, until the reader opens one.
227
+ autoOpenHeld = true;
228
+ return sessionHistory ? sessionHistory.missing(id) : false;
229
+ }
230
+
231
+ /**
232
+ * True after an unknown conversation left the view empty and said so, until
233
+ * the next conversation opens: a waiting question does not open its own in
234
+ * the meantime (ask-user-view.js).
235
+ */
236
+ export function isConversationAutoOpenHeld() {
237
+ return autoOpenHeld;
238
+ }
239
+
240
+ /**
241
+ * The session the history stands on: the one the Inbox or Status was opened
242
+ * over, though the store holds none while either shows.
243
+ */
244
+ export function sessionHistoryConversation() {
245
+ return sessionHistory?.conversation || '';
246
+ }
247
+
248
+ /** The open conversation was renamed. */
249
+ export function syncSessionDocumentTitle() {
250
+ if (sessionHistory) setDocumentTitle(sessionHistory.conversation);
251
+ }