@oar-sh/oar 0.9.0

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 (293) hide show
  1. package/.github/extensions/web-relay/README.md +190 -0
  2. package/.github/extensions/web-relay/extension.mjs +972 -0
  3. package/.github/extensions/web-relay/model-api/model-switching.mjs +338 -0
  4. package/.github/extensions/web-relay/polling/heartbeat.mjs +55 -0
  5. package/.github/extensions/web-relay/polling/polling-loop.mjs +1105 -0
  6. package/.github/extensions/web-relay/runtime/api-client.mjs +34 -0
  7. package/.github/extensions/web-relay/runtime/banner-state.mjs +126 -0
  8. package/.github/extensions/web-relay/runtime/config-loader.mjs +104 -0
  9. package/.github/extensions/web-relay/runtime/debug-log.mjs +27 -0
  10. package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +170 -0
  11. package/.github/extensions/web-relay/runtime/session-binding.mjs +78 -0
  12. package/.github/extensions/web-relay/runtime/session-io.mjs +349 -0
  13. package/.github/extensions/web-relay/runtime/session-join-retry.mjs +44 -0
  14. package/.github/extensions/web-relay/runtime/session-registry.mjs +29 -0
  15. package/.github/extensions/web-relay/runtime/session-runtime-manager.mjs +156 -0
  16. package/.github/extensions/web-relay/runtime/session-sync-bridge.mjs +74 -0
  17. package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +325 -0
  18. package/.github/extensions/web-relay/runtime/workspace-root.mjs +55 -0
  19. package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +386 -0
  20. package/.github/extensions/web-relay/skills/prompt-context.mjs +160 -0
  21. package/.github/extensions/web-relay/skills/question-bridge.mjs +141 -0
  22. package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +463 -0
  23. package/.github/extensions/web-relay/skills/reasoning-stream.mjs +285 -0
  24. package/.github/extensions/web-relay/skills/subagent-lifecycle.mjs +345 -0
  25. package/.github/extensions/web-relay/skills/tool-activity.mjs +243 -0
  26. package/.github/extensions/web-relay/utils/process-utils.mjs +25 -0
  27. package/.github/extensions/web-relay/utils/token-generation.mjs +58 -0
  28. package/CHANGELOG.md +25 -0
  29. package/LICENSE +21 -0
  30. package/README.md +717 -0
  31. package/bin/bat/oar.bat +46 -0
  32. package/bin/oar.js +665 -0
  33. package/package.json +68 -0
  34. package/server/README.md +1379 -0
  35. package/server/claude-worker/claude-attachments.mjs +134 -0
  36. package/server/claude-worker/claude-preview-tool.mjs +57 -0
  37. package/server/claude-worker/claude-sdk-adapter.mjs +420 -0
  38. package/server/claude-worker/claude-session-process.mjs +2391 -0
  39. package/server/claude-worker/claude-session-test-harness.mjs +236 -0
  40. package/server/claude-worker/claude-session-worker.mjs +186 -0
  41. package/server/claude-worker/claude-transcript-relocator.mjs +189 -0
  42. package/server/claude-worker/claude-turn-publisher.mjs +291 -0
  43. package/server/claude-worker/fixtures/workflow-run-record.json +1 -0
  44. package/server/claude-worker/sdk-message-normalizer.mjs +629 -0
  45. package/server/claude-worker/workflow-progress-digest.mjs +269 -0
  46. package/server/copilot-sdk-runtime.mjs +126 -0
  47. package/server/copilot-worker/copilot-attachments.mjs +97 -0
  48. package/server/copilot-worker/copilot-byok-provider.mjs +121 -0
  49. package/server/copilot-worker/copilot-continuation-signals.mjs +349 -0
  50. package/server/copilot-worker/copilot-plan-board.mjs +110 -0
  51. package/server/copilot-worker/copilot-prompt-context.mjs +86 -0
  52. package/server/copilot-worker/copilot-question-bridge.mjs +216 -0
  53. package/server/copilot-worker/copilot-sdk-adapter.mjs +494 -0
  54. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +800 -0
  55. package/server/copilot-worker/copilot-sdk-session-process.mjs +1729 -0
  56. package/server/copilot-worker/copilot-sdk-session-worker.mjs +167 -0
  57. package/server/copilot-worker/copilot-sdk-test-harness.mjs +329 -0
  58. package/server/copilot-worker/fixtures/abort-turn.json +588 -0
  59. package/server/copilot-worker/fixtures/ask-user-turn.json +1154 -0
  60. package/server/copilot-worker/fixtures/background-timer-continuation.json +445 -0
  61. package/server/copilot-worker/fixtures/background-timer-turn.json +497 -0
  62. package/server/copilot-worker/fixtures/happy-turn.json +566 -0
  63. package/server/copilot-worker/fixtures/quota-turn.json +647 -0
  64. package/server/copilot-worker/fixtures/reasoning-turn.json +625 -0
  65. package/server/copilot-worker/fixtures/resume-turn.json +700 -0
  66. package/server/copilot-worker/fixtures/subagent-turn.json +1378 -0
  67. package/server/copilot-worker/fixtures/tool-permission-turn.json +1293 -0
  68. package/server/cursor-worker/cursor-ask-user-tool.mjs +83 -0
  69. package/server/cursor-worker/cursor-attachments.mjs +88 -0
  70. package/server/cursor-worker/cursor-context-usage.mjs +85 -0
  71. package/server/cursor-worker/cursor-preview-tool.mjs +44 -0
  72. package/server/cursor-worker/cursor-sdk-adapter.mjs +554 -0
  73. package/server/cursor-worker/cursor-session-worker.mjs +145 -0
  74. package/server/cursor-worker/cursor-subagent-roster.mjs +81 -0
  75. package/server/cursor-worker/cursor-turn-runner.mjs +717 -0
  76. package/server/cursor-worker/sdk-message-normalizer.mjs +485 -0
  77. package/server/db-schema.mjs +932 -0
  78. package/server/features.mjs +106 -0
  79. package/server/grok-worker/acp-client.mjs +434 -0
  80. package/server/grok-worker/acp-host-services.mjs +277 -0
  81. package/server/grok-worker/grok-context-usage.mjs +77 -0
  82. package/server/grok-worker/grok-sdk-adapter.mjs +334 -0
  83. package/server/grok-worker/grok-session-worker.mjs +145 -0
  84. package/server/grok-worker/grok-turn-runner.mjs +523 -0
  85. package/server/grok-worker/sdk-message-normalizer.mjs +311 -0
  86. package/server/migrations/0001-add-structured-answer.mjs +80 -0
  87. package/server/migrations/0002-recent-workspace-roots-path-key.mjs +121 -0
  88. package/server/migrations/0003-push-subscriptions.mjs +87 -0
  89. package/server/public/app/action-confirmations.js +267 -0
  90. package/server/public/app/activity-replay-state.mjs +149 -0
  91. package/server/public/app/api-client.js +1082 -0
  92. package/server/public/app/ask-user-view.js +545 -0
  93. package/server/public/app/attachments-view.js +2112 -0
  94. package/server/public/app/auto-compact-window-options.mjs +59 -0
  95. package/server/public/app/background-tasks-view.mjs +494 -0
  96. package/server/public/app/bootstrap.js +4347 -0
  97. package/server/public/app/claude-auth-ui.js +394 -0
  98. package/server/public/app/claude-thinking-options.mjs +38 -0
  99. package/server/public/app/cli-install-ui.js +455 -0
  100. package/server/public/app/code-copy.mjs +85 -0
  101. package/server/public/app/composer-attachment-cache.mjs +151 -0
  102. package/server/public/app/composer-control-state.mjs +107 -0
  103. package/server/public/app/composer-paste.mjs +132 -0
  104. package/server/public/app/context-tier-options.mjs +68 -0
  105. package/server/public/app/context-usage-view.mjs +338 -0
  106. package/server/public/app/conversation-draft-timestamp-utils.mjs +11 -0
  107. package/server/public/app/conversation-preferences.mjs +91 -0
  108. package/server/public/app/conversation-provider-indicator.mjs +88 -0
  109. package/server/public/app/conversation-view.js +2898 -0
  110. package/server/public/app/cwd-menu-placement.mjs +85 -0
  111. package/server/public/app/cwd-picker.js +605 -0
  112. package/server/public/app/emoji-view.js +101 -0
  113. package/server/public/app/external-link-policy.mjs +80 -0
  114. package/server/public/app/file-preview-navigation.mjs +51 -0
  115. package/server/public/app/font-scaling.js +262 -0
  116. package/server/public/app/git-changes-view.js +339 -0
  117. package/server/public/app/git-diff-model.mjs +109 -0
  118. package/server/public/app/grok-auth-ui.js +387 -0
  119. package/server/public/app/image-downscale.mjs +174 -0
  120. package/server/public/app/in-flight-snapshot.mjs +40 -0
  121. package/server/public/app/infinite-loader.js +164 -0
  122. package/server/public/app/journal-view.js +1141 -0
  123. package/server/public/app/known-cwd-options.mjs +41 -0
  124. package/server/public/app/launch-cwd-path.mjs +23 -0
  125. package/server/public/app/live-message-dedupe.mjs +57 -0
  126. package/server/public/app/message-search-view.js +242 -0
  127. package/server/public/app/model-selector-options.mjs +31 -0
  128. package/server/public/app/model-vision-support.mjs +56 -0
  129. package/server/public/app/new-conversation-model-choice.mjs +67 -0
  130. package/server/public/app/plan-usage-view.mjs +307 -0
  131. package/server/public/app/preview-cards.mjs +273 -0
  132. package/server/public/app/preview-command.mjs +103 -0
  133. package/server/public/app/push-settings.js +370 -0
  134. package/server/public/app/pwa-install.js +456 -0
  135. package/server/public/app/question-schema-view.mjs +71 -0
  136. package/server/public/app/reasoning-effort-labels.mjs +42 -0
  137. package/server/public/app/relay-board-view.js +117 -0
  138. package/server/public/app/relay-dot-state.mjs +69 -0
  139. package/server/public/app/relay-error-ctas.mjs +52 -0
  140. package/server/public/app/relay-prompt-sanitizer.mjs +114 -0
  141. package/server/public/app/relay-thoughts.mjs +98 -0
  142. package/server/public/app/relay-watchdog-policy.mjs +29 -0
  143. package/server/public/app/repo-browser-preferences.mjs +79 -0
  144. package/server/public/app/repo-browser-tree-state.mjs +97 -0
  145. package/server/public/app/router.js +657 -0
  146. package/server/public/app/selection-guard.mjs +93 -0
  147. package/server/public/app/settings-modal.js +1484 -0
  148. package/server/public/app/settings-tabs.js +129 -0
  149. package/server/public/app/slash-autocomplete.mjs +188 -0
  150. package/server/public/app/slash-commands.mjs +183 -0
  151. package/server/public/app/socket-handlers.js +703 -0
  152. package/server/public/app/status-store.mjs +242 -0
  153. package/server/public/app/status-view.mjs +315 -0
  154. package/server/public/app/store.js +1454 -0
  155. package/server/public/app/stream-state.mjs +73 -0
  156. package/server/public/app/streaming-dom-patch.mjs +23 -0
  157. package/server/public/app/sync-outbox.mjs +208 -0
  158. package/server/public/app/tap-actions.js +62 -0
  159. package/server/public/app/thread-order.mjs +54 -0
  160. package/server/public/app/tmux-inspector-view.js +422 -0
  161. package/server/public/app/transcript-separators.mjs +282 -0
  162. package/server/public/app-icon-192.png +0 -0
  163. package/server/public/app-icon-512.png +0 -0
  164. package/server/public/app-icon.svg +4 -0
  165. package/server/public/favicon.ico +0 -0
  166. package/server/public/index.html +5743 -0
  167. package/server/public/manifest.webmanifest +32 -0
  168. package/server/public/sw.js +307 -0
  169. package/server/relay-exit-codes.mjs +4 -0
  170. package/server/relay-self-restart.mjs +170 -0
  171. package/server/relay-tools.md +27 -0
  172. package/server/relay.mjs +1026 -0
  173. package/server/repositories/image-conversation-repository.mjs +317 -0
  174. package/server/repositories/message-repository.mjs +434 -0
  175. package/server/repositories/question-repository.mjs +161 -0
  176. package/server/repositories/session-repository.mjs +320 -0
  177. package/server/repositories/share-visibility-statements.mjs +24 -0
  178. package/server/routes/ask-user-routes.mjs +292 -0
  179. package/server/routes/cache-routes.mjs +44 -0
  180. package/server/routes/git-routes.mjs +92 -0
  181. package/server/routes/messages-routes-test-harness.mjs +111 -0
  182. package/server/routes/messages-routes.mjs +7107 -0
  183. package/server/routes/preview-routes.mjs +108 -0
  184. package/server/routes/push-routes.mjs +59 -0
  185. package/server/routes/relay-board-routes.mjs +268 -0
  186. package/server/routes/sessions-routes.mjs +6150 -0
  187. package/server/server-runtime.mjs +7463 -0
  188. package/server/server.js +24 -0
  189. package/server/services/ask-user-routing-service.mjs +117 -0
  190. package/server/services/cache-rebuild-service.mjs +236 -0
  191. package/server/services/claude-auth-service.mjs +578 -0
  192. package/server/services/claude-context-usage.mjs +240 -0
  193. package/server/services/claude-session-root-service.mjs +228 -0
  194. package/server/services/cli-install-service.mjs +1152 -0
  195. package/server/services/cli-process-runner.mjs +181 -0
  196. package/server/services/cloudflared-tunnel-service.mjs +358 -0
  197. package/server/services/context-snapshot-service.mjs +499 -0
  198. package/server/services/context-usage-view.mjs +160 -0
  199. package/server/services/conversation-preferences-service.mjs +208 -0
  200. package/server/services/cursor-dashboard-usage.mjs +217 -0
  201. package/server/services/cursor-session-root-service.mjs +62 -0
  202. package/server/services/delete-archive-service.mjs +154 -0
  203. package/server/services/drives-path-helpers.mjs +66 -0
  204. package/server/services/fixtures/claude-auth-stub.sh +62 -0
  205. package/server/services/fixtures/cli-install-stub.sh +75 -0
  206. package/server/services/fixtures/grok-stub.sh +86 -0
  207. package/server/services/generated-image-cleanup-service.mjs +114 -0
  208. package/server/services/git-changes-service.mjs +195 -0
  209. package/server/services/github-billing-usage.mjs +133 -0
  210. package/server/services/grok-auth-service.mjs +593 -0
  211. package/server/services/grok-billing-usage.mjs +120 -0
  212. package/server/services/image-context-service.mjs +106 -0
  213. package/server/services/image-operation-service.mjs +135 -0
  214. package/server/services/keyed-mutex-service.mjs +82 -0
  215. package/server/services/mime-sniffer.mjs +118 -0
  216. package/server/services/oar-cli-helpers.mjs +109 -0
  217. package/server/services/oar-state-migration-service.mjs +243 -0
  218. package/server/services/plan-usage-claude.mjs +387 -0
  219. package/server/services/plan-usage-contract.mjs +271 -0
  220. package/server/services/plan-usage-copilot.mjs +421 -0
  221. package/server/services/plan-usage-cursor.mjs +566 -0
  222. package/server/services/plan-usage-grok.mjs +436 -0
  223. package/server/services/plan-usage-service.mjs +487 -0
  224. package/server/services/preview-health-probe.mjs +82 -0
  225. package/server/services/preview-proxy-server.mjs +498 -0
  226. package/server/services/preview-registry-service.mjs +457 -0
  227. package/server/services/preview-static-handler.mjs +237 -0
  228. package/server/services/provider-model-selection.mjs +91 -0
  229. package/server/services/provider-reasoning-effort.mjs +94 -0
  230. package/server/services/push-active-device-service.mjs +57 -0
  231. package/server/services/push-dispatch-service.mjs +459 -0
  232. package/server/services/rate-limit-service.mjs +63 -0
  233. package/server/services/relay-bridge-owner-service.mjs +84 -0
  234. package/server/services/relay-cli-launcher-service.mjs +209 -0
  235. package/server/services/relay-prompt-sanitizer.mjs +137 -0
  236. package/server/services/relay-queue-gate-service.mjs +38 -0
  237. package/server/services/relay-restart-orchestrator-service.mjs +935 -0
  238. package/server/services/relay-singleton-guard.mjs +124 -0
  239. package/server/services/safe-served-content.mjs +57 -0
  240. package/server/services/sdk-session-import-service.mjs +271 -0
  241. package/server/services/sdk-session-sync-service.mjs +207 -0
  242. package/server/services/session-history-refresh-service.mjs +497 -0
  243. package/server/services/session-transcript-service.mjs +419 -0
  244. package/server/services/session-worker-launch-service.mjs +786 -0
  245. package/server/services/session-worker-process-service.mjs +319 -0
  246. package/server/services/session-worker-registry-service.mjs +194 -0
  247. package/server/services/session-worker-stop-service.mjs +141 -0
  248. package/server/services/session-worker-supervisor-service.mjs +898 -0
  249. package/server/services/session-worker-websocket-service.mjs +483 -0
  250. package/server/services/ssh-tunnel-manager-service.mjs +423 -0
  251. package/server/services/status-event-service.mjs +158 -0
  252. package/server/services/tmux-inspector-access-policy.mjs +77 -0
  253. package/server/services/tmux-inspector-socket-service.mjs +191 -0
  254. package/server/services/tmux-inspector-stream-service.mjs +552 -0
  255. package/server/services/tunnel-worker-path-guard.mjs +107 -0
  256. package/server/services/upload-sweep.mjs +60 -0
  257. package/server/services/usage-snapshot-helpers.mjs +209 -0
  258. package/server/services/windows-autostart-service.mjs +115 -0
  259. package/server/services/workspace-root-defaults-service.mjs +34 -0
  260. package/server/services/workspace-root-path-policy.mjs +196 -0
  261. package/server/services/workspace-root-relaunch-service.mjs +132 -0
  262. package/server/services/workspace-symlink-guard.mjs +54 -0
  263. package/server/tools/capture-proxy.mjs +173 -0
  264. package/server/tty-console-bootstrap.mjs +75 -0
  265. package/server/uploads/README.md +36 -0
  266. package/server/uploads/image_input.md +540 -0
  267. package/server/windows-terminal-launcher.mjs +29 -0
  268. package/server/workspace-root.mjs +87 -0
  269. package/shared/ask-user-bridge.mjs +131 -0
  270. package/shared/auto-compact-window.mjs +116 -0
  271. package/shared/background-task-timeout.mjs +52 -0
  272. package/shared/claude-thinking.mjs +101 -0
  273. package/shared/context-window-fallbacks.mjs +148 -0
  274. package/shared/control-poller.mjs +115 -0
  275. package/shared/empty-turn-completion.mjs +20 -0
  276. package/shared/model-catalog-freshness.mjs +11 -0
  277. package/shared/model-descriptors.mjs +118 -0
  278. package/shared/model-id.mjs +147 -0
  279. package/shared/model-refresh.mjs +24 -0
  280. package/shared/openai-reasoning.mjs +13 -0
  281. package/shared/plan-lines.mjs +13 -0
  282. package/shared/preview-instructions.mjs +77 -0
  283. package/shared/preview-tool-core.mjs +191 -0
  284. package/shared/provider-routing.mjs +29 -0
  285. package/shared/question-schema.mjs +315 -0
  286. package/shared/question-timeout.mjs +18 -0
  287. package/shared/stream-emit-gating.mjs +28 -0
  288. package/shared/subagent-run-id.mjs +17 -0
  289. package/shared/thought-cap.mjs +13 -0
  290. package/shared/tool-activity.mjs +98 -0
  291. package/shared/turn-ceiling.mjs +53 -0
  292. package/shared/worker-bootstrap.mjs +44 -0
  293. package/shared/worker-crash-guard.mjs +60 -0
