@oar-sh/oar 0.9.2 → 0.9.3

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 (94) hide show
  1. package/CHANGELOG.md +269 -0
  2. package/README.md +407 -424
  3. package/bin/oar.js +25 -10
  4. package/package.json +7 -4
  5. package/server/README.md +1 -1
  6. package/server/claude-worker/claude-session-process.mjs +981 -44
  7. package/server/claude-worker/claude-session-worker.mjs +45 -4
  8. package/server/claude-worker/claude-turn-publisher.mjs +81 -20
  9. package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
  10. package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
  11. package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
  12. package/server/copilot-worker/copilot-question-bridge.mjs +34 -12
  13. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +101 -12
  14. package/server/copilot-worker/copilot-sdk-session-process.mjs +2269 -492
  15. package/server/copilot-worker/copilot-sdk-session-worker.mjs +82 -20
  16. package/server/db-schema.mjs +20 -0
  17. package/server/migrations/0004-steer-settle-markers.mjs +119 -0
  18. package/server/public/app/api-client.js +22 -2
  19. package/server/public/app/ask-user-view.js +102 -13
  20. package/server/public/app/attachments-view.js +37 -2
  21. package/server/public/app/background-task-timeout-options.mjs +9 -0
  22. package/server/public/app/background-tasks-view.mjs +33 -18
  23. package/server/public/app/bootstrap.js +34 -8
  24. package/server/public/app/composer-control-state.mjs +52 -34
  25. package/server/public/app/conversation-draft-sync.mjs +187 -0
  26. package/server/public/app/conversation-list-filter.mjs +86 -0
  27. package/server/public/app/conversation-provider-indicator.mjs +8 -0
  28. package/server/public/app/conversation-view.js +919 -194
  29. package/server/public/app/emoji-view.js +2 -0
  30. package/server/public/app/font-scaling.js +4 -37
  31. package/server/public/app/journal-view.js +145 -5
  32. package/server/public/app/model-selector-options.mjs +1 -1
  33. package/server/public/app/question-multi-select.mjs +52 -0
  34. package/server/public/app/relay-prompt-sanitizer.mjs +17 -2
  35. package/server/public/app/settings-modal.js +8 -6
  36. package/server/public/app/socket-handlers.js +19 -0
  37. package/server/public/app/steered-turn-merge.mjs +100 -0
  38. package/server/public/app/store.js +126 -15
  39. package/server/public/app/sync-outbox.mjs +16 -8
  40. package/server/public/app/tool-activity-emoji.mjs +36 -0
  41. package/server/public/app/transcript-viewport-anchor.mjs +210 -0
  42. package/server/public/index.html +206 -9
  43. package/server/public/sw.js +7 -0
  44. package/server/relay-tools.md +2 -0
  45. package/server/repositories/message-repository.mjs +61 -18
  46. package/server/repositories/question-repository.mjs +15 -0
  47. package/server/repositories/session-repository.mjs +7 -31
  48. package/server/routes/messages-routes.mjs +488 -46
  49. package/server/routes/sessions-routes.mjs +298 -55
  50. package/server/server-runtime.mjs +65 -25
  51. package/server/services/live-turn-picker.mjs +47 -0
  52. package/server/services/relay-prompt-sanitizer.mjs +17 -2
  53. package/server/services/relay-question-context.mjs +46 -0
  54. package/server/services/sdk-session-import-service.mjs +49 -7
  55. package/server/services/session-worker-registry-service.mjs +75 -0
  56. package/server/services/session-worker-supervisor-service.mjs +7 -1
  57. package/server/services/session-worker-websocket-service.mjs +23 -1
  58. package/server/services/steer-resend.mjs +45 -0
  59. package/shared/background-task-timeout.mjs +12 -4
  60. package/shared/control-poller.mjs +4 -1
  61. package/shared/question-multi-select.mjs +16 -0
  62. package/shared/relay-stop-failure.mjs +15 -0
  63. package/shared/slash-command-guard.mjs +76 -0
  64. package/shared/steer-note.mjs +66 -0
  65. package/shared/steer-settle-failure.mjs +89 -0
  66. package/shared/steer-settle-markers.mjs +18 -0
  67. package/shared/worker-crash-guard.mjs +40 -25
  68. package/shared/worker-runtime/heartbeat.mjs +55 -1
  69. package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
  70. package/shared/worker-runtime/worker-websocket-link.mjs +119 -8
  71. package/server/claude-worker/claude-session-test-harness.mjs +0 -250
  72. package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
  73. package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -382
  74. package/server/copilot-worker/fixtures/abort-turn.json +0 -588
  75. package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
  76. package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
  77. package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
  78. package/server/copilot-worker/fixtures/happy-turn.json +0 -566
  79. package/server/copilot-worker/fixtures/quota-turn.json +0 -647
  80. package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
  81. package/server/copilot-worker/fixtures/resume-turn.json +0 -700
  82. package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
  83. package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
  84. package/server/routes/messages-routes-test-harness.mjs +0 -111
  85. package/server/services/fixtures/claude-auth-stub.cmd +0 -1
  86. package/server/services/fixtures/claude-auth-stub.mjs +0 -76
  87. package/server/services/fixtures/claude-auth-stub.sh +0 -4
  88. package/server/services/fixtures/cli-install-stub.cmd +0 -1
  89. package/server/services/fixtures/cli-install-stub.mjs +0 -98
  90. package/server/services/fixtures/cli-install-stub.sh +0 -4
  91. package/server/services/fixtures/grok-stub.cmd +0 -1
  92. package/server/services/fixtures/grok-stub.mjs +0 -89
  93. package/server/services/fixtures/grok-stub.sh +0 -4
  94. package/shared/fixtures/copilot-catalog-raw-2026-09-07.json +0 -2363
