@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,456 @@
1
+ import { BASE, IS_SHARED_VIEW, showTransientRelayNotice } from './store.js';
2
+
3
+ const THEME_COLOR_BASE = '#0d1117';
4
+ const THEME_COLOR_IMMERSIVE = '#161b22';
5
+ const PWA_APP_NAME_STORAGE_KEY = 'copilot_pwa_app_name';
6
+ const PWA_APP_NAME_DEFAULT = 'OAR';
7
+ const PWA_APP_NAME_MAX_LENGTH = 60;
8
+ const INSTALLED_DISPLAY_MODE_QUERIES = ['(display-mode: standalone)', '(display-mode: fullscreen)'];
9
+
10
+ let deferredInstallPrompt = null;
11
+ let pendingInstalledFullscreenGesture = false;
12
+ let manifestTemplateCache = null;
13
+ let customManifestUrl = null;
14
+
15
+ function matchesDisplayMode(query) {
16
+ try {
17
+ return !!window.matchMedia(query).matches;
18
+ } catch {
19
+ return false;
20
+ }
21
+ }
22
+
23
+ function isInstalledAppMode() {
24
+ const standalone = matchesDisplayMode('(display-mode: standalone)');
25
+ const minimalUi = matchesDisplayMode('(display-mode: minimal-ui)');
26
+ const launchedFromAndroidApp = String(document.referrer || '').startsWith('android-app://');
27
+ return (
28
+ window.navigator.standalone === true
29
+ || launchedFromAndroidApp
30
+ || standalone
31
+ || minimalUi
32
+ );
33
+ }
34
+
35
+ function isDisplayModeFullscreen() {
36
+ return matchesDisplayMode('(display-mode: fullscreen)');
37
+ }
38
+
39
+ function isBrowserFullscreenMode() {
40
+ return !!document.fullscreenElement;
41
+ }
42
+
43
+ function shouldUseImmersiveTopLayout() {
44
+ return isDisplayModeFullscreen() || isBrowserFullscreenMode();
45
+ }
46
+
47
+ function syncThemeColor(immersive) {
48
+ const meta = document.querySelector('meta[name="theme-color"]');
49
+ if (!meta) return;
50
+ meta.setAttribute('content', immersive ? THEME_COLOR_IMMERSIVE : THEME_COLOR_BASE);
51
+ }
52
+
53
+ export function syncInstalledAppUiState() {
54
+ const installed = isInstalledAppMode();
55
+ const immersive = shouldUseImmersiveTopLayout();
56
+ document.body.classList.toggle('installed-app', installed);
57
+ document.body.classList.toggle('immersive-top', immersive);
58
+ syncThemeColor(immersive);
59
+ }
60
+
61
+ function canToggleFullscreen() {
62
+ return !!document.documentElement.requestFullscreen || !!document.fullscreenElement;
63
+ }
64
+
65
+ async function ensureInstalledAppFullscreen(options = {}) {
66
+ syncInstalledAppUiState();
67
+ if (!isInstalledAppMode()) {
68
+ return false;
69
+ }
70
+ if (isDisplayModeFullscreen() || document.fullscreenElement) {
71
+ return true;
72
+ }
73
+ if (!canToggleFullscreen()) return false;
74
+ if (!options.userGesture) return false;
75
+ try {
76
+ await document.documentElement.requestFullscreen();
77
+ return true;
78
+ } catch {
79
+ return false;
80
+ } finally {
81
+ updateInstallButton();
82
+ updateFullscreenButton();
83
+ }
84
+ }
85
+
86
+ function shouldQueueInstalledFullscreen() {
87
+ return isInstalledAppMode()
88
+ && window.matchMedia('(max-width: 680px)').matches
89
+ && canToggleFullscreen()
90
+ && !document.fullscreenElement;
91
+ }
92
+
93
+ function queueInstalledFullscreenGesture() {
94
+ pendingInstalledFullscreenGesture = shouldQueueInstalledFullscreen();
95
+ }
96
+
97
+ function consumeInstalledFullscreenGesture() {
98
+ if (!pendingInstalledFullscreenGesture || !shouldQueueInstalledFullscreen()) return;
99
+ pendingInstalledFullscreenGesture = false;
100
+ ensureInstalledAppFullscreen({ userGesture: true }).catch(() => {
101
+ pendingInstalledFullscreenGesture = true;
102
+ });
103
+ }
104
+
105
+ function initInstalledFullscreenGestureBridge() {
106
+ if (window.__installedFullscreenGestureBridgeBound) return;
107
+ window.__installedFullscreenGestureBridgeBound = true;
108
+ const consume = () => consumeInstalledFullscreenGesture();
109
+ document.addEventListener('pointerdown', consume, true);
110
+ document.addEventListener('keydown', consume, true);
111
+ window.addEventListener('pageshow', () => {
112
+ queueInstalledFullscreenGesture();
113
+ });
114
+ document.addEventListener('visibilitychange', () => {
115
+ if (document.visibilityState === 'visible') {
116
+ queueInstalledFullscreenGesture();
117
+ }
118
+ });
119
+ }
120
+
121
+ function getInstallHelpMessage() {
122
+ const ua = String(navigator.userAgent || '').toLowerCase();
123
+ if (/iphone|ipad|ipod/.test(ua)) {
124
+ return 'To install on iPhone/iPad: open this page in Safari, tap Share, then choose "Add to Home Screen".';
125
+ }
126
+ if (/android/.test(ua)) {
127
+ return 'To install on Android: open the browser menu (⋮) and choose "Install app" or "Add to Home screen". If Chrome says the app is already installed, open it from your launcher or uninstall the old copy first.';
128
+ }
129
+ return 'To install: open your browser menu and choose "Install app" or "Add to Home screen".';
130
+ }
131
+
132
+ export function updateInstallButton() {
133
+ const btn = document.getElementById('install-btn');
134
+ if (!btn) return;
135
+ if (IS_SHARED_VIEW) {
136
+ btn.disabled = true;
137
+ btn.hidden = true;
138
+ btn.setAttribute('aria-disabled', 'true');
139
+ btn.title = 'PWA install is unavailable for shared conversations';
140
+ return;
141
+ }
142
+ syncInstalledAppUiState();
143
+
144
+ if (isInstalledAppMode()) {
145
+ btn.style.display = 'none';
146
+ return;
147
+ }
148
+
149
+ const title = deferredInstallPrompt ? 'Install app to home screen' : 'Show install instructions';
150
+ btn.textContent = '⬇';
151
+ btn.style.display = 'inline-flex';
152
+ btn.title = title;
153
+ }
154
+
155
+ export async function promptInstallApp() {
156
+ if (IS_SHARED_VIEW) return;
157
+ if (!deferredInstallPrompt) {
158
+ await new Promise((resolve) => setTimeout(resolve, 350));
159
+ }
160
+ if (deferredInstallPrompt) {
161
+ try {
162
+ deferredInstallPrompt.prompt();
163
+ const choice = await deferredInstallPrompt.userChoice.catch(() => null);
164
+ if (choice?.outcome === 'accepted') {
165
+ showTransientRelayNotice('Install accepted. The app will appear on your home screen.');
166
+ }
167
+ } finally {
168
+ deferredInstallPrompt = null;
169
+ updateInstallButton();
170
+ }
171
+ return;
172
+ }
173
+
174
+ alert(getInstallHelpMessage());
175
+ }
176
+
177
+ export function initInstallButton() {
178
+ if (IS_SHARED_VIEW) {
179
+ updateInstallButton();
180
+ return;
181
+ }
182
+ if (window.__installButtonBound) {
183
+ updateInstallButton();
184
+ initInstalledFullscreenGestureBridge();
185
+ queueInstalledFullscreenGesture();
186
+ ensureInstalledAppFullscreen().catch(() => {});
187
+ return;
188
+ }
189
+ window.__installButtonBound = true;
190
+ initInstalledFullscreenGestureBridge();
191
+
192
+ window.addEventListener('beforeinstallprompt', (event) => {
193
+ event.preventDefault();
194
+ deferredInstallPrompt = event;
195
+ updateInstallButton();
196
+ updateFullscreenButton();
197
+ });
198
+
199
+ window.addEventListener('appinstalled', () => {
200
+ deferredInstallPrompt = null;
201
+ updateInstallButton();
202
+ updateFullscreenButton();
203
+ queueInstalledFullscreenGesture();
204
+ ensureInstalledAppFullscreen().catch(() => {});
205
+ showTransientRelayNotice('App installed.');
206
+ });
207
+
208
+ window.addEventListener('resize', () => {
209
+ updateInstallButton();
210
+ updateFullscreenButton();
211
+ }, { passive: true });
212
+
213
+ for (const query of INSTALLED_DISPLAY_MODE_QUERIES) {
214
+ const media = window.matchMedia(query);
215
+ if (media && typeof media.addEventListener === 'function') {
216
+ media.addEventListener('change', () => {
217
+ updateInstallButton();
218
+ updateFullscreenButton();
219
+ queueInstalledFullscreenGesture();
220
+ ensureInstalledAppFullscreen().catch(() => {});
221
+ });
222
+ }
223
+ }
224
+
225
+ updateInstallButton();
226
+ updateFullscreenButton();
227
+ queueInstalledFullscreenGesture();
228
+ ensureInstalledAppFullscreen().catch(() => {});
229
+ }
230
+
231
+ export async function toggleFullscreen() {
232
+ if (isInstalledAppMode()) {
233
+ if (document.fullscreenElement) {
234
+ document.exitFullscreen().catch(() => {});
235
+ } else {
236
+ ensureInstalledAppFullscreen({ userGesture: true }).catch(() => {});
237
+ }
238
+ return;
239
+ }
240
+ if (!canToggleFullscreen()) return;
241
+ try {
242
+ if (document.fullscreenElement) {
243
+ await document.exitFullscreen();
244
+ } else {
245
+ await document.documentElement.requestFullscreen();
246
+ }
247
+ } catch {
248
+ } finally {
249
+ updateInstallButton();
250
+ updateFullscreenButton();
251
+ }
252
+ }
253
+
254
+ export function updateFullscreenButton() {
255
+ const btn = document.getElementById('fullscreen-btn');
256
+ if (!btn) return;
257
+ syncInstalledAppUiState();
258
+ if (isInstalledAppMode() || isDisplayModeFullscreen()) {
259
+ btn.style.display = 'none';
260
+ return;
261
+ }
262
+
263
+ const mobile = window.matchMedia('(max-width: 680px)').matches;
264
+ if (!mobile) {
265
+ btn.style.display = 'none';
266
+ return;
267
+ }
268
+
269
+ const full = !!document.fullscreenElement;
270
+ const supported = canToggleFullscreen();
271
+ btn.style.display = 'inline-flex';
272
+ btn.disabled = !supported;
273
+
274
+ if (full) {
275
+ btn.textContent = '⤢';
276
+ btn.title = 'Exit fullscreen';
277
+ } else {
278
+ btn.textContent = '⛶';
279
+ btn.title = isInstalledAppMode()
280
+ ? (supported ? 'Enter fullscreen (recommended for installed app)' : 'Fullscreen not supported on this browser')
281
+ : (supported ? 'Enter fullscreen' : 'Fullscreen not supported on this browser');
282
+ }
283
+ }
284
+
285
+ export function initFullscreenButton() {
286
+ const syncFullscreenUi = () => {
287
+ updateInstallButton();
288
+ updateFullscreenButton();
289
+ };
290
+ document.addEventListener('fullscreenchange', syncFullscreenUi);
291
+ window.addEventListener('resize', syncFullscreenUi);
292
+ for (const query of INSTALLED_DISPLAY_MODE_QUERIES) {
293
+ const media = window.matchMedia(query);
294
+ if (media && typeof media.addEventListener === 'function') {
295
+ media.addEventListener('change', syncFullscreenUi);
296
+ }
297
+ }
298
+ updateFullscreenButton();
299
+ }
300
+
301
+ function normalizePwaAppName(rawValue, { allowEmpty = true } = {}) {
302
+ const normalized = String(rawValue || '').replace(/\s+/g, ' ').trim();
303
+ if (!normalized) {
304
+ return allowEmpty
305
+ ? { value: '', error: null }
306
+ : { value: '', error: 'App name cannot be empty.' };
307
+ }
308
+ if (normalized.length > PWA_APP_NAME_MAX_LENGTH) {
309
+ return { value: '', error: `App name must be ${PWA_APP_NAME_MAX_LENGTH} characters or fewer.` };
310
+ }
311
+ return { value: normalized, error: null };
312
+ }
313
+
314
+ function derivePwaShortName(name) {
315
+ const text = String(name || '').trim();
316
+ if (!text) return 'Copilot';
317
+ const firstWord = text.split(/\s+/)[0] || text;
318
+ if (firstWord.length <= 12) return firstWord;
319
+ return text.slice(0, 12).trim() || 'Copilot';
320
+ }
321
+
322
+ function resolveManifestUrlValue(rawValue, baseHref) {
323
+ const value = String(rawValue || '').trim();
324
+ if (!value || value.startsWith('data:') || value.startsWith('blob:')) return value;
325
+ try {
326
+ return new URL(value, baseHref).href;
327
+ } catch {
328
+ return value;
329
+ }
330
+ }
331
+
332
+ function normalizeManifestForBlob(manifest, defaultHref) {
333
+ const baseHref = new URL(String(defaultHref || '').trim(), window.location.href).href;
334
+ const next = { ...(manifest || {}) };
335
+ next.id = resolveManifestUrlValue(next.id, baseHref);
336
+ next.start_url = resolveManifestUrlValue(next.start_url, baseHref);
337
+ next.scope = resolveManifestUrlValue(next.scope, baseHref);
338
+ if (Array.isArray(next.icons)) {
339
+ next.icons = next.icons.map((icon) => {
340
+ if (!icon || typeof icon !== 'object') return icon;
341
+ const source = resolveManifestUrlValue(icon.src, baseHref);
342
+ return { ...icon, src: source };
343
+ });
344
+ }
345
+ return next;
346
+ }
347
+
348
+ function readStoredPwaAppName() {
349
+ const { value } = normalizePwaAppName(localStorage.getItem(PWA_APP_NAME_STORAGE_KEY), { allowEmpty: true });
350
+ return value;
351
+ }
352
+
353
+ export function syncPwaAppNameInput() {
354
+ const input = document.getElementById('pwa-app-name-input');
355
+ if (!input) return;
356
+ input.value = readStoredPwaAppName();
357
+ }
358
+
359
+ async function loadManifestTemplate(defaultHref) {
360
+ if (manifestTemplateCache) return { ...manifestTemplateCache };
361
+ const fallback = {
362
+ name: PWA_APP_NAME_DEFAULT,
363
+ short_name: derivePwaShortName(PWA_APP_NAME_DEFAULT),
364
+ description: 'OAR — Open Agent Relay: drive your local coding agents from any browser.',
365
+ id: './__copilot_remote_pwa__',
366
+ start_url: './',
367
+ scope: './',
368
+ display_override: ['standalone'],
369
+ display: 'standalone',
370
+ background_color: '#161b22',
371
+ theme_color: '#161b22',
372
+ icons: [
373
+ { src: 'app-icon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any' },
374
+ { src: 'app-icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
375
+ { src: 'app-icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
376
+ ],
377
+ };
378
+ try {
379
+ const response = await fetch(defaultHref, { cache: 'no-store' });
380
+ const manifest = response.ok ? await response.json() : null;
381
+ if (manifest && typeof manifest === 'object' && !Array.isArray(manifest)) {
382
+ manifestTemplateCache = manifest;
383
+ return { ...manifestTemplateCache };
384
+ }
385
+ } catch (error) {
386
+ console.warn('Failed to load manifest template; using fallback.', error);
387
+ }
388
+ manifestTemplateCache = fallback;
389
+ return { ...manifestTemplateCache };
390
+ }
391
+
392
+ export async function applyPwaManifestFromSettings() {
393
+ const manifestLink = document.querySelector('link[rel="manifest"]');
394
+ if (!manifestLink) return;
395
+
396
+ const defaultHref = String(manifestLink.dataset.defaultHref || manifestLink.getAttribute('href') || '').trim();
397
+ if (!defaultHref) return;
398
+ if (!manifestLink.dataset.defaultHref) manifestLink.dataset.defaultHref = defaultHref;
399
+
400
+ const customName = readStoredPwaAppName();
401
+ if (!customName) {
402
+ if (customManifestUrl) {
403
+ URL.revokeObjectURL(customManifestUrl);
404
+ customManifestUrl = null;
405
+ }
406
+ manifestLink.setAttribute('href', defaultHref);
407
+ return;
408
+ }
409
+
410
+ const baseManifest = await loadManifestTemplate(defaultHref);
411
+ const nextManifest = {
412
+ ...baseManifest,
413
+ name: customName,
414
+ short_name: derivePwaShortName(customName),
415
+ };
416
+ const normalizedManifest = normalizeManifestForBlob(nextManifest, defaultHref);
417
+ const manifestBlob = new Blob([JSON.stringify(normalizedManifest, null, 2)], { type: 'application/manifest+json' });
418
+ const objectUrl = URL.createObjectURL(manifestBlob);
419
+ if (customManifestUrl) URL.revokeObjectURL(customManifestUrl);
420
+ customManifestUrl = objectUrl;
421
+ manifestLink.setAttribute('href', objectUrl);
422
+ }
423
+
424
+ export function updatePwaAppName(rawValue) {
425
+ const normalized = normalizePwaAppName(rawValue, { allowEmpty: true });
426
+ if (normalized.error) {
427
+ alert(normalized.error);
428
+ syncPwaAppNameInput();
429
+ return;
430
+ }
431
+ if (normalized.value) {
432
+ localStorage.setItem(PWA_APP_NAME_STORAGE_KEY, normalized.value);
433
+ } else {
434
+ localStorage.removeItem(PWA_APP_NAME_STORAGE_KEY);
435
+ }
436
+ applyPwaManifestFromSettings()
437
+ .then(() => {
438
+ syncPwaAppNameInput();
439
+ showTransientRelayNotice(normalized.value
440
+ ? `Install app name updated to "${normalized.value}".`
441
+ : 'Install app name reset to default.');
442
+ })
443
+ .catch((error) => {
444
+ alert(error?.message || 'Failed to apply install app name');
445
+ syncPwaAppNameInput();
446
+ });
447
+ }
448
+
449
+ export function registerPwaShell() {
450
+ if (IS_SHARED_VIEW) return;
451
+ if (!('serviceWorker' in navigator)) return;
452
+ const scopeBase = BASE;
453
+ const scopeRoot = `${scopeBase}/`;
454
+ const pwaVersion = String(window.__COPILOT_PWA_VERSION || '0').trim() || '0';
455
+ return navigator.serviceWorker.register(`${scopeBase}/sw.js?v=${encodeURIComponent(pwaVersion)}`, { scope: scopeRoot, updateViaCache: 'none' }).catch(() => {});
456
+ }
@@ -0,0 +1,71 @@
1
+ // Browser-side mirror of shared/question-schema.mjs field parsing.
2
+ // Kept in sync with the server helper so the relay UI renders multi-field
3
+ // `ask_user` / elicitation forms identically to how the server validates them.
4
+
5
+ function isPlainObject(value) {
6
+ return !!value && typeof value === 'object' && !Array.isArray(value);
7
+ }
8
+
9
+ function normalizeChoice(entry) {
10
+ if (typeof entry === 'string') {
11
+ const value = entry.trim();
12
+ return value ? { value, label: value } : null;
13
+ }
14
+ if (isPlainObject(entry)) {
15
+ const value = entry.const !== undefined ? entry.const : (entry.value !== undefined ? entry.value : entry.title);
16
+ if (value === undefined || value === null) return null;
17
+ const label = String(entry.title || entry.label || entry.description || value).trim();
18
+ return { value, label: label || String(value) };
19
+ }
20
+ return null;
21
+ }
22
+
23
+ function fieldChoices(prop) {
24
+ const out = [];
25
+ const pools = [prop?.enum, prop?.oneOf];
26
+ if (isPlainObject(prop?.items)) {
27
+ pools.push(prop.items.enum, prop.items.anyOf);
28
+ }
29
+ for (const pool of pools) {
30
+ if (!Array.isArray(pool)) continue;
31
+ for (const entry of pool) {
32
+ const choice = normalizeChoice(entry);
33
+ if (choice) out.push(choice);
34
+ }
35
+ }
36
+ const seen = new Set();
37
+ return out.filter((choice) => {
38
+ const key = String(choice.value);
39
+ if (seen.has(key)) return false;
40
+ seen.add(key);
41
+ return true;
42
+ });
43
+ }
44
+
45
+ export function schemaFieldsFromQuestion(question) {
46
+ const schema = question?.requestSchema;
47
+ if (!isPlainObject(schema) || !isPlainObject(schema.properties)) return [];
48
+ const requiredSet = new Set(Array.isArray(schema.required) ? schema.required.map(String) : []);
49
+ const fields = [];
50
+ for (const [name, rawProp] of Object.entries(schema.properties)) {
51
+ if (!isPlainObject(rawProp)) continue;
52
+ const type = String(rawProp.type || 'string').toLowerCase();
53
+ fields.push({
54
+ name,
55
+ type,
56
+ title: String(rawProp.title || name).trim() || name,
57
+ description: String(rawProp.description || '').trim(),
58
+ required: requiredSet.has(name),
59
+ hasDefault: Object.prototype.hasOwnProperty.call(rawProp, 'default'),
60
+ default: rawProp.default,
61
+ choices: fieldChoices(rawProp),
62
+ isMultiSelect: type === 'array',
63
+ format: String(rawProp.format || '').trim() || null,
64
+ });
65
+ }
66
+ return fields;
67
+ }
68
+
69
+ export function isMultiFieldQuestion(question) {
70
+ return schemaFieldsFromQuestion(question).length > 1;
71
+ }
@@ -0,0 +1,42 @@
1
+ // Cursor models without a 'thinking' parameter (or an 'effort' value of 'none')
2
+ // cannot be told to stop reasoning. The relay still sends 'none' on the wire —
3
+ // the SDK just receives no reasoning parameter and the model runs its default —
4
+ // so the option is labelled for what it does instead of promising an off switch.
5
+ export const PROVIDER_DEFAULT_EFFORT_LABEL = 'default';
6
+
7
+ export function normalizeProviderKey(providerType = '') {
8
+ const key = String(providerType || '').trim().toLowerCase();
9
+ if (key === 'openai-byok') return 'openai';
10
+ if (key === 'openai-image' || key === 'openai-image-byok') return 'openai';
11
+ if (key === 'github-copilot') return 'github';
12
+ return key;
13
+ }
14
+
15
+ export function isReasoningOffUnsupported(catalog = null, providerType = '', modelId = '') {
16
+ const provider = normalizeProviderKey(providerType);
17
+ const model = String(modelId || '').trim().toLowerCase();
18
+ if (!provider || !model) return false;
19
+ return catalog?.reasoningOffUnsupportedByProvider?.[provider]?.[model] === true;
20
+ }
21
+
22
+ // Claude's top tier is a mode, not just more thinking: the session fans out
23
+ // multi-agent workflows. It gets a display name and a tooltip so the ladder
24
+ // jump in cost/behavior is visible before it is selected.
25
+ export const CLAUDE_ULTRACODE_EFFORT = 'ultracode';
26
+ export const ULTRACODE_EFFORT_LABEL = 'Ultracode';
27
+ export const ULTRACODE_EFFORT_OPTION_TITLE =
28
+ 'xhigh effort plus multi-agent workflow orchestration — expect much higher token use';
29
+
30
+ export function reasoningEffortOptionLabel(effort, { reasoningOffUnsupported = false } = {}) {
31
+ const value = String(effort || '').trim().toLowerCase();
32
+ if (!value) return '';
33
+ if (value === 'none' && reasoningOffUnsupported) return PROVIDER_DEFAULT_EFFORT_LABEL;
34
+ if (value === CLAUDE_ULTRACODE_EFFORT) return ULTRACODE_EFFORT_LABEL;
35
+ return value;
36
+ }
37
+
38
+ export function reasoningEffortOptionTitle(effort) {
39
+ return String(effort || '').trim().toLowerCase() === CLAUDE_ULTRACODE_EFFORT
40
+ ? ULTRACODE_EFFORT_OPTION_TITLE
41
+ : '';
42
+ }
@@ -0,0 +1,117 @@
1
+ import {
2
+ currentConvId,
3
+ escHtml,
4
+ fmtDate,
5
+ relayBoards,
6
+ } from './store.js';
7
+ import {
8
+ loadRelayBoards as loadRelayBoardsApi,
9
+ submitRelayBoardAction as submitRelayBoardActionApi,
10
+ } from './api-client.js';
11
+ import { renderMarkdownPreview } from './router.js';
12
+ import { attachCodeCopyButtons } from './code-copy.mjs';
13
+
14
+ let relayBoardRenderHash = '';
15
+
16
+ export function upsertRelayBoard(board) {
17
+ if (!board || !board.id) return;
18
+ relayBoards.set(board.id, board);
19
+ window.renderConvList?.();
20
+ renderRelayBoards();
21
+ }
22
+
23
+ export async function loadRelayBoards() {
24
+ const pendingRes = await loadRelayBoardsApi('pending');
25
+ if (!pendingRes) return;
26
+ const pending = Array.isArray(pendingRes?.boards) ? pendingRes.boards.filter((board) => board && board.id) : [];
27
+ relayBoards.clear();
28
+ for (const board of pending) relayBoards.set(board.id, board);
29
+ window.renderConvList?.();
30
+ renderRelayBoards();
31
+ }
32
+
33
+ export function renderRelayBoards() {
34
+ const el = document.getElementById('messages');
35
+ if (!el) return;
36
+ const boards = Array.from(relayBoards.values())
37
+ .filter((board) => board && board.conversationId === currentConvId && board.status === 'pending')
38
+ .sort((a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0));
39
+
40
+ const nextHash = JSON.stringify(
41
+ boards.map((board) => ({
42
+ id: board.id,
43
+ status: board.status,
44
+ title: board.title || '',
45
+ body: board.body || '',
46
+ createdAt: board.createdAt || '',
47
+ actions: Array.isArray(board.actions) ? board.actions.map((action) => action?.id || '') : [],
48
+ recommendedAction: board.recommendedAction || '',
49
+ })),
50
+ );
51
+ const existingCards = el.querySelectorAll('.relay-board-container');
52
+ if (relayBoardRenderHash === nextHash && existingCards.length === boards.length) return;
53
+ relayBoardRenderHash = nextHash;
54
+
55
+ existingCards.forEach((node) => node.remove());
56
+ if (!boards.length) return;
57
+
58
+ for (const board of boards) {
59
+ const wrapper = document.createElement('div');
60
+ wrapper.className = 'msg relay-board-container';
61
+ wrapper.dataset.boardId = board.id;
62
+
63
+ const modeTag = board.mode ? ` <span class="msg-mode">${escHtml(board.mode)}</span>` : '';
64
+ const boardType = String(board.boardType || 'board').trim().replace(/[_-]+/g, ' ');
65
+ const title = String(board.title || 'Plan ready for review').trim();
66
+ const bodyHtml = renderMarkdownPreview(board.body || '', false);
67
+ const actions = Array.isArray(board.actions) ? board.actions : [];
68
+ const recommendedAction = String(board.recommendedAction || '').trim().toLowerCase();
69
+ const actionHtml = actions.length
70
+ ? `<div class="relay-board-actions">${
71
+ actions.map((action) => {
72
+ const actionId = String(action?.id || '').trim().toLowerCase();
73
+ if (!actionId) return '';
74
+ const actionLabel = String(action?.label || actionId).trim();
75
+ const isRecommended = recommendedAction && actionId === recommendedAction;
76
+ return `<button class="relay-board-action${isRecommended ? ' relay-board-action-recommended' : ''}" data-action-id="${escHtml(actionId)}" onclick="submitRelayBoardAction('${board.id}', this.dataset.actionId)">${escHtml(actionLabel)}</button>`;
77
+ }).join('')
78
+ }</div>`
79
+ : '';
80
+
81
+ wrapper.innerHTML = `
82
+ <div class="relay-board-card">
83
+ <div class="relay-board-head">${escHtml(title)}${modeTag} · ${escHtml(boardType)} · ${fmtDate(board.createdAt)}</div>
84
+ <div class="relay-board-body">${bodyHtml}</div>
85
+ ${actionHtml}
86
+ </div>`;
87
+ wrapper.querySelectorAll('pre code').forEach((node) => hljs.highlightElement(node));
88
+ attachCodeCopyButtons(wrapper);
89
+ el.appendChild(wrapper);
90
+ }
91
+ window.scrollBottom?.();
92
+ }
93
+
94
+ export async function submitRelayBoardAction(boardId, actionId) {
95
+ const id = String(boardId || '').trim();
96
+ const nextActionId = String(actionId || '').trim();
97
+ if (!id || !nextActionId) return;
98
+ const card = document.querySelector(`.relay-board-container[data-board-id="${id}"]`);
99
+ const controls = card ? card.querySelectorAll('button') : [];
100
+ controls.forEach((control) => { control.disabled = true; });
101
+
102
+ try {
103
+ const r = await submitRelayBoardActionApi(id, nextActionId);
104
+ if (!r?.board) throw new Error('Failed to submit board action');
105
+ relayBoards.set(id, r.board);
106
+ if (String(r.board.status || '').toLowerCase() !== 'pending') {
107
+ relayBoards.delete(id);
108
+ }
109
+ window.renderConvList?.();
110
+ renderRelayBoards();
111
+ window.showTransientRelayNotice?.(`✅ Action selected: ${nextActionId}`);
112
+ } catch (error) {
113
+ controls.forEach((control) => { control.disabled = false; });
114
+ alert(error.message || 'Failed to submit board action');
115
+ }
116
+ }
117
+