@oar-sh/oar 0.9.20 → 0.9.21

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 (95) hide show
  1. package/CHANGELOG.md +212 -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 +22 -1
  7. package/server/claude-cloud-worker/claude-cloud-worker-steering.mjs +34 -0
  8. package/server/claude-worker/claude-session-process.mjs +7 -0
  9. package/server/claude-worker/claude-session-worker-main.mjs +8 -1
  10. package/server/copilot-worker/copilot-sdk-session-process.mjs +74 -1
  11. package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -1
  12. package/server/cursor-worker/cursor-session-worker.mjs +12 -1
  13. package/server/cursor-worker/cursor-worker-steering.mjs +20 -0
  14. package/server/grok-worker/grok-background-tasks.mjs +9 -0
  15. package/server/grok-worker/grok-session-worker.mjs +12 -1
  16. package/server/grok-worker/grok-turn-runner.mjs +3 -0
  17. package/server/grok-worker/grok-worker-steering.mjs +23 -0
  18. package/server/public/app/about-ui.js +1 -1
  19. package/server/public/app/about-view.mjs +19 -0
  20. package/server/public/app/action-confirmations.js +1 -1
  21. package/server/public/app/api-client.js +4 -2
  22. package/server/public/app/ask-user-view.js +20 -6
  23. package/server/public/app/attachments-view.js +1 -1
  24. package/server/public/app/back-guard.mjs +31 -5
  25. package/server/public/app/bootstrap.js +556 -68
  26. package/server/public/app/chat-actions-menu-state.mjs +139 -4
  27. package/server/public/app/chat-header-meta.mjs +27 -0
  28. package/server/public/app/claude-billing-toggle-ui.js +2 -1
  29. package/server/public/app/claude-cloud-conversation-ui.js +2 -2
  30. package/server/public/app/composer-settings-label.mjs +90 -0
  31. package/server/public/app/composer-settings-ui.js +442 -0
  32. package/server/public/app/context-menu.mjs +36 -5
  33. package/server/public/app/conversation-deep-link.mjs +15 -11
  34. package/server/public/app/conversation-group-drag.mjs +12 -2
  35. package/server/public/app/conversation-group-order.mjs +9 -2
  36. package/server/public/app/conversation-group-state.mjs +47 -10
  37. package/server/public/app/conversation-origin-ui.js +17 -1
  38. package/server/public/app/conversation-view.js +3 -0
  39. package/server/public/app/cwd-menu-placement.mjs +18 -6
  40. package/server/public/app/cwd-picker.js +36 -6
  41. package/server/public/app/emoji-view.js +1 -1
  42. package/server/public/app/font-scaling.js +26 -0
  43. package/server/public/app/header-priority-actions.mjs +234 -0
  44. package/server/public/app/hidden-messages-view.mjs +4 -0
  45. package/server/public/app/inbox-ui.js +18 -2
  46. package/server/public/app/journal-view.js +311 -58
  47. package/server/public/app/phone-landscape-ui.js +225 -0
  48. package/server/public/app/phone-landscape.mjs +99 -0
  49. package/server/public/app/phone-typing-state.mjs +104 -0
  50. package/server/public/app/pinned-messages-view.mjs +5 -0
  51. package/server/public/app/push-settings.js +2 -2
  52. package/server/public/app/pwa-install.js +24 -3
  53. package/server/public/app/relay-event-subscriptions-ui.js +71 -22
  54. package/server/public/app/relay-event-view.mjs +38 -0
  55. package/server/public/app/relay-restart-ui.js +2 -2
  56. package/server/public/app/relay-schedule-view.mjs +32 -6
  57. package/server/public/app/relay-schedules-ui.js +69 -20
  58. package/server/public/app/remote-relay-origin-view.mjs +41 -23
  59. package/server/public/app/session-history-ui.js +250 -0
  60. package/server/public/app/session-history.mjs +599 -0
  61. package/server/public/app/settings-modal.js +61 -6
  62. package/server/public/app/settings-tabs.js +12 -0
  63. package/server/public/app/share-state.js +1 -1
  64. package/server/public/app/shared-conversations-settings.js +2 -2
  65. package/server/public/app/socket-handlers.js +5 -2
  66. package/server/public/app/status-view.mjs +18 -2
  67. package/server/public/app/store.js +87 -16
  68. package/server/public/app/subagent-viewer.mjs +27 -2
  69. package/server/public/app/tab-strip-fade.mjs +101 -0
  70. package/server/public/app/tap-actions.js +66 -0
  71. package/server/public/app/usage-limit-ui.js +63 -2
  72. package/server/public/app/viewer-history.mjs +13 -2
  73. package/server/public/index.html +2249 -1590
  74. package/server/relay-tools.md +1 -0
  75. package/server/repositories/message-repository.mjs +23 -0
  76. package/server/repositories/session-repository.mjs +19 -0
  77. package/server/routes/claude-cloud-routes.mjs +19 -0
  78. package/server/routes/messages-routes.mjs +57 -1
  79. package/server/routes/sessions-routes.mjs +41 -7
  80. package/server/server-runtime.mjs +190 -40
  81. package/server/services/about-service.mjs +22 -53
  82. package/server/services/claude-billing-swap-service.mjs +7 -20
  83. package/server/services/claude-cloud-session-service.mjs +47 -5
  84. package/server/services/remote-relay-dispatcher.mjs +61 -14
  85. package/server/services/session-activity-tracker.mjs +1 -1
  86. package/server/services/session-launch-mutex-service.mjs +23 -0
  87. package/server/services/session-worker-registry-service.mjs +36 -0
  88. package/server/services/session-worker-websocket-service.mjs +40 -16
  89. package/server/services/worker-recycle-service.mjs +772 -0
  90. package/server/services/worker-replacement.mjs +197 -0
  91. package/shared/build-id.mjs +128 -0
  92. package/shared/worker-runtime/heartbeat.mjs +5 -0
  93. package/shared/worker-runtime/simple-steering-state.mjs +38 -0
  94. package/shared/worker-runtime/worker-websocket-link.mjs +23 -0
  95. package/shared/worker-runtime/working-directory.mjs +1 -1