@@ -1,3 +1,4 @@
1
+ import { resolveNearBottomThresholdPx } from './transcript-viewport-anchor.mjs';
1
2
  import { recordCliLifecycleEvent, recordRelayLifecycleEvent } from './status-store.mjs';
2
3
  import { resolveRelayDotState } from './relay-dot-state.mjs';
3
4
  import { readRepoBrowserPreferences } from './repo-browser-preferences.mjs';
@@ -309,10 +310,23 @@ function cleanupStalePendingUserMessages(maxAgeMs = 15 * 60 * 1000) {
309
310
  }
310
311
  }
311
312
 
312
- export function trackPendingUserMessage(messageId, conversationId, text) {
313
+ // An attachment-only send has no text to fingerprint; its attachments stand
314
+ // in, so a screenshot send is tracked (and its bubble kept across rebuilds)
315
+ // like any other.
316
+ function pendingAttachmentFingerprint(attachments) {
317
+ const list = Array.isArray(attachments) ? attachments.filter(Boolean) : [];
318
+ if (!list.length) return '';
319
+ const firstSha = String(list[0]?.sha256 || list[0]?.uploaded?.sha256 || '').trim().toLowerCase();
320
+ return `attachments:${list.length}:${firstSha}`;
321
+ }
322
+
323
+ // Always records the entry — the conversation key is what scopes a kept
324
+ // pending bubble to its own conversation — even when there is nothing to
325
+ // fingerprint for the duplicate check.
326
+ export function trackPendingUserMessage(messageId, conversationId, text, { attachments = [] } = {}) {
313
327
  const id = String(messageId || '').trim();
314
- const fingerprint = normalizePendingMessageText(text);
315
- if (!id || !fingerprint) return false;
328
+ if (!id) return false;
329
+ const fingerprint = normalizePendingMessageText(text) || pendingAttachmentFingerprint(attachments);
316
330
  cleanupStalePendingUserMessages();
317
331
  pendingUserMessageEntries.set(id, {
318
332
  conversationKey: pendingConversationKey(conversationId),
@@ -597,10 +611,7 @@ function resolveMessagesNearBottomThreshold(el, thresholdPx = null) {
597
611
  if (Number.isFinite(Number(thresholdPx)) && Number(thresholdPx) >= 0) {
598
612
  return Math.trunc(Number(thresholdPx));
599
613
  }
600
- const viewportHeight = Number(el?.clientHeight || 0);
601
- if (!Number.isFinite(viewportHeight) || viewportHeight <= 0) return 0;
602
- const relativeThreshold = Math.floor(viewportHeight * 0.08);
603
- return Math.min(48, Math.max(12, relativeThreshold));
614
+ return resolveNearBottomThresholdPx(el?.clientHeight);
604
615
  }
605
616
 
606
617
  export function isMessagesNearBottom(thresholdPx = null) {
@@ -876,6 +887,28 @@ export function resolveConversationUiState({ conversation = null, workerState =
876
887
  return fallbackUiState || 'offline';
877
888
  }
878
889
 
890
+ // Composer steering snapshot from the worker's heartbeat: whether a typed
891
+ // message would steer into the live turn, and why not when it wouldn't.
892
+ function normalizeWorkerSteering(raw) {
893
+ if (!raw || typeof raw !== 'object') return null;
894
+ return {
895
+ turnActive: raw.turnActive === true,
896
+ canSteer: raw.canSteer === true,
897
+ holdReason: String(raw.holdReason || '').trim() || null,
898
+ // The turn the snapshot describes — lets the composer drop a hold that
899
+ // belongs to a previous turn (the heartbeat lags ~10s).
900
+ messageId: String(raw.messageId || '').trim() || null,
901
+ // The worker's own opt-in to mid-turn steering (both SDK workers send
902
+ // it); the composer's only steering gate — no provider rule backs it up.
903
+ supported: raw.supported === true,
904
+ // Pushed-but-unconsumed rows the worker can still pull back out of its
905
+ // runtime: their bubbles keep Cancel while listed here.
906
+ cancellableIds: Array.isArray(raw.cancellableIds)
907
+ ? raw.cancellableIds.map((id) => String(id || '').trim()).filter(Boolean)
908
+ : [],
909
+ };
910
+ }
911
+
879
912
  function normalizeWorkerStateEntry(worker) {
880
913
  const sdkSessionId = normalizeWorkerSessionId(worker?.sdkSessionId);
881
914
  if (!sdkSessionId) return null;
@@ -889,13 +922,17 @@ function normalizeWorkerStateEntry(worker) {
889
922
  workerId: String(worker?.workerId || '').trim() || null,
890
923
  pid: Number.isInteger(Number(worker?.pid)) ? Number(worker.pid) : null,
891
924
  updatedAt: String(worker?.updatedAt || '').trim() || null,
925
+ steering: normalizeWorkerSteering(worker?.steering),
892
926
  };
893
927
  }
894
928
 
895
929
  function buildSessionWorkerStateHash(map) {
896
930
  const parts = [];
897
931
  for (const [sid, state] of map.entries()) {
898
- parts.push(`${sid}:${state.status}:${state.uiState || ''}:${state.derivedUiState || ''}:${state.workerId || ''}:${state.pid || ''}:${state.updatedAt || ''}`);
932
+ const steering = state.steering
933
+ ? `${state.steering.turnActive ? 1 : 0}${state.steering.canSteer ? 1 : 0}${state.steering.supported ? 1 : 0}${state.steering.holdReason || ''}:${state.steering.messageId || ''}:${state.steering.cancellableIds.join(',')}`
934
+ : '';
935
+ parts.push(`${sid}:${state.status}:${state.uiState || ''}:${state.derivedUiState || ''}:${state.workerId || ''}:${state.pid || ''}:${state.updatedAt || ''}:${steering}`);
899
936
  }
900
937
  return parts.sort().join('|');
901
938
  }
@@ -917,6 +954,26 @@ export function setSessionWorkerStatesFromStatusPayload(payload) {
917
954
  return true;
918
955
  }
919
956
 
957
+ /**
958
+ * Apply one worker's steering snapshot as the relay broadcasts it
959
+ * (`session_worker_steering`), ahead of the next status poll. Only an entry
960
+ * the client already knows is patched — the snapshot alone carries none of
961
+ * the entry's other fields. Returns null for an unknown session (the caller
962
+ * refreshes the whole status instead), otherwise whether anything changed.
963
+ */
964
+ export function applySessionWorkerSteering(sdkSessionId, steering) {
965
+ const sid = normalizeWorkerSessionId(sdkSessionId);
966
+ const existing = sid ? sessionWorkerStates.get(sid) : null;
967
+ if (!existing) return null;
968
+ const next = new Map(sessionWorkerStates);
969
+ next.set(sid, { ...existing, steering: normalizeWorkerSteering(steering) });
970
+ const nextHash = buildSessionWorkerStateHash(next);
971
+ if (nextHash === sessionWorkerStateHash) return false;
972
+ sessionWorkerStateHash = nextHash;
973
+ sessionWorkerStates = next;
974
+ return true;
975
+ }
976
+
920
977
  export function getSessionWorkerState(sdkSessionId) {
921
978
  const sid = normalizeWorkerSessionId(sdkSessionId);
922
979
  if (!sid) return null;
@@ -1194,7 +1251,19 @@ export function setModelBanner(message) {
1194
1251
  el.classList.add('visible');
1195
1252
  }
1196
1253
 
1197
- export function showTransientRelayNotice(message, ms = 4000) {
1254
+ let transientNoticeSeq = 0;
1255
+ // The action notice on screen (or covered by a plain one), so a plain notice
1256
+ // that covers it hands the toast back afterwards instead of dropping the
1257
+ // action for good.
1258
+ let activeActionNotice = null;
1259
+
1260
+ /**
1261
+ * `action` ({ actionLabel, onAction }) adds one button to the toast; the
1262
+ * toast then takes pointer input and hides as soon as the button is used.
1263
+ * A plain notice shown over an action notice hands the toast back to it when
1264
+ * it hides, for whatever time the action notice had left.
1265
+ */
1266
+ export function showTransientRelayNotice(message, ms = 4000, action = null) {
1198
1267
  const text = String(message || '').trim();
1199
1268
  if (!text) return;
1200
1269
  // Transient notices render in the fixed #relay-toast, not #model-banner:
@@ -1209,17 +1278,59 @@ export function showTransientRelayNotice(message, ms = 4000) {
1209
1278
  }, Math.max(1500, Number(ms) || 4000));
1210
1279
  return;
1211
1280
  }
1281
+ const durationMs = Math.max(1500, Number(ms) || 4000);
1282
+ const noticeId = String(++transientNoticeSeq);
1283
+ const actionLabel = String(action?.actionLabel || '').trim();
1284
+ const hasAction = !!actionLabel && typeof action?.onAction === 'function';
1285
+ const covered = !hasAction && activeActionNotice && activeActionNotice.expiresAt > Date.now()
1286
+ ? activeActionNotice
1287
+ : null;
1288
+ if (hasAction) {
1289
+ activeActionNotice = { noticeId, message: text, action, expiresAt: Date.now() + durationMs };
1290
+ }
1291
+ const hide = () => {
1292
+ if (toast.dataset.noticeId !== noticeId) return;
1293
+ toast.textContent = '';
1294
+ toast.classList.remove('visible', 'has-action');
1295
+ if (hasAction) {
1296
+ if (activeActionNotice?.noticeId === noticeId) activeActionNotice = null;
1297
+ return;
1298
+ }
1299
+ if (covered && activeActionNotice === covered) {
1300
+ const remainingMs = covered.expiresAt - Date.now();
1301
+ if (remainingMs > 0) showTransientRelayNotice(covered.message, Math.max(4000, remainingMs), covered.action);
1302
+ }
1303
+ };
1304
+ toast.dataset.noticeId = noticeId;
1212
1305
  toast.textContent = text;
1306
+ if (hasAction) {
1307
+ const button = document.createElement('button');
1308
+ button.type = 'button';
1309
+ button.className = 'relay-toast-action';
1310
+ button.textContent = actionLabel;
1311
+ button.addEventListener('click', () => {
1312
+ hide();
1313
+ action.onAction();
1314
+ });
1315
+ toast.appendChild(button);
1316
+ }
1317
+ toast.classList.toggle('has-action', hasAction);
1213
1318
  toast.classList.add('visible');
1214
- setTimeout(() => {
1215
- if (String(toast.textContent || '').trim() === text) {
1216
- toast.textContent = '';
1217
- toast.classList.remove('visible');
1218
- }
1219
- }, Math.max(1500, Number(ms) || 4000));
1319
+ setTimeout(hide, durationMs);
1320
+ }
1321
+
1322
+ // The screen's short side sizes phone-landscape text (index.html); unlike
1323
+ // the viewport, the on-screen keyboard never changes it. Refreshed here for
1324
+ // foldables, whose screen changes when they unfold.
1325
+ function syncScreenShortSide() {
1326
+ const shortSide = Math.min(Number(window.screen?.width) || 0, Number(window.screen?.height) || 0);
1327
+ if (!shortSide) return;
1328
+ document.documentElement.style.setProperty('--screen-short-side', `${shortSide}px`);
1329
+ document.documentElement.toggleAttribute('data-phone-screen', shortSide <= 500);
1220
1330
  }
1221
1331
 
1222
1332
  export function syncViewportMetrics() {
1333
+ syncScreenShortSide();
1223
1334
  const layoutHeight = window.innerHeight || document.documentElement.clientHeight || 0;
1224
1335
  const layoutWidth = window.innerWidth || document.documentElement.clientWidth || 0;
1225
1336
  const vv = window.visualViewport;
@@ -1,4 +1,5 @@
1
1
  import { BASE, CLIENT_ID, authHeaders, conversations } from './store.js';
2
+ import { draftFlushRequestBody, getSyncedDraft, isStaleDraftFlushEntry } from './conversation-draft-sync.mjs';
2
3
 
3
4
  // Durable outbox for Background Sync: message sends, ask_user answers, and
4
5
  // draft flushes that failed (or might die mid-flight on pagehide) are queued
@@ -129,6 +130,10 @@ export async function replayOutboxFromPage() {
129
130
  db = await openOutboxDb();
130
131
  const entries = await readOutboxEntries(db);
131
132
  for (const { key, value } of entries) {
133
+ if (isStaleDraftFlushEntry(value)) {
134
+ await deleteOutboxEntry(db, key);
135
+ continue;
136
+ }
132
137
  let response = null;
133
138
  try {
134
139
  response = await fetch(`${BASE}${value.path}`, {
@@ -185,23 +190,26 @@ export async function initOutboxFallbackReplay() {
185
190
  * replay treats as final. Only queues when the draft differs from the last
186
191
  * persisted state.
187
192
  */
188
- export async function enqueueDraftFlushForBackgroundSync(conversationId) {
193
+ export async function enqueueDraftFlushForBackgroundSync(conversationId, { afterPendingSend = false } = {}) {
189
194
  const id = String(conversationId || '').trim();
190
195
  if (!id) return false;
191
196
  const conversation = conversations[id];
192
197
  if (!conversation) return false;
193
198
  const input = document.getElementById('msg-input');
194
- const draftText = String(input ? input.value : (conversation.draftText || ''));
195
- if (draftText === String(conversation.draftText || '')) return false;
199
+ const body = draftFlushRequestBody({
200
+ inputText: input ? input.value : (conversation.draftText || ''),
201
+ synced: getSyncedDraft(id),
202
+ fallbackText: conversation.draftText,
203
+ fallbackUpdatedAt: conversation.draftUpdatedAt,
204
+ clientId: CLIENT_ID,
205
+ afterPendingSend,
206
+ });
207
+ if (!body) return false;
196
208
  const queued = await enqueueOutboxRequest({
197
209
  kind: 'draft-flush',
198
210
  path: `/api/conversation/${encodeURIComponent(id)}/draft`,
199
211
  method: 'PATCH',
200
- body: JSON.stringify({
201
- draftText,
202
- clientId: CLIENT_ID,
203
- baseDraftUpdatedAt: conversation.draftUpdatedAt || null,
204
- }),
212
+ body: JSON.stringify(body),
205
213
  });
206
214
  if (queued) void registerOutboxSync();
207
215
  return queued;
@@ -0,0 +1,36 @@
1
+ // Leading emoji for a tool-activity line ("Tool (PowerShell): …" → "🪓 Tool
2
+ // (PowerShell): …"), shared by the transcript's activity rows and the
3
+ // composer's background-tasks panel so a tool reads the same everywhere.
4
+ // Text that already starts with an emoji is returned unchanged.
5
+ export function prefixToolActivityEmoji(text) {
6
+ const value = String(text || '').trim();
7
+ if (!value) return '';
8
+ if (/^[\u{1F300}-\u{1FAFF}☀-➿]/u.test(value)) return value;
9
+ if (value.startsWith('● ')) return `🔄 ${value.slice(2).trim()}`;
10
+ if (/^Model selected:/i.test(value)) return `🧠 ${value}`;
11
+ if (/^Search \((glob|grep)\)/i.test(value)) return `🔍 ${value}`;
12
+ if (/^Tool \(ask_user\)/i.test(value)) return `❓ ${value}`;
13
+ if (/^Tool \(view\)/i.test(value)) return `👀 ${value}`;
14
+ if (/^Tool \(apply_patch\)/i.test(value)) return `🪡 ${value}`;
15
+ if (/^Tool \(powershell\)/i.test(value)) return `🪓 ${value}`;
16
+ if (/^Tool \(edit\)/i.test(value)) return `📝 ${value}`;
17
+ if (/^Tool \(read_file\)/i.test(value)) return `📄 ${value}`;
18
+ if (/^Tool \((grep_search|file_search)\)/i.test(value)) return `🔎 ${value}`;
19
+ if (/^Tool \(semantic_search\)/i.test(value)) return `🧭 ${value}`;
20
+ if (/^Tool \(vscode_listCodeUsages\)/i.test(value)) return `🔗 ${value}`;
21
+ if (/^Tool \(vscode_renameSymbol\)/i.test(value)) return `✏️ ${value}`;
22
+ if (/^Tool \(list_dir\)/i.test(value)) return `📂 ${value}`;
23
+ if (/^Tool \(create_directory\)/i.test(value)) return `📁 ${value}`;
24
+ if (/^Tool \((delete|remove)\)/i.test(value)) return `🗑️ ${value}`;
25
+ if (/^Tool \(execution_subagent\)/i.test(value)) return `🚀 ${value}`;
26
+ if (/^Tool \(get_errors\)/i.test(value)) return `🚨 ${value}`;
27
+ if (/^Tool \(debug_[^)]+\)/i.test(value)) return `🐞 ${value}`;
28
+ if (/^Tool \(fetch_webpage\)/i.test(value)) return `🌐 ${value}`;
29
+ if (/^Tool \(github_[^)]+\)/i.test(value)) return `🐙 ${value}`;
30
+ if (/^Tool \(run_in_terminal\)/i.test(value)) return `🖥️ ${value}`;
31
+ if (/^Tool \((create_file|write)\)/i.test(value)) return `🆕 ${value}`;
32
+ if (/^Tool \((bash|shell|terminal)\)/i.test(value)) return `🔧 ${value}`;
33
+ if (/^Tool \((sql|sqlite)\)/i.test(value)) return `🗄️ ${value}`;
34
+ if (/^Tool \(/i.test(value)) return `🛠️ ${value}`;
35
+ return `ℹ️ ${value}`;
36
+ }
@@ -0,0 +1,210 @@
1
+ // Keeps the transcript's reading position through layout changes that
2
+ // reflow the rows: phone rotation, the on-screen keyboard, a desktop window
3
+ // resize, a font-scale change. The browser only preserves scrollTop as a
4
+ // pixel count (and its own scroll anchoring pins the top-most row), so a
5
+ // reader sitting at the end of a portrait transcript lands screens above
6
+ // the end once the wider landscape rows shrink the content. A position is
7
+ // therefore described by content, not pixels: "at the bottom", or "this
8
+ // message is the top-most visible one, with this fraction of it scrolled
9
+ // past" — a fraction, because the rows themselves change height when the
10
+ // width changes, and a pixel offset would hide a whole shrunken row.
11
+
12
+ const BOTTOM_EPSILON_PX = 1;
13
+
14
+ /** Same rule the composer's follow-live logic uses: 8% of the viewport,
15
+ * clamped to 12–48 px. */
16
+ export function resolveNearBottomThresholdPx(clientHeight) {
17
+ const viewportHeight = Number(clientHeight || 0);
18
+ if (!Number.isFinite(viewportHeight) || viewportHeight <= 0) return 0;
19
+ return Math.min(48, Math.max(12, Math.floor(viewportHeight * 0.08)));
20
+ }
21
+
22
+ function distanceFromBottom(el) {
23
+ return Math.max(0, Number(el.scrollHeight || 0) - Number(el.clientHeight || 0) - Number(el.scrollTop || 0));
24
+ }
25
+
26
+ function transcriptRows(el) {
27
+ return Array.from(el.querySelectorAll('.msg[data-message-id]'));
28
+ }
29
+
30
+ /**
31
+ * Describes where the reader is. `atBottom` uses the near-bottom threshold
32
+ * rather than an exact pixel match: a reader a few pixels short of the end
33
+ * still means "the end", and a rotation must not strand them above it.
34
+ * Returns null for an empty transcript.
35
+ */
36
+ export function captureTranscriptAnchor(el, { nearBottomThresholdPx = null } = {}) {
37
+ if (!el) return null;
38
+ const rows = transcriptRows(el);
39
+ if (!rows.length) return null;
40
+ const threshold = nearBottomThresholdPx !== null && Number.isFinite(Number(nearBottomThresholdPx))
41
+ ? Math.max(0, Number(nearBottomThresholdPx))
42
+ : Math.max(BOTTOM_EPSILON_PX, resolveNearBottomThresholdPx(el.clientHeight));
43
+ const atBottom = distanceFromBottom(el) <= threshold;
44
+ const containerRect = el.getBoundingClientRect();
45
+ let anchorRow = null;
46
+ let offsetTop = 0;
47
+ let rowHeight = 0;
48
+ for (const row of rows) {
49
+ const rect = row.getBoundingClientRect();
50
+ if (rect.bottom <= containerRect.top + 1) continue;
51
+ if (rect.top >= containerRect.bottom) break;
52
+ anchorRow = row;
53
+ offsetTop = rect.top - containerRect.top;
54
+ rowHeight = Math.max(0, rect.bottom - rect.top);
55
+ break;
56
+ }
57
+ const maxScroll = Math.max(1, Number(el.scrollHeight || 0) - Number(el.clientHeight || 0));
58
+ return {
59
+ atBottom,
60
+ messageId: String(anchorRow?.dataset?.messageId || '').trim() || null,
61
+ // Negative while the row's top is scrolled past the edge; scaled to the
62
+ // row's height so the same share of it stays hidden after a reflow.
63
+ offsetFraction: rowHeight > 0 ? offsetTop / rowHeight : 0,
64
+ ratio: Math.min(1, Math.max(0, Number(el.scrollTop || 0) / maxScroll)),
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Moves the scroller so the captured position holds in the current layout.
70
+ * Bottom wins over the row anchor; a row that is no longer rendered falls
71
+ * back to the proportional position. Returns true when scrollTop changed.
72
+ */
73
+ export function restoreTranscriptAnchor(el, anchor) {
74
+ if (!el || !anchor) return false;
75
+ const before = Number(el.scrollTop || 0);
76
+ if (anchor.atBottom) {
77
+ el.scrollTop = Number(el.scrollHeight || 0);
78
+ return Number(el.scrollTop || 0) !== before;
79
+ }
80
+ const messageId = String(anchor.messageId || '').trim();
81
+ const row = messageId
82
+ ? transcriptRows(el).find((node) => String(node.dataset?.messageId || '').trim() === messageId)
83
+ : null;
84
+ if (row) {
85
+ const containerRect = el.getBoundingClientRect();
86
+ const rect = row.getBoundingClientRect();
87
+ const targetOffset = Number(anchor.offsetFraction || 0) * Math.max(0, rect.bottom - rect.top);
88
+ const delta = rect.top - containerRect.top - targetOffset;
89
+ if (Math.abs(delta) > 0.5) el.scrollTop = before + delta;
90
+ return Number(el.scrollTop || 0) !== before;
91
+ }
92
+ const maxScroll = Math.max(0, Number(el.scrollHeight || 0) - Number(el.clientHeight || 0));
93
+ el.scrollTop = Math.round(maxScroll * Number(anchor.ratio || 0));
94
+ return Number(el.scrollTop || 0) !== before;
95
+ }
96
+
97
+ const KEYBOARD_INPUT_TYPES = new Set(['', 'text', 'search', 'email', 'url', 'tel', 'number', 'password']);
98
+
99
+ /**
100
+ * The focused field inside the transcript that has the on-screen keyboard
101
+ * open (a question card's reply box or form input), or null.
102
+ */
103
+ export function focusedTranscriptTextField(el, active = globalThis.document?.activeElement) {
104
+ if (!el || !active || active === el) return null;
105
+ if (typeof el.contains !== 'function' || !el.contains(active)) return null;
106
+ const tag = String(active.tagName || '').toUpperCase();
107
+ if (tag === 'TEXTAREA') return active;
108
+ if (tag === 'INPUT') {
109
+ return KEYBOARD_INPUT_TYPES.has(String(active.type ?? '').toLowerCase()) ? active : null;
110
+ }
111
+ return active.isContentEditable === true ? active : null;
112
+ }
113
+
114
+ /**
115
+ * Scrolls the transcript just enough to show the field whole, top-aligned
116
+ * when it is taller than the viewport. Arithmetic on scrollTop rather than
117
+ * scrollIntoView, which would scroll the page root too. Returns true when
118
+ * scrollTop changed.
119
+ */
120
+ export function revealFieldInTranscript(el, field, { marginPx = 8 } = {}) {
121
+ if (!el || typeof field?.getBoundingClientRect !== 'function') return false;
122
+ const before = Number(el.scrollTop || 0);
123
+ const container = el.getBoundingClientRect();
124
+ const rect = field.getBoundingClientRect();
125
+ const viewTop = container.top + marginPx;
126
+ const viewBottom = container.bottom - marginPx;
127
+ let delta = 0;
128
+ if (rect.top < viewTop || rect.bottom - rect.top > viewBottom - viewTop) {
129
+ delta = rect.top - viewTop;
130
+ } else if (rect.bottom > viewBottom) {
131
+ delta = rect.bottom - viewBottom;
132
+ }
133
+ if (Math.abs(delta) <= 0.5) return false;
134
+ el.scrollTop = before + delta;
135
+ return Number(el.scrollTop || 0) !== before;
136
+ }
137
+
138
+ /**
139
+ * Tracks the position across resize storms. Scroll events commit the
140
+ * reader's position; a resize re-applies the last committed position and
141
+ * opens a settle window during which scroll events are ignored — they are
142
+ * the browser's own anchoring and our re-pins, not the reader. Rotation on
143
+ * a phone resizes in several steps (browser chrome animates), so the window
144
+ * extends with every step and the position is re-applied once more when it
145
+ * closes.
146
+ *
147
+ * The browser's reveal of a focused field when the keyboard shrinks the
148
+ * viewport is one of those ignored scrolls, so every re-apply reveals the
149
+ * focused transcript field again itself.
150
+ */
151
+ export function createTranscriptResizeKeeper({
152
+ getElement,
153
+ settleMs = 500,
154
+ setTimer = (fn, ms) => setTimeout(fn, ms),
155
+ clearTimer = (id) => clearTimeout(id),
156
+ capture = captureTranscriptAnchor,
157
+ restore = restoreTranscriptAnchor,
158
+ getFocusedField = focusedTranscriptTextField,
159
+ reveal = revealFieldInTranscript,
160
+ } = {}) {
161
+ let committed = null;
162
+ let settleTimer = null;
163
+
164
+ function element() {
165
+ try { return typeof getElement === 'function' ? getElement() : null; } catch { return null; }
166
+ }
167
+
168
+ function recordScroll() {
169
+ if (settleTimer !== null) return false;
170
+ const el = element();
171
+ if (!el) return false;
172
+ const next = capture(el);
173
+ if (next) committed = next;
174
+ return Boolean(next);
175
+ }
176
+
177
+ function apply(el) {
178
+ const restored = committed ? restore(el, committed) : false;
179
+ let field = null;
180
+ try { field = getFocusedField(el); } catch { field = null; }
181
+ const revealed = field ? reveal(el, field) : false;
182
+ return restored || revealed;
183
+ }
184
+
185
+ function endSettle() {
186
+ settleTimer = null;
187
+ const el = element();
188
+ if (el) apply(el);
189
+ }
190
+
191
+ function handleResize() {
192
+ const el = element();
193
+ if (!el) return false;
194
+ if (!committed) {
195
+ // Nothing committed yet (no scroll since load): the current position
196
+ // is the best description we have.
197
+ committed = capture(el);
198
+ }
199
+ if (settleTimer !== null) clearTimer(settleTimer);
200
+ settleTimer = setTimer(endSettle, settleMs);
201
+ return apply(el);
202
+ }
203
+
204
+ return {
205
+ recordScroll,
206
+ handleResize,
207
+ isSettling: () => settleTimer !== null,
208
+ committedAnchor: () => committed,
209
+ };
210
+ }