@@ -0,0 +1,545 @@
1
+ import {
2
+ conversations,
3
+ currentConvId,
4
+ fmtDate,
5
+ escHtml,
6
+ relayQuestions,
7
+ relayQuestionDrafts,
8
+ } from './store.js';
9
+ import { loadRelayQuestions as loadRelayQuestionsApi, answerRelayQuestion, answerRelayQuestionStructured } from './api-client.js';
10
+ import { enqueueOutboxRequest, registerOutboxSync } from './sync-outbox.mjs';
11
+ import { renderLinkedPlainText } from './router.js';
12
+ import { schemaFieldsFromQuestion } from './question-schema-view.mjs';
13
+ import { isChatInteractionHeld } from './selection-guard.mjs';
14
+
15
+ let relayQuestionRenderHash = '';
16
+ let renderedRelayQuestionIds = new Set();
17
+ const relayQuestionStructuredDrafts = new Map();
18
+ const RELAY_QUESTION_AUTO_SCROLL_THRESHOLD_PX = 80;
19
+
20
+ function distanceFromBottom(el) {
21
+ if (!el) return Number.POSITIVE_INFINITY;
22
+ return Math.max(0, el.scrollHeight - el.clientHeight - el.scrollTop);
23
+ }
24
+
25
+ function captureFocusedQuestionControl(messagesEl) {
26
+ const active = document.activeElement;
27
+ if (!active || typeof active.closest !== 'function') return null;
28
+ if (!messagesEl?.contains(active)) return null;
29
+ const questionNode = active.closest('.relay-question-container');
30
+ if (!questionNode) return null;
31
+ const questionId = String(questionNode.dataset.questionId || '').trim();
32
+ if (!questionId) return null;
33
+ return {
34
+ questionId,
35
+ controlId: String(active.id || '').trim(),
36
+ controlName: String(active.getAttribute?.('name') || '').trim(),
37
+ controlKey: String(active.dataset?.relayFocusKey || '').trim(),
38
+ tagName: String(active.tagName || '').toLowerCase(),
39
+ type: String(active.type || '').toLowerCase(),
40
+ selectionStart: Number.isInteger(active.selectionStart) ? active.selectionStart : null,
41
+ selectionEnd: Number.isInteger(active.selectionEnd) ? active.selectionEnd : null,
42
+ };
43
+ }
44
+
45
+ function restoreFocusedQuestionControl(messagesEl, snapshot) {
46
+ if (!messagesEl || !snapshot?.questionId) return;
47
+ const questionNode = messagesEl.querySelector(`.relay-question-container[data-question-id="${CSS.escape(snapshot.questionId)}"]`);
48
+ if (!questionNode) return;
49
+ let target = null;
50
+ if (snapshot.controlId) {
51
+ target = questionNode.querySelector(`#${CSS.escape(snapshot.controlId)}`);
52
+ }
53
+ if (!target && snapshot.controlName) {
54
+ target = questionNode.querySelector(`[name="${CSS.escape(snapshot.controlName)}"]`);
55
+ }
56
+ if (!target && snapshot.controlKey) {
57
+ target = questionNode.querySelector(`[data-relay-focus-key="${CSS.escape(snapshot.controlKey)}"]`);
58
+ }
59
+ if (!target) return;
60
+ if (typeof target.focus === 'function') {
61
+ target.focus({ preventScroll: true });
62
+ }
63
+ const isTextControl = target.tagName === 'TEXTAREA' || (target.tagName === 'INPUT' && ['text', 'email', 'url', 'search', 'tel'].includes(String(target.type || '').toLowerCase()));
64
+ if (!isTextControl) return;
65
+ const hasSelection = Number.isInteger(snapshot.selectionStart) && Number.isInteger(snapshot.selectionEnd);
66
+ if (!hasSelection || typeof target.setSelectionRange !== 'function') return;
67
+ const max = String(target.value || '').length;
68
+ const start = Math.max(0, Math.min(snapshot.selectionStart, max));
69
+ const end = Math.max(0, Math.min(snapshot.selectionEnd, max));
70
+ target.setSelectionRange(start, end);
71
+ }
72
+
73
+ export function upsertRelayQuestion(question) {
74
+ if (!question || !question.id) return;
75
+ relayQuestions.set(question.id, question);
76
+ updatePendingQuestionBanner();
77
+ window.renderConvList?.();
78
+ renderRelayQuestions();
79
+ }
80
+
81
+ export async function loadRelayQuestions(conversationId) {
82
+ const pendingRes = await loadRelayQuestionsApi('pending');
83
+ if (!pendingRes) return;
84
+ const pending = Array.isArray(pendingRes?.questions) ? pendingRes.questions.filter((q) => q && q.id) : [];
85
+ const next = new Map(pending.map((q) => [q.id, q]));
86
+ for (const [id, q] of relayQuestions.entries()) {
87
+ if (q.status === 'answered' && !next.has(id)) next.set(id, q);
88
+ }
89
+ for (const questionId of relayQuestionDrafts.keys()) {
90
+ const question = next.get(questionId);
91
+ if (!question || question.status !== 'pending') {
92
+ relayQuestionDrafts.delete(questionId);
93
+ }
94
+ }
95
+ for (const questionId of relayQuestionStructuredDrafts.keys()) {
96
+ const question = next.get(questionId);
97
+ if (!question || question.status !== 'pending') {
98
+ relayQuestionStructuredDrafts.delete(questionId);
99
+ }
100
+ }
101
+ relayQuestions.clear();
102
+ for (const [id, q] of next.entries()) relayQuestions.set(id, q);
103
+ updatePendingQuestionBanner();
104
+ window.renderConvList?.();
105
+
106
+ if (!conversationId && pending.length) {
107
+ const latest = pending
108
+ .slice()
109
+ .sort((a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0))[0];
110
+ const targetConversationId = String(latest?.conversationId || '').trim();
111
+ if (targetConversationId && targetConversationId !== currentConvId && conversations[targetConversationId]) {
112
+ await window.openConversation?.(targetConversationId);
113
+ return;
114
+ }
115
+ }
116
+
117
+ renderRelayQuestions();
118
+ }
119
+
120
+ export function getPendingRelayQuestions() {
121
+ return Array.from(relayQuestions.values())
122
+ .filter((q) => q && q.id && q.status === 'pending')
123
+ .sort((a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0));
124
+ }
125
+
126
+ export function getPendingQuestionCountsByConversation() {
127
+ const counts = Object.create(null);
128
+ for (const question of getPendingRelayQuestions()) {
129
+ const conversationId = String(question?.conversationId || '').trim();
130
+ if (!conversationId) continue;
131
+ counts[conversationId] = (counts[conversationId] || 0) + 1;
132
+ }
133
+ return counts;
134
+ }
135
+
136
+ export function updatePendingQuestionBanner() {
137
+ const banner = document.getElementById('pending-question-banner');
138
+ if (!banner) return;
139
+ const pending = getPendingRelayQuestions();
140
+ if (!pending.length) {
141
+ banner.textContent = '';
142
+ banner.classList.remove('visible');
143
+ banner.disabled = true;
144
+ return;
145
+ }
146
+
147
+ const latest = pending[pending.length - 1];
148
+ const targetConversationId = String(latest?.conversationId || '').trim();
149
+ banner.dataset.targetConversationId = targetConversationId;
150
+ banner.dataset.targetQuestionId = String(latest?.id || '').trim();
151
+ const count = pending.length;
152
+ const currentCount = currentConvId ? pending.filter((q) => q?.conversationId === currentConvId).length : 0;
153
+ const convTitle = escHtml(conversations[targetConversationId]?.title || targetConversationId || 'conversation');
154
+ banner.innerHTML = currentCount > 0
155
+ ? `❓ ${count} open question${count === 1 ? '' : 's'}`
156
+ : `❓ ${count} open question${count === 1 ? '' : 's'} · latest in ${convTitle}`;
157
+ banner.classList.add('visible');
158
+ banner.disabled = false;
159
+ }
160
+
161
+ export async function openPendingQuestionFromBanner() {
162
+ const banner = document.getElementById('pending-question-banner');
163
+ if (!banner) return;
164
+ const targetConversationId = String(banner.dataset.targetConversationId || '').trim();
165
+ const pending = getPendingRelayQuestions();
166
+ const fallbackConversationId = String(pending[pending.length - 1]?.conversationId || '').trim();
167
+ const nextConversationId = targetConversationId || fallbackConversationId;
168
+ if (!nextConversationId) return;
169
+ if (nextConversationId !== currentConvId) {
170
+ if (!conversations[nextConversationId]) {
171
+ await window.refreshConversations?.();
172
+ }
173
+ await window.openConversation?.(nextConversationId);
174
+ if (String(currentConvId || '').trim() !== nextConversationId) {
175
+ window.showTransientRelayNotice?.('Could not open the latest question conversation yet. Try again in a moment.');
176
+ }
177
+ return;
178
+ }
179
+ renderRelayQuestions();
180
+ window.scrollBottom?.();
181
+ }
182
+
183
+ // Scoped to #messages on purpose: scrollIntoView walks every ancestor
184
+ // scroller, and when a short last card cannot align to the container's top the
185
+ // leftover scroll spills onto the page root — shifting the header off-screen
186
+ // with no user-visible way back (the body hides its scrollbar).
187
+ function scrollQuestionCardIntoMessages(el, target) {
188
+ if (!el || !target) return;
189
+ if (isChatInteractionHeld()) return;
190
+ const top = target.getBoundingClientRect().top - el.getBoundingClientRect().top + el.scrollTop;
191
+ el.scrollTop = Math.max(0, Math.min(top, el.scrollHeight - el.clientHeight));
192
+ }
193
+
194
+ export function renderRelayQuestions() {
195
+ const el = document.getElementById('messages');
196
+ if (!el) return;
197
+ const previousScrollTop = el.scrollTop;
198
+ const shouldAutoScroll = distanceFromBottom(el) <= RELAY_QUESTION_AUTO_SCROLL_THRESHOLD_PX;
199
+ const focusedControl = captureFocusedQuestionControl(el);
200
+ const questions = Array.from(relayQuestions.values())
201
+ .filter((q) => q && q.conversationId === currentConvId && q.status === 'pending')
202
+ .sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
203
+ const firstNewQuestionId = questions.find((question) => !renderedRelayQuestionIds.has(question.id))?.id || '';
204
+ const nextRenderedQuestionIds = new Set(questions.map((question) => question.id));
205
+
206
+ const nextHash = JSON.stringify(
207
+ questions.map((q) => ({
208
+ id: q.id,
209
+ status: q.status,
210
+ prompt: q.prompt || '',
211
+ answer: q.answer || '',
212
+ answeredAt: q.answeredAt || '',
213
+ createdAt: q.createdAt || '',
214
+ choices: Array.isArray(q.choices) ? q.choices : [],
215
+ schema: q.requestSchema || null,
216
+ }))
217
+ );
218
+ const existingCards = el.querySelectorAll('.relay-question-container');
219
+ if (relayQuestionRenderHash === nextHash && existingCards.length === questions.length) return;
220
+ relayQuestionRenderHash = nextHash;
221
+
222
+ for (const node of existingCards) {
223
+ const questionId = String(node?.dataset?.questionId || '').trim();
224
+ const question = relayQuestions.get(questionId);
225
+ const fields = schemaFieldsFromQuestion(question);
226
+ if (!questionId || !fields.length) continue;
227
+ relayQuestionStructuredDrafts.set(questionId, collectStructuredAnswer(question));
228
+ }
229
+ existingCards.forEach((node) => node.remove());
230
+ if (!questions.length) {
231
+ renderedRelayQuestionIds = nextRenderedQuestionIds;
232
+ if (!shouldAutoScroll) el.scrollTop = previousScrollTop;
233
+ return;
234
+ }
235
+
236
+ for (const question of questions) {
237
+ const wrapper = document.createElement('div');
238
+ wrapper.className = 'msg relay-question-container';
239
+ wrapper.dataset.questionId = question.id;
240
+
241
+ const modeTag = question.mode ? ` <span class="msg-mode">${escHtml(question.mode)}</span>` : '';
242
+ const contextText = String(question?.context?.rationale || '').trim();
243
+ const contextHtml = contextText ? `<div class="relay-question-context">${renderLinkedPlainText(contextText)}</div>` : '';
244
+ const fields = schemaFieldsFromQuestion(question);
245
+ const structuredQuestion = fields.length > 0;
246
+
247
+ let interactiveHtml = '';
248
+ if (structuredQuestion) {
249
+ interactiveHtml = renderMultiFieldForm(question);
250
+ } else {
251
+ const choices = Array.isArray(question.choices) ? question.choices : [];
252
+ const choiceHtml = choices.length
253
+ ? (question.status === 'pending'
254
+ ? `<div class="relay-question-choices">${
255
+ choices.map((choice) => `<button class="relay-question-choice" data-choice="${escHtml(choice)}" data-relay-focus-key="choice-${escHtml(choice)}" onclick="submitRelayQuestionChoice('${question.id}', this.dataset.choice)">${escHtml(choice)}</button>`).join('')
256
+ }</div>`
257
+ : `<div class="relay-question-choices">${
258
+ choices.map((choice) => `<button class="relay-question-choice" disabled>${escHtml(choice)}</button>`).join('')
259
+ }</div>`)
260
+ : '';
261
+ const showReplyControls = question.status === 'pending';
262
+ const draftText = String(relayQuestionDrafts.get(question.id) || '');
263
+ const replyHtml = showReplyControls
264
+ ? `<div class="relay-question-reply">
265
+ <textarea id="relay-question-input-${question.id}" placeholder="Type a reply… (Ctrl/Cmd+Enter to send)" onkeydown="handleRelayQuestionKey(event, '${question.id}')" oninput="onRelayQuestionDraftInput('${question.id}', this.value)">${escHtml(draftText)}</textarea>
266
+ <button class="relay-question-submit" onclick="submitRelayQuestionAnswer('${question.id}')">Reply</button>
267
+ </div>`
268
+ : '';
269
+ interactiveHtml = `${choiceHtml}${replyHtml}`;
270
+ }
271
+
272
+ const answeredHtml = question.status === 'answered' && question.answer
273
+ ? `<div class="relay-question-context"><strong>Your answer:</strong> ${escHtml(question.answer)}</div>`
274
+ : '';
275
+ const statusText = question.status === 'answered'
276
+ ? `Answered${question.answeredAt ? ` · ${fmtDate(question.answeredAt)}` : ''}`
277
+ : `${question.expiresAt ? `Expires ${fmtDate(question.expiresAt)} · ` : ''}Waiting for your answer`;
278
+
279
+ wrapper.innerHTML = `
280
+ <div class="relay-question-card${question.status === 'timed_out' ? ' relay-question-timed-out' : ''}">
281
+ <div class="relay-question-head">Copilot question${modeTag} · ${fmtDate(question.createdAt)}</div>
282
+ <div class="relay-question-body">${renderLinkedPlainText(question.prompt || '')}</div>
283
+ ${contextHtml}
284
+ ${answeredHtml}
285
+ ${interactiveHtml}
286
+ <div class="relay-question-status">${statusText}</div>
287
+ </div>`;
288
+
289
+ el.appendChild(wrapper);
290
+ }
291
+
292
+ renderedRelayQuestionIds = nextRenderedQuestionIds;
293
+ restoreFocusedQuestionControl(el, focusedControl);
294
+ if (firstNewQuestionId) {
295
+ const target = el.querySelector(`.relay-question-container[data-question-id="${CSS.escape(firstNewQuestionId)}"]`);
296
+ scrollQuestionCardIntoMessages(el, target);
297
+ } else if (shouldAutoScroll) {
298
+ window.scrollBottom?.();
299
+ } else {
300
+ el.scrollTop = previousScrollTop;
301
+ }
302
+ }
303
+
304
+ function fieldDomId(questionId, index) {
305
+ return `relay-field-${questionId}-${index}`;
306
+ }
307
+
308
+ function renderMultiFieldForm(question) {
309
+ const fields = schemaFieldsFromQuestion(question);
310
+ if (!fields.length) return '';
311
+ const disabled = question.status !== 'pending';
312
+ const draft = relayQuestionStructuredDrafts.get(question.id);
313
+ const submitted = question.structuredAnswer && typeof question.structuredAnswer === 'object'
314
+ ? question.structuredAnswer
315
+ : {};
316
+ const hasSubmitted = Object.keys(submitted).length > 0;
317
+ const sourceValues = hasSubmitted && question.status !== 'pending'
318
+ ? submitted
319
+ : (draft && typeof draft === 'object' ? draft : submitted);
320
+
321
+ const fieldHtml = fields.map((field, index) => {
322
+ const id = fieldDomId(question.id, index);
323
+ const current = Object.prototype.hasOwnProperty.call(sourceValues, field.name)
324
+ ? sourceValues[field.name]
325
+ : (field.hasDefault ? field.default : undefined);
326
+ const requiredMark = field.required ? ' <span class="relay-field-required">*</span>' : '';
327
+ const descHtml = field.description
328
+ ? `<div class="relay-field-desc">${escHtml(field.description)}</div>`
329
+ : '';
330
+ const control = renderFieldControl(field, id, current, disabled);
331
+ return `<div class="relay-field" data-field-name="${escHtml(field.name)}" data-field-type="${escHtml(field.type)}" data-field-id="${id}">
332
+ <label class="relay-field-label" for="${id}">${escHtml(field.title)}${requiredMark}</label>
333
+ ${descHtml}
334
+ ${control}
335
+ </div>`;
336
+ }).join('');
337
+
338
+ const submitHtml = disabled
339
+ ? ''
340
+ : `<div class="relay-question-reply">
341
+ <div class="relay-field-error" id="relay-form-error-${question.id}"></div>
342
+ <button class="relay-question-submit" onclick="submitRelayStructuredAnswer('${question.id}')">Submit</button>
343
+ </div>`;
344
+
345
+ return `<form class="relay-question-form" data-question-id="${question.id}" onsubmit="return false;">${fieldHtml}${submitHtml}</form>`;
346
+ }
347
+
348
+ function renderFieldControl(field, id, current, disabled) {
349
+ const dis = disabled ? ' disabled' : '';
350
+ if (field.type === 'boolean') {
351
+ const checked = current === true || String(current).toLowerCase() === 'true' ? ' checked' : '';
352
+ return `<label class="relay-field-check"><input type="checkbox" id="${id}"${checked}${dis}> Yes</label>`;
353
+ }
354
+ if (field.isMultiSelect && field.choices.length) {
355
+ const selected = Array.isArray(current) ? current.map(String) : [];
356
+ return `<div class="relay-field-multi" id="${id}">${
357
+ field.choices.map((choice, ci) => {
358
+ const checked = selected.includes(String(choice.value)) ? ' checked' : '';
359
+ const focusKey = `multi-${field.name}-${choice.value}`;
360
+ return `<label class="relay-field-check"><input type="checkbox" data-choice-value="${escHtml(String(choice.value))}" data-relay-focus-key="${escHtml(focusKey)}" value="${escHtml(String(choice.value))}"${checked}${dis}> ${escHtml(choice.label)}</label>`;
361
+ }).join('')
362
+ }</div>`;
363
+ }
364
+ if (field.choices.length) {
365
+ const currentStr = current === undefined || current === null ? '' : String(current);
366
+ const options = [`<option value=""${currentStr === '' ? ' selected' : ''}>${field.required ? 'Select…' : '(none)'}</option>`]
367
+ .concat(field.choices.map((choice) => {
368
+ const value = String(choice.value);
369
+ const sel = value === currentStr ? ' selected' : '';
370
+ return `<option value="${escHtml(value)}"${sel}>${escHtml(choice.label)}</option>`;
371
+ }));
372
+ return `<select id="${id}"${dis}>${options.join('')}</select>`;
373
+ }
374
+ if (field.type === 'number' || field.type === 'integer') {
375
+ const val = current === undefined || current === null ? '' : escHtml(String(current));
376
+ const step = field.type === 'integer' ? ' step="1"' : '';
377
+ return `<input type="number" id="${id}" value="${val}"${step}${dis}>`;
378
+ }
379
+ const val = current === undefined || current === null ? '' : escHtml(String(current));
380
+ const inputType = field.format === 'email' ? 'email' : (field.format === 'uri' || field.format === 'url' ? 'url' : 'text');
381
+ return `<input type="${inputType}" id="${id}" value="${val}"${dis}>`;
382
+ }
383
+
384
+ function collectStructuredAnswer(question) {
385
+ const fields = schemaFieldsFromQuestion(question);
386
+ const answer = {};
387
+ fields.forEach((field, index) => {
388
+ const id = fieldDomId(question.id, index);
389
+ if (field.type === 'boolean') {
390
+ const el = document.getElementById(id);
391
+ if (el) answer[field.name] = !!el.checked;
392
+ return;
393
+ }
394
+ if (field.isMultiSelect && field.choices.length) {
395
+ const container = document.getElementById(id);
396
+ const checked = container ? Array.from(container.querySelectorAll('input[type="checkbox"]:checked')) : [];
397
+ const values = checked.map((el) => el.getAttribute('data-choice-value'));
398
+ if (values.length) answer[field.name] = values;
399
+ return;
400
+ }
401
+ const el = document.getElementById(id);
402
+ if (!el) return;
403
+ const raw = String(el.value || '').trim();
404
+ if (!raw) return;
405
+ if (field.type === 'number' || field.type === 'integer') {
406
+ const num = Number(raw);
407
+ if (Number.isFinite(num)) answer[field.name] = field.type === 'integer' ? Math.trunc(num) : num;
408
+ return;
409
+ }
410
+ answer[field.name] = raw;
411
+ });
412
+ return answer;
413
+ }
414
+
415
+ function resolveSingleFieldStructuredAnswer(question, answerText) {
416
+ const fields = schemaFieldsFromQuestion(question);
417
+ if (fields.length !== 1) return null;
418
+ const field = fields[0];
419
+ const raw = String(answerText || '').trim();
420
+ if (!raw) return null;
421
+ if (!field.choices.length) {
422
+ return { [field.name]: raw };
423
+ }
424
+
425
+ const exact = field.choices.find((choice) => String(choice.value) === raw || String(choice.label) === raw);
426
+ if (exact) return { [field.name]: String(exact.value) };
427
+
428
+ const lowered = raw.toLowerCase();
429
+ const insensitive = field.choices.find((choice) => (
430
+ String(choice.value).toLowerCase() === lowered
431
+ || String(choice.label).toLowerCase() === lowered
432
+ ));
433
+ if (insensitive) return { [field.name]: String(insensitive.value) };
434
+
435
+ return { [field.name]: raw };
436
+ }
437
+
438
+ export async function submitRelayStructuredAnswer(questionId) {
439
+ const question = relayQuestions.get(questionId) || null;
440
+ if (!question) return;
441
+ const errorEl = document.getElementById(`relay-form-error-${questionId}`);
442
+ if (errorEl) errorEl.textContent = '';
443
+
444
+ const structuredAnswer = collectStructuredAnswer(question);
445
+ const card = document.querySelector(`.relay-question-container[data-question-id="${questionId}"]`);
446
+ const controls = card ? card.querySelectorAll('button, input, select, textarea') : [];
447
+ controls.forEach((el) => { el.disabled = true; });
448
+
449
+ try {
450
+ const sdkSessionId = String(question?.sdkSessionId || '').trim();
451
+ const r = await answerRelayQuestionStructured(questionId, structuredAnswer, sdkSessionId || null);
452
+ if (!r?.ok) {
453
+ const queued = await enqueueRelayAnswerForSync(questionId, {
454
+ structuredAnswer,
455
+ sdk_session_id: sdkSessionId || undefined,
456
+ });
457
+ if (queued) return;
458
+ controls.forEach((el) => { el.disabled = false; });
459
+ const fieldErrors = Array.isArray(r?.fields) ? r.fields.map((f) => f.message).join('; ') : '';
460
+ const message = fieldErrors || r?.error || 'Failed to submit answer';
461
+ if (errorEl) errorEl.textContent = message;
462
+ else alert(message);
463
+ return;
464
+ }
465
+ relayQuestionDrafts.delete(questionId);
466
+ relayQuestionStructuredDrafts.delete(questionId);
467
+ if (r.question) relayQuestions.set(questionId, r.question);
468
+ updatePendingQuestionBanner();
469
+ window.renderConvList?.();
470
+ renderRelayQuestions();
471
+ window.showTransientRelayNotice?.(`✅ Answer received · Agent continuing…`, 7000);
472
+ } catch (e) {
473
+ controls.forEach((el) => { el.disabled = false; });
474
+ if (errorEl) errorEl.textContent = e.message || 'Failed to submit answer';
475
+ else alert(e.message || 'Failed to submit answer');
476
+ }
477
+ }
478
+
479
+ export async function submitRelayQuestionChoice(questionId, choice) {
480
+ await submitRelayQuestionAnswer(questionId, choice);
481
+ }
482
+
483
+ // Offline fallback: park the answer in the durable outbox so Background Sync
484
+ // (or the next page load) delivers it. A replay against an already-answered
485
+ // or expired question gets a 4xx and is dropped, so this cannot double-answer.
486
+ async function enqueueRelayAnswerForSync(questionId, body) {
487
+ if (navigator.onLine !== false) return false;
488
+ const queued = await enqueueOutboxRequest({
489
+ kind: 'question-answer',
490
+ path: `/api/relay-question/${encodeURIComponent(questionId)}/answer`,
491
+ body: JSON.stringify(body),
492
+ });
493
+ if (queued) {
494
+ void registerOutboxSync();
495
+ window.showTransientRelayNotice?.('You are offline. Answer queued — it will send when the connection returns.', 7000);
496
+ }
497
+ return queued;
498
+ }
499
+
500
+ export async function submitRelayQuestionAnswer(questionId, presetAnswer = null) {
501
+ const input = document.getElementById(`relay-question-input-${questionId}`);
502
+ const answer = String(presetAnswer != null ? presetAnswer : (input?.value || '')).trim();
503
+ if (!answer) return;
504
+
505
+ const card = document.querySelector(`.relay-question-container[data-question-id="${questionId}"]`);
506
+ const controls = card ? card.querySelectorAll('button, textarea') : [];
507
+ controls.forEach((el) => { el.disabled = true; });
508
+
509
+ try {
510
+ const question = relayQuestions.get(questionId) || null;
511
+ const sdkSessionId = String(question?.sdkSessionId || '').trim();
512
+ const singleFieldStructuredAnswer = resolveSingleFieldStructuredAnswer(question, answer);
513
+ const r = singleFieldStructuredAnswer
514
+ ? await answerRelayQuestionStructured(questionId, singleFieldStructuredAnswer, sdkSessionId || null)
515
+ : await answerRelayQuestion(questionId, answer, sdkSessionId || null);
516
+ if (!r?.question) {
517
+ const queued = await enqueueRelayAnswerForSync(questionId, singleFieldStructuredAnswer
518
+ ? { structuredAnswer: singleFieldStructuredAnswer, sdk_session_id: sdkSessionId || undefined }
519
+ : { answer, sdk_session_id: sdkSessionId || undefined });
520
+ if (queued) return;
521
+ throw new Error('Failed to submit relay question answer');
522
+ }
523
+ relayQuestionDrafts.delete(questionId);
524
+ relayQuestionStructuredDrafts.delete(questionId);
525
+ relayQuestions.set(questionId, r.question);
526
+ updatePendingQuestionBanner();
527
+ window.renderConvList?.();
528
+ renderRelayQuestions();
529
+ window.showTransientRelayNotice?.(`✅ Answer received: ${answer} · Agent continuing…`, 7000);
530
+ } catch (e) {
531
+ controls.forEach((el) => { el.disabled = false; });
532
+ alert(e.message || 'Failed to submit relay question answer');
533
+ }
534
+ }
535
+
536
+ export function onRelayQuestionDraftInput(questionId, value) {
537
+ relayQuestionDrafts.set(String(questionId || ''), String(value || ''));
538
+ }
539
+
540
+ export function handleRelayQuestionKey(e, questionId) {
541
+ if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
542
+ e.preventDefault();
543
+ submitRelayQuestionAnswer(questionId);
544
+ }
545
+ }