@@ -13,10 +13,12 @@ import {
13
13
  import { createApiClient } from '../../shared/worker-runtime/api-client.mjs';
14
14
  import { createWorkerWebSocketLink } from '../../shared/worker-runtime/worker-websocket-link.mjs';
15
15
  import { createHeartbeatController } from '../../shared/worker-runtime/heartbeat.mjs';
16
+ import { resolveBuildId } from '../../shared/build-id.mjs';
16
17
  import { installWorkerLogFile } from '../../shared/worker-runtime/worker-log-file.mjs';
17
18
  import { createControlPoller } from '../../shared/control-poller.mjs';
18
19
  import { installWorkerCrashGuard } from '../../shared/worker-crash-guard.mjs';
19
20
  import { createCursorTurnRunner } from './cursor-turn-runner.mjs';
21
+ import { buildCursorSteeringState } from './cursor-worker-steering.mjs';
20
22
  import { resolveCursorSessionStoreDir } from '../services/cursor-session-root-service.mjs';
21
23
 
22
24
  // Windows console launch: the launcher names the worker log, this copies
@@ -48,6 +50,10 @@ async function main() {
48
50
 
49
51
  const configPath = String(process.env.COPILOT_WEB_RELAY_CONFIG || '').trim()
50
52
  || path.resolve(__dirname, '..', 'config.json');
53
+ // The code this process started on, read once from the repo root and kept
54
+ // for its whole life: the relay recycles a worker whose build differs from
55
+ // its own, and a tree that moves on under us must not change our answer.
56
+ const buildId = resolveBuildId(path.resolve(__dirname, '..', '..')).id;
51
57
  const serverUrl = resolveRelayServerUrl({ configPath });
52
58
  const token = loadTokenFromConfig(configPath);
53
59
  const cwd = String(process.env.COPILOT_WORKSPACE_ROOT || '').trim() || process.cwd();
@@ -93,17 +99,22 @@ async function main() {
93
99
  let heartbeatTimer = null;
94
100
  const heartbeat = createHeartbeatController({
95
101
  api,
102
+ buildId,
96
103
  pollMs: HEARTBEAT_MS,
97
104
  getSessionReady: () => true,
98
105
  getHeartbeatTimer: () => heartbeatTimer,
99
106
  setHeartbeatTimer: (timer) => { heartbeatTimer = timer; },
100
107
  getActiveQueueMessageId: () => turnRunner.getActiveQueueMessageId(),
108
+ // No steering here; the snapshot tells the relay when it may replace
109
+ // this worker: between turns (the agent handle is rebuilt on demand).
110
+ getSteeringState: () => buildCursorSteeringState(turnRunner),
101
111
  });
102
112
 
103
113
  const wsLink = createWorkerWebSocketLink({
104
114
  serverUrl,
105
115
  token,
106
116
  dbg,
117
+ buildId,
107
118
  getSessionReady: () => true,
108
119
  getSessionId: () => sdkSessionId,
109
120
  getPid: () => process.pid,
@@ -146,7 +157,7 @@ async function main() {
146
157
  getActiveQueueMessageIds: () => [turnRunner.getActiveQueueAttempt()],
147
158
  });
148
159
 
149
- dbg(`starting session=${sdkSessionId.slice(0, 8)} server=${serverUrl} cwd=${cwd} model=${defaultModel || 'default'}`);
160
+ dbg(`starting session=${sdkSessionId.slice(0, 8)} server=${serverUrl} cwd=${cwd} model=${defaultModel || 'default'} build=${buildId || 'unknown'}`);
150
161
  heartbeat.startHeartbeat();
151
162
  wsLink.start();
152
163
  }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The Cursor worker's heartbeat steering snapshot, from its turn runner
3
+ * (cursor-turn-runner.mjs). A module of its own so it can be tested: the
4
+ * worker main runs at import.
5
+ *
6
+ * Only a turn holds the release. The Cursor SDK tells the runner of no work
7
+ * that outlives a run: whether a sub-agent or a background shell can keep
8
+ * running after the run's result is not something its types say, and the
9
+ * runner sees neither of them, so there is no background count to send. The
10
+ * relay's quiet rule (10 minutes without a delivery or a turn end) covers
11
+ * what the SDK does not say.
12
+ */
13
+ import { buildSimpleSteeringState } from '../../shared/worker-runtime/simple-steering-state.mjs';
14
+
15
+ export function buildCursorSteeringState(turnRunner) {
16
+ return buildSimpleSteeringState({
17
+ activeAttempt: turnRunner.getActiveQueueAttempt(),
18
+ turnActive: turnRunner.isTurnActive(),
19
+ });
20
+ }
@@ -235,6 +235,15 @@ export function createGrokTaskPublisher({
235
235
  stop,
236
236
  /** The ids of the live terminals, for the heartbeat's digest. */
237
237
  backgroundTaskIds: () => liveEntries().map((entry) => entry.taskId),
238
+ /**
239
+ * Terminals whose process runs and that the agent has not released, from
240
+ * the moment they start: a superset of the live ids, which wait out the
241
+ * publish grace first. What may die with the worker, so the relay must
242
+ * not replace it while any is open (the steering snapshot's count); a
243
+ * `sleep 600` started a second before a recycle is as live as one the
244
+ * panel already shows.
245
+ */
246
+ openTerminalCount: () => [...terminals.values()].filter((entry) => !entry.exited && !entry.released).length,
238
247
  /** The relay asked for the set again (heartbeat answer). */
239
248
  resync: () => publish({ resync: true }),
240
249
  /** The relay answered a heartbeat: settle notices kept for it go out. */
@@ -12,10 +12,12 @@ import {
12
12
  import { createApiClient } from '../../shared/worker-runtime/api-client.mjs';
13
13
  import { createWorkerWebSocketLink } from '../../shared/worker-runtime/worker-websocket-link.mjs';
14
14
  import { createHeartbeatController } from '../../shared/worker-runtime/heartbeat.mjs';
15
+ import { resolveBuildId } from '../../shared/build-id.mjs';
15
16
  import { installWorkerLogFile } from '../../shared/worker-runtime/worker-log-file.mjs';
16
17
  import { createControlPoller } from '../../shared/control-poller.mjs';
17
18
  import { installWorkerCrashGuard } from '../../shared/worker-crash-guard.mjs';
18
19
  import { createGrokTurnRunner } from './grok-turn-runner.mjs';
20
+ import { buildGrokSteeringState } from './grok-worker-steering.mjs';
19
21
  import { createGrokTaskPublisher } from './grok-background-tasks.mjs';
20
22
 
21
23
  // Windows console launch: the launcher names the worker log, this copies
@@ -47,6 +49,10 @@ async function main() {
47
49
 
48
50
  const configPath = String(process.env.COPILOT_WEB_RELAY_CONFIG || '').trim()
49
51
  || path.resolve(__dirname, '..', 'config.json');
52
+ // The code this process started on, read once from the repo root and kept
53
+ // for its whole life: the relay recycles a worker whose build differs from
54
+ // its own, and a tree that moves on under us must not change our answer.
55
+ const buildId = resolveBuildId(path.resolve(__dirname, '..', '..')).id;
50
56
  const serverUrl = resolveRelayServerUrl({ configPath });
51
57
  const token = loadTokenFromConfig(configPath);
52
58
  const cwd = String(process.env.COPILOT_WORKSPACE_ROOT || '').trim() || process.cwd();
@@ -96,6 +102,7 @@ async function main() {
96
102
  let heartbeatTimer = null;
97
103
  heartbeat = createHeartbeatController({
98
104
  api,
105
+ buildId,
99
106
  pollMs: HEARTBEAT_MS,
100
107
  getSessionReady: () => true,
101
108
  getHeartbeatTimer: () => heartbeatTimer,
@@ -106,12 +113,16 @@ async function main() {
106
113
  getBackgroundTaskIds: () => turnRunner.backgroundTaskIds(),
107
114
  onResyncBackgroundTasks: () => turnRunner.resyncBackgroundTasks(),
108
115
  onAnswered: () => turnRunner.flushKeptTaskSettles(),
116
+ // No steering here; the snapshot tells the relay when it may replace
117
+ // this worker: no turn, and no open terminal (they end with the worker).
118
+ getSteeringState: () => buildGrokSteeringState(turnRunner),
109
119
  });
110
120
 
111
121
  const wsLink = createWorkerWebSocketLink({
112
122
  serverUrl,
113
123
  token,
114
124
  dbg,
125
+ buildId,
115
126
  getSessionReady: () => true,
116
127
  // A relay that just (re)started learns of the terminals from the digest
117
128
  // on the next beat; ask for it now.
@@ -171,7 +182,7 @@ async function main() {
171
182
  getActiveQueueMessageIds: () => [turnRunner.getActiveQueueAttempt()],
172
183
  });
173
184
 
174
- dbg(`starting session=${sdkSessionId.slice(0, 8)} server=${serverUrl} cwd=${cwd} model=${defaultModel || 'default'}`);
185
+ dbg(`starting session=${sdkSessionId.slice(0, 8)} server=${serverUrl} cwd=${cwd} model=${defaultModel || 'default'} build=${buildId || 'unknown'}`);
175
186
  heartbeat.startHeartbeat();
176
187
  wsLink.start();
177
188
  }
@@ -667,6 +667,9 @@ export function createGrokTurnRunner({
667
667
  // The terminals as background tasks: the heartbeat's digest and resync,
668
668
  // the panel's Stop, kept settle notices.
669
669
  backgroundTaskIds: () => terminalTasks?.backgroundTaskIds() || [],
670
+ // Every terminal that runs and is not released, published or not yet:
671
+ // the steering snapshot's background count (grok-worker-steering.mjs).
672
+ openTerminalCount: () => terminalTasks?.openTerminalCount?.() || 0,
670
673
  resyncBackgroundTasks: () => terminalTasks?.resync(),
671
674
  stopBackgroundTask: (taskId) => terminalTasks?.stop(taskId) || false,
672
675
  flushKeptTaskSettles: () => terminalTasks?.flushKeptSettles(),
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The Grok worker's heartbeat steering snapshot, from its turn runner
3
+ * (grok-turn-runner.mjs). A module of its own so it can be tested: the worker
4
+ * main runs at import.
5
+ *
6
+ * A turn holds the release, then any open terminal: it dies with the worker.
7
+ * Counted from the terminal's start (`openTerminalCount`), not from when it
8
+ * becomes a panel row (`backgroundTaskIds`, after a 2 s grace), so a command
9
+ * started just before a recycle sweep still holds it.
10
+ */
11
+ import { buildSimpleSteeringState } from '../../shared/worker-runtime/simple-steering-state.mjs';
12
+
13
+ export function buildGrokSteeringState(turnRunner) {
14
+ const open = Number(turnRunner.openTerminalCount?.()) || 0;
15
+ // The live ids are open terminals too; the larger count wins should the two
16
+ // ever disagree.
17
+ const listed = turnRunner.backgroundTaskIds?.().length || 0;
18
+ return buildSimpleSteeringState({
19
+ activeAttempt: turnRunner.getActiveQueueAttempt(),
20
+ turnActive: turnRunner.isTurnActive(),
21
+ backgroundTaskCount: Math.max(open, listed),
22
+ });
23
+ }
@@ -1,4 +1,4 @@
1
- // ⋯ → About OAR: the menu entry's label and the modal (about-view.mjs builds
1
+ // ︙ → About OAR: the menu entry's label and the modal (about-view.mjs builds
2
2
  // its HTML from GET /api/about).
3
3
 
4
4
  import {
@@ -256,6 +256,23 @@ function backupsRow(backups, formatDateTime) {
256
256
  return row('Backups', esc(value), { hint });
257
257
  }
258
258
 
259
+ /**
260
+ * The running workers and how many still run the code before the last deploy
261
+ * (the relay recycles those once idle): "14, all on this build", "14, 3 on an
262
+ * older build", or the bare count when the relay cannot tell. Empty when no
263
+ * worker runs, so the row is left out.
264
+ */
265
+ export function workersBuildText(workers) {
266
+ const total = finite(workers?.total) || 0;
267
+ if (total <= 0) return '';
268
+ const count = formatCount(total);
269
+ // Number(null) is 0: an unknown count must not read as "all on this build".
270
+ const other = workers?.onOtherBuild === null || workers?.onOtherBuild === undefined ? null : finite(workers.onOtherBuild);
271
+ if (other === null) return count;
272
+ if (other <= 0) return total === 1 ? `${count}, on this build` : `${count}, all on this build`;
273
+ return `${count}, ${formatCount(other)} on an older build`;
274
+ }
275
+
259
276
  /**
260
277
  * @param {object} about the GET /api/about payload
261
278
  * @param {object} [options]
@@ -270,8 +287,10 @@ export function buildAboutModalHtml(about = {}, {
270
287
 
271
288
  const commit = String(about.commit || '').trim();
272
289
  const install = about.installMethod === 'git-checkout' ? 'git checkout' : (about.installMethod === 'npm-global' ? 'npm package' : '');
290
+ const workersText = workersBuildText(about.workers);
273
291
  sections.push(section('build', 'OAR', table([
274
292
  row('Version', esc(about.version || '–'), install ? { hint: install } : {}),
293
+ ...(workersText ? [row('Workers', esc(workersText))] : []),
275
294
  ...(commit ? [row('Commit', `<code>${esc(commit.slice(0, 7))}</code>`, { title: commit })] : []),
276
295
  row('Started', esc(about.startedAt ? formatDateTime(about.startedAt) : '–')),
277
296
  row('Uptime', esc(formatUptime(about.uptimeSeconds))),
@@ -107,7 +107,7 @@ export async function confirmKillCurrentSession() {
107
107
  }
108
108
  }
109
109
 
110
- // ⋯ → Restart web relay first shows what the restart would wait for and
110
+ // ︙ → Restart web relay first shows what the restart would wait for and
111
111
  // leave running (relay-restart-ui.js); its buttons land in
112
112
  // confirmRestartWebRelay below or in confirmRestartRelayNow.
113
113
  export { openRestartRelayConfirmation };
@@ -982,7 +982,9 @@ export async function loadConversation(id, options = {}) {
982
982
  const aroundMessageId = String(options.aroundMessageId || '').trim();
983
983
  if (aroundMessageId) params.set('aroundMessageId', aroundMessageId);
984
984
  const query = params.toString();
985
- return apiFetch(`/api/conversation/${convId}${query ? `?${query}` : ''}`);
985
+ // `onErrorResponse` tells a conversation the relay does not know (404) from a failed load.
986
+ const onErrorResponse = typeof options.onErrorResponse === 'function' ? options.onErrorResponse : undefined;
987
+ return apiFetch(`/api/conversation/${convId}${query ? `?${query}` : ''}`, onErrorResponse ? { onErrorResponse } : undefined);
986
988
  }
987
989
 
988
990
  export async function refreshConversationHistory(id, options = {}) {
@@ -1247,7 +1249,7 @@ export async function createConversationShareLink(id) {
1247
1249
  });
1248
1250
  }
1249
1251
 
1250
- // Share management (Settings → Shared conversations, the ⋯ menu). These throw
1252
+ // Share management (Settings → Shared conversations, the ︙ menu). These throw
1251
1253
  // with the relay's reason, so the caller can say why an action failed.
1252
1254
  export async function listConversationShares() {
1253
1255
  return apiFetchOrThrow('/api/shares');
@@ -17,6 +17,7 @@ import { questionCardHeadLabel } from './conversation-provider-indicator.mjs';
17
17
  import { questionAcceptsFreeText } from './question-answer-rule.mjs';
18
18
  import { beginOwnSend, followsTranscriptEnd, refreshJumpButton, scrollAfterOwnSend } from './transcript-jump-bottom-view.mjs';
19
19
  import { renderSessionAskCards } from './inbox-ui.js';
20
+ import { isConversationAutoOpenHeld } from './session-history-ui.js';
20
21
 
21
22
  let relayQuestionRenderHash = '';
22
23
  let renderedRelayQuestionIds = new Set();
@@ -124,13 +125,20 @@ export async function loadRelayQuestions(conversationId) {
124
125
  window.renderConvList?.();
125
126
  window.syncComposerButtonState?.();
126
127
 
127
- if (!conversationId && pending.length) {
128
+ // Nothing open: the latest question's conversation opens. Not from the
129
+ // Inbox or Status, which show nothing open too: they are being read. And
130
+ // in place of the empty screen, not as a step of its own in history. Nor
131
+ // after a link the relay did not know: its notice keeps the screen empty
132
+ // until the reader opens something.
133
+ const readingAView = document.body?.classList?.contains('inbox-view-active')
134
+ || document.body?.classList?.contains('status-view-active');
135
+ if (!conversationId && pending.length && !readingAView && !isConversationAutoOpenHeld()) {
128
136
  const latest = pending
129
137
  .slice()
130
138
  .sort((a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0))[0];
131
139
  const targetConversationId = String(latest?.conversationId || '').trim();
132
140
  if (targetConversationId && targetConversationId !== currentConvId && conversations[targetConversationId]) {
133
- await window.openConversation?.(targetConversationId);
141
+ await window.openConversation?.(targetConversationId, { history: 'replace' });
134
142
  return;
135
143
  }
136
144
  }
@@ -171,10 +179,16 @@ export function updatePendingQuestionBanner() {
171
179
  banner.dataset.targetQuestionId = String(latest?.id || '').trim();
172
180
  const count = pending.length;
173
181
  const currentCount = currentConvId ? pending.filter((q) => q?.conversationId === currentConvId).length : 0;
174
- const convTitle = escHtml(conversations[targetConversationId]?.title || targetConversationId || 'conversation');
175
- banner.innerHTML = currentCount > 0
176
- ? `❓ ${count} open question${count === 1 ? '' : 's'}`
177
- : `❓ ${count} open question${count === 1 ? '' : 's'} · latest in ${convTitle}`;
182
+ const rawTitle = String(conversations[targetConversationId]?.title || targetConversationId || 'conversation');
183
+ const countText = `❓ ${count} open question${count === 1 ? '' : 's'}`;
184
+ const tailText = currentCount > 0 ? '' : ` · latest in ${rawTitle}`;
185
+ // The full text, and the compact chip "❓ N" the header fit switches to
186
+ // (data-compact) where the row is short. Either way the button's name and
187
+ // tooltip are the full text.
188
+ banner.innerHTML = `<span class="pending-question-full"><span class="pending-question-count">${escHtml(countText)}</span>${tailText ? `<span class="pending-question-tail">${escHtml(tailText)}</span>` : ''}</span>`
189
+ + `<span class="pending-question-compact" aria-hidden="true">❓ ${count}</span>`;
190
+ banner.title = `${countText}${tailText}`;
191
+ banner.setAttribute('aria-label', `${countText}${tailText}`);
178
192
  banner.classList.add('visible');
179
193
  banner.disabled = false;
180
194
  }
@@ -277,7 +277,7 @@ export function renderAttachmentPreview() {
277
277
  const annotatable = att.isImage && att.previewUrl && isAnnotatableImageType(att.type);
278
278
  const thumb = att.isImage && att.previewUrl
279
279
  ? `<img src="${escHtml(att.previewUrl)}" alt="${escHtml(att.name)}"${annotatable ? ` onclick="openAnnotateEditorForPending(${idx})" title="Annotate ${escHtml(att.name)}" style="cursor: pointer;"` : ''}>`
280
- : `<div class="attachment-preview-meta" style="height:88px;display:flex;align-items:center;justify-content:center">📎</div>`;
280
+ : `<div class="attachment-preview-meta" style="height:calc(88px * var(--ui-scale));display:flex;align-items:center;justify-content:center">📎</div>`;
281
281
  const annotateBadge = annotatable
282
282
  ? `<span class="attachment-preview-annotate" aria-hidden="true">🖍️</span>`
283
283
  : '';
@@ -9,7 +9,26 @@
9
9
 
10
10
  export const BACK_GUARD_FLOOR = 'oarBackGuardFloor';
11
11
  export const BACK_GUARD_APP = 'oarApp';
12
- const FIRST_INTERACTION_EVENTS = ['pointerdown', 'keydown', 'touchstart'];
12
+ // Every entry the app writes carries its place in the stack, so a pop tells
13
+ // back from forward (session-history.mjs, viewer-history.mjs).
14
+ export const HISTORY_POSITION = 'oarPos';
15
+ // The visit's base (the floor, the phone's session list) is laid: a reload
16
+ // on any later entry must not lay it again.
17
+ export const VISIT_LAID = 'oarLaid';
18
+ // The place in the stack of the visit's lowest own entry (the phone's list,
19
+ // else the first session): back from above it stays in the app.
20
+ export const VISIT_BASE = 'oarBase';
21
+ export const FIRST_INTERACTION_EVENTS = Object.freeze(['pointerdown', 'keydown', 'touchstart']);
22
+
23
+ /** The marks every later entry of the visit carries over from the one below. */
24
+ export function inheritedVisitMarks(state) {
25
+ const marks = {};
26
+ if (!state || typeof state !== 'object') return marks;
27
+ if (state[BACK_GUARD_APP]) marks[BACK_GUARD_APP] = true;
28
+ if (state[VISIT_LAID]) marks[VISIT_LAID] = state[VISIT_LAID];
29
+ if (Number.isFinite(state[VISIT_BASE])) marks[VISIT_BASE] = state[VISIT_BASE];
30
+ return marks;
31
+ }
13
32
 
14
33
  /**
15
34
  * Lay the floor at the first touch or key, not at start-up: an entry a page
@@ -19,13 +38,13 @@ const FIRST_INTERACTION_EVENTS = ['pointerdown', 'keydown', 'touchstart'];
19
38
  * interaction nothing a page does keeps back from leaving; from then on
20
39
  * the floor holds.
21
40
  */
22
- export function armBackGuardOnFirstInteraction({ history, enabled = true, target, onFloor = null } = {}) {
41
+ export function armBackGuardOnFirstInteraction({ history, enabled = true, target, onFloor = null, entryAgain = null } = {}) {
23
42
  if (!enabled || !history || !target) return null;
24
43
  const state = { guard: null };
25
44
  const arm = () => {
26
45
  for (const eventName of FIRST_INTERACTION_EVENTS) target.removeEventListener(eventName, arm, true);
27
46
  if (state.guard) return;
28
- state.guard = installBackGuard({ history, enabled: true, onFloor });
47
+ state.guard = installBackGuard({ history, enabled: true, onFloor, entryAgain });
29
48
  target.addEventListener('popstate', () => state.guard.onPopState());
30
49
  };
31
50
  for (const eventName of FIRST_INTERACTION_EVENTS) target.addEventListener(eventName, arm, true);
@@ -38,8 +57,12 @@ export function armBackGuardOnFirstInteraction({ history, enabled = true, target
38
57
  * lets the next back leave unless the user touches the app in between:
39
58
  * one swallowed press, then the app closes — the pattern phones use, and
40
59
  * the moment to say "press back again to close".
60
+ *
61
+ * `entryAgain` names the entry pushed back above the floor: `{ state, url }`
62
+ * (the session list on a phone, else the session on screen). Without it a
63
+ * bare app entry goes back up.
41
64
  */
42
- export function installBackGuard({ history, enabled = true, onFloor = null } = {}) {
65
+ export function installBackGuard({ history, enabled = true, onFloor = null, entryAgain = null } = {}) {
43
66
  if (!enabled || !history) return null;
44
67
  const current = history.state && typeof history.state === 'object' ? history.state : {};
45
68
  // After a reload on the app's entry the floor already lies beneath it.
@@ -51,7 +74,10 @@ export function installBackGuard({ history, enabled = true, onFloor = null } = {
51
74
  /** Feed every popstate here. */
52
75
  onPopState() {
53
76
  if (!history.state?.[BACK_GUARD_FLOOR]) return;
54
- history.pushState({ [BACK_GUARD_APP]: true }, '');
77
+ const again = typeof entryAgain === 'function' ? entryAgain() : null;
78
+ if (again?.state && again.url) history.pushState({ ...again.state, [BACK_GUARD_APP]: true }, '', again.url);
79
+ else if (again?.state) history.pushState({ ...again.state, [BACK_GUARD_APP]: true }, '');
80
+ else history.pushState({ [BACK_GUARD_APP]: true }, '');
55
81
  onFloor?.();
56
82
  },
57
83
  };