@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,4347 @@
1
+ import {
2
+ CLIENT_ID,
3
+ currentConvId,
4
+ conversations,
5
+ repoBrowserState,
6
+ workspaceRootPath,
7
+ defaultSessionWorkspaceRootPath,
8
+ defaultSessionWorkspaceRootWarning,
9
+ getConversationWorkspaceState,
10
+ escHtml,
11
+ setToken,
12
+ setCliOnline,
13
+ setRelayOnline,
14
+ setSessionWorkerStatesFromStatusPayload,
15
+ setCurrentConv,
16
+ updateWorkspaceRootHints,
17
+ updateCliStatus,
18
+ cliOnline,
19
+ openSidebar,
20
+ closeSidebar,
21
+ updateCompactButton,
22
+ updateSessionPill,
23
+ setModelBanner,
24
+ showTransientRelayNotice,
25
+ applyContextUsageBar,
26
+ scrollBottom,
27
+ isMessagesAtBottom,
28
+ setHistoryRefreshInFlight,
29
+ isHistoryRefreshInFlight,
30
+ setSummaryModalLoading,
31
+ renderSummaryModalContent,
32
+ openSummaryModal,
33
+ closeSummaryModal,
34
+ refreshSummaryModal,
35
+ syncViewportMetrics,
36
+ isMobileComposerViewport,
37
+ releaseComposerFocusAfterSend,
38
+ autoResize,
39
+ initSidebarLayout,
40
+ toggleSidebar,
41
+ loadConversationScrollTop,
42
+ loadConversationLoadedMessageCount,
43
+ saveConversationScrollTop,
44
+ getSessionWorkerState,
45
+ resolveConversationUiState,
46
+ summaryModalState,
47
+ IS_SHARED_VIEW,
48
+ SHARED_CONVERSATION_TOKEN,
49
+ getConversationWatcherCount,
50
+ generateId,
51
+ } from './store.js';
52
+ import {
53
+ verifyExistingSession,
54
+ verifyToken,
55
+ refreshWorkspaceRootHints,
56
+ loadUsageSummary,
57
+ getClaudeAuthStatus,
58
+ loadContextSummary,
59
+ loadModelCatalog,
60
+ loadModelVariantCatalog,
61
+ refreshModelVariantCatalog,
62
+ saveEnabledModelVariants,
63
+ updateClaudeSettings,
64
+ updateCursorSettings,
65
+ updateGrokSettings,
66
+ loadConversation,
67
+ refreshConversationHistory,
68
+ updateConversationTitle,
69
+ updateConversationPreferences,
70
+ createConversationShareLink,
71
+ updateDefaultSessionWorkspaceRoot,
72
+ scheduleContextUsageRefresh,
73
+ loadSharedConversation,
74
+ reportSharedViewerPresence,
75
+ setNetworkRequestsEnabled,
76
+ } from './api-client.js';
77
+ import { loadConversations, refreshConversations, openConversation, renderConvList, applyLoadedConversationState, initConversationListLazyLoading } from './journal-view.js';
78
+ import {
79
+ newConversation,
80
+ confirmNewConversationModel,
81
+ closeNewConversationModelModal,
82
+ deleteConv,
83
+ } from './journal-view.js';
84
+ import {
85
+ loadRelayQuestions,
86
+ getPendingQuestionCountsByConversation,
87
+ } from './ask-user-view.js';
88
+ import { openPendingQuestionFromBanner, submitRelayQuestionChoice, submitRelayQuestionAnswer, submitRelayStructuredAnswer, onRelayQuestionDraftInput, handleRelayQuestionKey } from './ask-user-view.js';
89
+ import { loadRelayBoards, submitRelayBoardAction } from './relay-board-view.js';
90
+ import {
91
+ restoreInFlightThinking,
92
+ renderMessages,
93
+ appendMessage,
94
+ compactCurrentConversation,
95
+ sendMessage,
96
+ handleKey,
97
+ updateComposerSlashMenu,
98
+ getConversationLoadedMessageCount,
99
+ loadOlderConversationMessages,
100
+ syncComposerControlState,
101
+ persistComposerAttachments,
102
+ flushConversationDraft,
103
+ initConversationHistoryLazyLoading,
104
+ initBubbleActionHandlers,
105
+ isSendInFlight,
106
+ setImageEditTarget,
107
+ clearImageEditTarget,
108
+ jumpToImageParent,
109
+ flushDeferredMessageRender,
110
+ } from './conversation-view.js';
111
+ import { bindChatSelectionGuard, chatSelectionGuard, isChatInteractionHeld } from './selection-guard.mjs';
112
+ import { loadRepoBrowserTree, openRepoBrowser, closeRepoBrowser, setRepoBrowserSessionInfo, resetWorkspaceRepoBrowserForRootChange } from './attachments-view.js';
113
+ import { handleAttachmentInput, retryAttachmentUpload, handleComposerPaste, handleComposerDrop, refreshComposerAttachmentWarning, removeAttachment, clearAttachments, openUploadedAttachmentViewer, setFilePreviewMode, toggleFilePreviewHtml, closeFilePreview, goBackFilePreview, openWorkspaceFilePreview, openWorkspaceFilePreviewFromRepo, setRepoBrowserRoot, setRepoBrowserViewMode, toggleRepoBrowserHidden, toggleRepoBrowserHeavy, refreshRepoBrowser, focusRepoTree, setRepoCurrentPath, confirmRepoBrowserCwdPick } from './attachments-view.js';
114
+ import { initEmojiPicker, toggleEmojiPicker } from './emoji-view.js';
115
+ import { initSlashAutocomplete } from './slash-autocomplete.mjs';
116
+ import { dataTransferHasFiles } from './composer-paste.mjs';
117
+ import { isReasoningOffUnsupported, reasoningEffortOptionLabel, reasoningEffortOptionTitle } from './reasoning-effort-labels.mjs';
118
+ import {
119
+ firstDefinedPreference,
120
+ normalizePreferenceValue,
121
+ resolveComposerReasoningEffort,
122
+ resolveConversationComposerSelection,
123
+ } from './conversation-preferences.mjs';
124
+ import {
125
+ modelSelectorOptionsEqual,
126
+ normalizeModelSelectorOptions,
127
+ } from './model-selector-options.mjs';
128
+ import {
129
+ buildContextTierOptions,
130
+ resolveContextTierValue,
131
+ } from './context-tier-options.mjs';
132
+ import {
133
+ isOpenAIImageModelId,
134
+ sessionLockNoteText,
135
+ sessionLockProviderKey,
136
+ } from './conversation-provider-indicator.mjs';
137
+ import {
138
+ initMessageSearchView,
139
+ openMessageSearchModal,
140
+ closeMessageSearchModal,
141
+ } from './message-search-view.js';
142
+ import {
143
+ initGitChangesView,
144
+ openGitChangesModal,
145
+ closeGitChangesModal,
146
+ openGitDiffViewer,
147
+ closeGitDiffViewer,
148
+ setGitDiffMode,
149
+ } from './git-changes-view.js';
150
+
151
+ import {
152
+ initSocketHandlers,
153
+ connectSocket,
154
+ setSocketActivityEnabled,
155
+ ensureSocketConnected,
156
+ hardResetSocket,
157
+ verifySocketLiveness,
158
+ emitDeviceVisibility,
159
+ } from './socket-handlers.js';
160
+ import {
161
+ adviseWatchdogTick,
162
+ RELAY_WATCHDOG_HARD_RESET_TICKS,
163
+ } from './relay-watchdog-policy.mjs';
164
+ import {
165
+ initInstallButton,
166
+ initFullscreenButton,
167
+ promptInstallApp,
168
+ toggleFullscreen,
169
+ applyPwaManifestFromSettings,
170
+ registerPwaShell,
171
+ updatePwaAppName,
172
+ } from './pwa-install.js';
173
+ import {
174
+ renderAutoCompactControlHtml,
175
+ renderContextUsageHtml,
176
+ renderThinkingControlHtml,
177
+ thinkingEnabledFromKey,
178
+ thinkingEnabledToKey,
179
+ } from './context-usage-view.mjs';
180
+ import {
181
+ autoCompactWindowFromIndex,
182
+ autoCompactWindowToIndex,
183
+ formatAutoCompactWindowLabel,
184
+ } from './auto-compact-window-options.mjs';
185
+ import { renderPlanUsageHtml, planUsageSubtitle, normalizeUsageProvider } from './plan-usage-view.mjs';
186
+ import { initFontScaling, updateFontScaleFromSelect } from './font-scaling.js';
187
+ import { initClientDiagnostics, recordStatusEvent } from './status-store.mjs';
188
+ import { isStatusViewActive, toggleStatusView } from './status-view.mjs';
189
+ import { installExternalLinkPolicy, openExternalNavigation } from './external-link-policy.mjs';
190
+ import {
191
+ initCwdPicker,
192
+ openChangeCwdModal,
193
+ confirmChangeCwd,
194
+ confirmChangeCwdAndLaunch,
195
+ syncChatHeaderWorkspaceLabel,
196
+ normalizeKnownCwdPath,
197
+ clearLegacyKnownCwdHistoryStorage,
198
+ } from './cwd-picker.js';
199
+ import { bindTapAction, bindMenuAction } from './tap-actions.js';
200
+
201
+ let pendingExternalLinkUrl = '';
202
+
203
+ function showExternalLinkFallback(url) {
204
+ pendingExternalLinkUrl = String(url || '').trim();
205
+ if (!pendingExternalLinkUrl) return;
206
+ openSummaryModal({
207
+ title: 'Open external link',
208
+ subtitle: 'Your browser blocked opening a separate tab',
209
+ bodyHtml: `
210
+ <p>The OAR app remains open. Copy this link and open it in your system browser:</p>
211
+ <pre><code>${escHtml(pendingExternalLinkUrl)}</code></pre>
212
+ <div class="summary-actions">
213
+ <button class="summary-btn" type="button" onclick="copyExternalLinkUrl()">Copy link</button>
214
+ <button class="summary-btn" type="button" onclick="retryExternalLinkOpen()">Open again</button>
215
+ <button class="summary-close" type="button" onclick="closeSummaryModal()">Close</button>
216
+ </div>
217
+ `,
218
+ kind: 'external-link-fallback',
219
+ });
220
+ }
221
+
222
+ async function copyExternalLinkUrl() {
223
+ if (!pendingExternalLinkUrl) return;
224
+ await copyTextToClipboard(pendingExternalLinkUrl);
225
+ showTransientRelayNotice('External link copied.');
226
+ }
227
+
228
+ function retryExternalLinkOpen() {
229
+ if (!pendingExternalLinkUrl) return;
230
+ openExternalNavigation(pendingExternalLinkUrl, showExternalLinkFallback);
231
+ }
232
+ import { initTmuxInspectorView, closeTmuxInspectorView } from './tmux-inspector-view.js';
233
+ import {
234
+ initTheme,
235
+ updateTheme,
236
+ openSettingsModal,
237
+ closeSettingsModal,
238
+ syncSuspendHostVisibility,
239
+ updateShowSuspendHostSetting,
240
+ syncDefaultSessionWorkspaceRootInput,
241
+ updateDefaultSessionWorkspaceRootSetting,
242
+ saveOpenAISettings,
243
+ removeOpenAISettings,
244
+ toggleOpenAIProvider,
245
+ applyOpenAISettingsState,
246
+ refreshOpenAISettingsState,
247
+ saveClaudeSettings,
248
+ toggleClaudeProvider,
249
+ applyClaudeSettingsState,
250
+ refreshClaudeSettingsState,
251
+ saveCopilotSettings,
252
+ applyCopilotSettingsState,
253
+ saveGrokSettings,
254
+ toggleGrokProvider,
255
+ applyGrokSettingsState,
256
+ refreshGrokSettingsState,
257
+ saveCursorSettings,
258
+ removeCursorSettings,
259
+ saveCursorAllowanceSettings,
260
+ resetCursorAllowanceAccounting,
261
+ saveCursorDashboardToken,
262
+ removeCursorDashboardToken,
263
+ saveGrokAllowanceSettings,
264
+ resetGrokAllowanceAccounting,
265
+ toggleCursorProvider,
266
+ applyCursorSettingsState,
267
+ refreshCursorSettingsState,
268
+ updateWindowsAutostartSettingFromToggle,
269
+ previewTurnCeilingSetting,
270
+ updateTurnCeilingSetting,
271
+ previewBackgroundTaskTimeoutSetting,
272
+ updateBackgroundTaskTimeoutSetting,
273
+ } from './settings-modal.js';
274
+ import {
275
+ togglePushOnThisDevice,
276
+ updatePushPreferencesFromControls,
277
+ } from './push-settings.js';
278
+ import {
279
+ enqueueDraftFlushForBackgroundSync,
280
+ initOutboxFallbackReplay,
281
+ } from './sync-outbox.mjs';
282
+ import {
283
+ initActionConfirmations,
284
+ openKillSessionConfirmation,
285
+ confirmKillCurrentSession,
286
+ openRestartRelayConfirmation,
287
+ confirmRestartWebRelay,
288
+ openEmptyQueueConfirmation,
289
+ confirmEmptyQueue,
290
+ openSuspendHostConfirmation,
291
+ confirmSuspendHost,
292
+ } from './action-confirmations.js';
293
+ import {
294
+ startClaudeRelogin,
295
+ submitClaudeLoginCodeFromInput,
296
+ handleClaudeLoginCodeKey,
297
+ cancelClaudeRelogin,
298
+ copyClaudeLoginUrl,
299
+ openClaudeLogoutConfirmation,
300
+ confirmClaudeLogout,
301
+ } from './claude-auth-ui.js';
302
+ import {
303
+ startGrokSignIn,
304
+ cancelGrokSignIn,
305
+ copyGrokLoginUrl,
306
+ openGrokLogoutConfirmation,
307
+ confirmGrokLogout,
308
+ } from './grok-auth-ui.js';
309
+ import {
310
+ confirmCliInstall,
311
+ runCliInstall,
312
+ cancelCliInstallRun,
313
+ } from './cli-install-ui.js';
314
+
315
+ const MODEL_STORAGE_KEY = 'copilot_selected_model';
316
+ // The New Chat modal used to keep its own model key, so a selection made there
317
+ // never reached the composer. Both now read MODEL_STORAGE_KEY.
318
+ const LEGACY_MODEL_STORAGE_KEY = 'copilot_model';
319
+ const REASONING_STORAGE_KEY = 'copilot_selected_reasoning_effort';
320
+ const MODE_STORAGE_KEY = 'copilot_selected_mode';
321
+ const AUTO_MODEL_OPTION = 'auto';
322
+ // Claude "[1m]" long-context variants surface as the base model plus the
323
+ // long_context tier in the context-size dropdown, never as separate entries.
324
+ const CLAUDE_LONG_CONTEXT_PATTERN = /\[1m\]$/i;
325
+ const FALLBACK_MODEL = 'gpt-5.4-mini';
326
+ const FALLBACK_REASONING_EFFORT = 'none';
327
+ const FALLBACK_MODE = 'agent';
328
+ const PROVIDER_LABELS = {
329
+ openai: 'OpenAI',
330
+ 'openai-byok': 'OpenAI (BYOK)',
331
+ claude: 'Claude SDK',
332
+ cursor: 'Cursor SDK',
333
+ grok: 'Grok',
334
+ 'github-copilot': 'GitHub Copilot',
335
+ // Vendor grouping for Copilot-served rows, distinct from the Claude SDK runtime.
336
+ anthropic: 'Anthropic',
337
+ google: 'Google',
338
+ microsoft: 'Microsoft',
339
+ other: 'Other',
340
+ };
341
+ const CHAT_TITLE_MAX_LENGTH = 120;
342
+ const LOCAL_PROCESSING_STALE_MS = 5 * 60 * 1000;
343
+ const FOREGROUND_RECOVERY_DEBOUNCE_MS = 1000;
344
+ // Upper bound on how long the in-flight latch may stay set. A recovery request
345
+ // that never settles (frozen renderer, half-open mobile socket) must not be able
346
+ // to keep the latch closed, because that would suppress every later recovery.
347
+ const FOREGROUND_RECOVERY_TIMEOUT_MS = 20000;
348
+ const RELAY_WATCHDOG_INTERVAL_MS = 5000;
349
+ // The server treats a visibility report older than 90s as stale, so a foregrounded
350
+ // device has to keep re-asserting itself or it silently stops counting as active
351
+ // and push suppression stops working mid-turn. 30s leaves room for two dropped
352
+ // beats inside that window. Tests shorten this rather than waiting it out.
353
+ const DEVICE_VISIBILITY_HEARTBEAT_MS = Number.isFinite(Number(window.__COPILOT_VISIBILITY_HEARTBEAT_MS))
354
+ ? Math.max(50, Number(window.__COPILOT_VISIBILITY_HEARTBEAT_MS))
355
+ : 30_000;
356
+ // How long a hidden page keeps its transport before suspending it. Brief
357
+ // app switches then never drop the socket at all. 45s matches the server's
358
+ // ping window (pingInterval 25s + pingTimeout 20s): past that the server has
359
+ // already dropped the client, so waiting longer would be inert. Tests override
360
+ // this to avoid waiting out the real grace period.
361
+ const BACKGROUND_SUSPEND_GRACE_MS = Number.isFinite(Number(window.__COPILOT_BACKGROUND_GRACE_MS))
362
+ ? Math.max(0, Number(window.__COPILOT_BACKGROUND_GRACE_MS))
363
+ : 45_000;
364
+
365
+ let relayQuestionPollTimer = null;
366
+ let relayBoardPollTimer = null;
367
+ let sessionWorkerStatusPollTimer = null;
368
+ let sharedConversationPollTimer = null;
369
+ let sharedPresencePollTimer = null;
370
+ let sharedConversationPollInFlight = false;
371
+ let sharedConversationRequestSeq = 0;
372
+ let sharedConversationAppliedSeq = 0;
373
+ let liveConversationPollTimer = null;
374
+ let liveConversationPollInFlight = false;
375
+ let sharedViewerIdFallback = '';
376
+ let sharedConversationRenderKey = '';
377
+ let sharedConversationLastError = '';
378
+ let networkLifecycleBound = false;
379
+ let foregroundRecoveryTimer = null;
380
+ let foregroundRecoveryInFlight = false;
381
+ let foregroundRecoveryWatchdogTimer = null;
382
+ let foregroundRecoveryGeneration = 0;
383
+ let relayConnectionWatchdogTimer = null;
384
+ let relayWatchdogDisconnectedTicks = 0;
385
+ let deviceVisibilityHeartbeatTimer = null;
386
+ let backgroundSuspendTimer = null;
387
+ let appSharedMode = false;
388
+ let viewportBaseHeight = window.innerHeight || document.documentElement.clientHeight || 0;
389
+ let chatTitleEditingConversationId = null;
390
+ let relayQuestionRenderHash = '';
391
+ let modelCatalogState = {
392
+ models: [FALLBACK_MODEL],
393
+ currentModel: FALLBACK_MODEL,
394
+ defaultModel: FALLBACK_MODEL,
395
+ reasoningByModel: {},
396
+ reasoningByProvider: {},
397
+ providersByModel: {},
398
+ reasoningEfforts: [],
399
+ modelMetadataByModel: {},
400
+ claudeContextTiersByModel: {},
401
+ stale: true,
402
+ metadataValid: false,
403
+ reasoningMetadataValid: false,
404
+ warning: null,
405
+ error: null,
406
+ refreshedAt: null,
407
+ };
408
+ let lastHealthyModelCatalogState = null;
409
+ let deferredModelCatalogPayload = null;
410
+ let modelMetadataBlocked = true;
411
+ let modelMetadataRetryInFlight = false;
412
+ let modelVariantCatalogState = {
413
+ variants: [],
414
+ enabledVariantIds: [],
415
+ reasoningByModel: {},
416
+ source: null,
417
+ refreshedAt: null,
418
+ warning: null,
419
+ error: null,
420
+ reasoningEfforts: [],
421
+ };
422
+ let modelVariantCatalogProviderTab = 'copilot';
423
+ let suppressConversationPreferenceSync = false;
424
+ let conversationPreferenceWriteVersion = 0;
425
+ let latestQueueStatus = {
426
+ pendingCount: 0,
427
+ processingCount: 0,
428
+ parkedCount: 0,
429
+ };
430
+ const SHARED_VIEWER_ID_STORAGE_KEY = 'copilot_shared_viewer_id';
431
+ const AUTH_TOKEN_STORAGE_KEY = 'copilot_auth_token';
432
+ const AUTH_COOKIE_NAME = 'copilot_auth';
433
+
434
+ function resolveSharedTokenFromLocation() {
435
+ const configured = String(window.__COPILOT_APP_CONFIG?.sharedToken || SHARED_CONVERSATION_TOKEN || '').trim().toLowerCase();
436
+ if (configured) return configured;
437
+ const pathToken = String(window.location.pathname || '').match(/\/shared\/([^/?#]+)\/?$/i);
438
+ if (!pathToken) return '';
439
+ return String(pathToken[1] || '').trim().toLowerCase();
440
+ }
441
+
442
+ function isSharedReaderMode() {
443
+ if (IS_SHARED_VIEW) return true;
444
+ return !!resolveSharedTokenFromLocation();
445
+ }
446
+
447
+ function getTokenFromUrl() {
448
+ return new URLSearchParams(window.location.search).get('token');
449
+ }
450
+
451
+ function resolveAuthCookiePath() {
452
+ const configuredBase = typeof window.__COPILOT_APP_CONFIG?.basePath === 'string'
453
+ ? window.__COPILOT_APP_CONFIG.basePath.trim()
454
+ : '';
455
+ if (configuredBase && configuredBase !== '/') {
456
+ return configuredBase.startsWith('/')
457
+ ? configuredBase.replace(/\/+$/, '') || '/'
458
+ : `/${configuredBase.replace(/\/+$/, '')}`;
459
+ }
460
+ const pathname = String(window.location.pathname || '/');
461
+ const sharedIndex = pathname.indexOf('/shared/');
462
+ const base = sharedIndex >= 0
463
+ ? pathname.slice(0, sharedIndex).replace(/\/+$/, '')
464
+ : pathname.replace(/\/+$/, '');
465
+ return base || '/';
466
+ }
467
+
468
+ function clearLegacyClientAuthCookie() {
469
+ const path = resolveAuthCookiePath();
470
+ document.cookie = `${AUTH_COOKIE_NAME}=; Path=${path}; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax`;
471
+ }
472
+
473
+ function consumeLegacyPersistedAuthToken() {
474
+ let sessionValue = '';
475
+ let localValue = '';
476
+ try {
477
+ sessionValue = String(sessionStorage.getItem(AUTH_TOKEN_STORAGE_KEY) || '').trim();
478
+ sessionStorage.removeItem(AUTH_TOKEN_STORAGE_KEY);
479
+ } catch {
480
+ sessionValue = '';
481
+ }
482
+ try {
483
+ localValue = String(localStorage.getItem(AUTH_TOKEN_STORAGE_KEY) || '').trim();
484
+ localStorage.removeItem(AUTH_TOKEN_STORAGE_KEY);
485
+ } catch {
486
+ localValue = '';
487
+ }
488
+ clearLegacyClientAuthCookie();
489
+ return sessionValue || localValue;
490
+ }
491
+
492
+ function stripTokenFromUrl() {
493
+ const url = new URL(window.location.href);
494
+ if (!url.searchParams.has('token')) return;
495
+ url.searchParams.delete('token');
496
+ history.replaceState(null, document.title, `${url.pathname}${url.search}${url.hash}`);
497
+ }
498
+
499
+ function ensureTrailingSlashPath() {
500
+ if (isSharedReaderMode()) return false;
501
+ const url = new URL(window.location.href);
502
+ const path = url.pathname || '/';
503
+ if (path === '/' || path.endsWith('/')) return false;
504
+ const lastSegment = path.split('/').filter(Boolean).pop() || '';
505
+ if (lastSegment.includes('.')) return false;
506
+ url.pathname = `${path}/`;
507
+ window.location.replace(url.toString());
508
+ return true;
509
+ }
510
+
511
+ function sharedViewerId() {
512
+ let viewerId = '';
513
+ try {
514
+ viewerId = String(sessionStorage.getItem(SHARED_VIEWER_ID_STORAGE_KEY) || '').trim();
515
+ } catch {
516
+ viewerId = sharedViewerIdFallback;
517
+ }
518
+ if (!viewerId) {
519
+ viewerId = generateId();
520
+ sharedViewerIdFallback = viewerId;
521
+ try {
522
+ sessionStorage.setItem(SHARED_VIEWER_ID_STORAGE_KEY, viewerId);
523
+ } catch {}
524
+ }
525
+ return viewerId;
526
+ }
527
+
528
+ function stopSharedModeTimers() {
529
+ if (sharedConversationPollTimer) {
530
+ clearInterval(sharedConversationPollTimer);
531
+ sharedConversationPollTimer = null;
532
+ }
533
+ if (sharedPresencePollTimer) {
534
+ clearInterval(sharedPresencePollTimer);
535
+ sharedPresencePollTimer = null;
536
+ }
537
+ if (liveConversationPollTimer) {
538
+ clearInterval(liveConversationPollTimer);
539
+ liveConversationPollTimer = null;
540
+ }
541
+ sharedConversationPollInFlight = false;
542
+ sharedConversationRequestSeq = 0;
543
+ sharedConversationAppliedSeq = 0;
544
+ liveConversationPollInFlight = false;
545
+ }
546
+
547
+ function isAppForegrounded() {
548
+ return document.visibilityState === 'visible';
549
+ }
550
+
551
+ // Polling gates on foreground state alone. It deliberately ignores the recovery
552
+ // latch: polling is the fallback that keeps the UI current while the socket is
553
+ // down, so a slow or stalled recovery pass must not silence it.
554
+ function shouldRunForegroundNetworkWork() {
555
+ return isAppForegrounded();
556
+ }
557
+
558
+ function setForegroundNetworkWorkEnabled(enabled) {
559
+ const next = !!enabled;
560
+ setNetworkRequestsEnabled(next);
561
+ setSocketActivityEnabled(next);
562
+ }
563
+
564
+ function clearForegroundRecoveryLatch() {
565
+ if (foregroundRecoveryWatchdogTimer) {
566
+ clearTimeout(foregroundRecoveryWatchdogTimer);
567
+ foregroundRecoveryWatchdogTimer = null;
568
+ }
569
+ foregroundRecoveryInFlight = false;
570
+ }
571
+
572
+ async function runForegroundRecovery(reason = 'visible') {
573
+ if (!isAppForegrounded()) return;
574
+ // Re-enabling the transport happens ahead of the stampede guard. An explicit
575
+ // socket.disconnect() drops socket.io's own reconnect subscriptions, so this is
576
+ // the only route back online and it must never be skippable.
577
+ setForegroundNetworkWorkEnabled(true);
578
+ if (foregroundRecoveryInFlight) {
579
+ recordStatusEvent('foreground-recovery-skipped', { reason });
580
+ return;
581
+ }
582
+ const generation = ++foregroundRecoveryGeneration;
583
+ foregroundRecoveryInFlight = true;
584
+ foregroundRecoveryWatchdogTimer = setTimeout(() => {
585
+ foregroundRecoveryWatchdogTimer = null;
586
+ foregroundRecoveryInFlight = false;
587
+ recordStatusEvent('foreground-recovery-timeout', {
588
+ reason,
589
+ timeoutMs: FOREGROUND_RECOVERY_TIMEOUT_MS,
590
+ });
591
+ }, FOREGROUND_RECOVERY_TIMEOUT_MS);
592
+ try {
593
+ if (appSharedMode) {
594
+ await refreshSharedConversation();
595
+ await pulseSharedViewerPresence();
596
+ return;
597
+ }
598
+ // Each step recovers a different slice of missed state; isolate their
599
+ // failures so one fetch losing a race with a restarting relay cannot
600
+ // skip the rest — refreshCurrentView in particular is the only
601
+ // correction path for change events this client missed while suspended
602
+ // (an emptied background-task store never re-announces itself).
603
+ const recoverySteps = [
604
+ ['session-worker-status', () => refreshSessionWorkerStatus()],
605
+ ['current-view', () => refreshCurrentView()],
606
+ ['model-catalog', () => refreshModelCatalog(true)],
607
+ ['relay-questions', () => loadRelayQuestions(currentConvId)],
608
+ ['relay-boards', () => loadRelayBoards()],
609
+ ];
610
+ for (const [step, run] of recoverySteps) {
611
+ try {
612
+ await run();
613
+ } catch (error) {
614
+ console.warn(`[foreground-recovery:${reason}] ${step}`, error?.message || error);
615
+ }
616
+ }
617
+ } catch (error) {
618
+ console.warn(`[foreground-recovery:${reason}]`, error?.message || error);
619
+ } finally {
620
+ // The watchdog may already have released the latch and let a newer pass take
621
+ // ownership; only the pass that still owns it may clear it.
622
+ if (foregroundRecoveryGeneration === generation) clearForegroundRecoveryLatch();
623
+ }
624
+ }
625
+
626
+ function scheduleForegroundRecovery(reason = 'visible', { immediate = false } = {}) {
627
+ if (foregroundRecoveryTimer) {
628
+ clearTimeout(foregroundRecoveryTimer);
629
+ foregroundRecoveryTimer = null;
630
+ }
631
+ if (immediate) {
632
+ void runForegroundRecovery(reason);
633
+ return;
634
+ }
635
+ foregroundRecoveryTimer = setTimeout(() => {
636
+ foregroundRecoveryTimer = null;
637
+ void runForegroundRecovery(reason);
638
+ }, FOREGROUND_RECOVERY_DEBOUNCE_MS);
639
+ }
640
+
641
+ function cancelBackgroundSuspend() {
642
+ if (!backgroundSuspendTimer) return;
643
+ clearTimeout(backgroundSuspendTimer);
644
+ backgroundSuspendTimer = null;
645
+ }
646
+
647
+ // Defer the background suspend by the grace period so a quick app switch keeps
648
+ // the socket alive (and connectionStateRecovery never even has to replay).
649
+ function scheduleBackgroundSuspend() {
650
+ cancelBackgroundSuspend();
651
+ if (BACKGROUND_SUSPEND_GRACE_MS <= 0) {
652
+ setForegroundNetworkWorkEnabled(false);
653
+ return;
654
+ }
655
+ backgroundSuspendTimer = setTimeout(() => {
656
+ backgroundSuspendTimer = null;
657
+ // A throttled or sleep-delayed timer can fire at the exact moment the user
658
+ // returns; re-check visibility so it cannot tear down a healthy connection.
659
+ if (document.visibilityState !== 'hidden') return;
660
+ setForegroundNetworkWorkEnabled(false);
661
+ }, BACKGROUND_SUSPEND_GRACE_MS);
662
+ }
663
+
664
+ // A notification tap in the service worker either messages an existing window
665
+ // (copilot-open-conversation) or opens a fresh one with ?push_conv=<id>.
666
+ function initPushNotificationClientHooks() {
667
+ if (!('serviceWorker' in navigator)) return;
668
+ if (window.__pushNotificationHooksBound) return;
669
+ window.__pushNotificationHooksBound = true;
670
+ navigator.serviceWorker.addEventListener('message', (event) => {
671
+ const data = event.data || {};
672
+ if (data.type !== 'copilot-open-conversation') return;
673
+ const conversationId = String(data.conversationId || '').trim();
674
+ if (!conversationId) return;
675
+ void openConversation(conversationId).catch(() => {});
676
+ });
677
+ }
678
+
679
+ function consumePushConversationDeepLink() {
680
+ const url = new URL(window.location.href);
681
+ const conversationId = String(url.searchParams.get('push_conv') || '').trim();
682
+ if (!conversationId) return '';
683
+ url.searchParams.delete('push_conv');
684
+ history.replaceState(null, document.title, `${url.pathname}${url.search}${url.hash}`);
685
+ return conversationId;
686
+ }
687
+
688
+ function handleForegroundTransition(reason, { immediate = false } = {}) {
689
+ if (!isAppForegrounded()) return;
690
+ // The app came back before the grace period elapsed; keep the socket.
691
+ cancelBackgroundSuspend();
692
+ // The transport comes back right away; only the data refresh is debounced.
693
+ setForegroundNetworkWorkEnabled(true);
694
+ // Tell the server this device is foregrounded again so push suppression
695
+ // resumes. If the socket is still reconnecting this is a no-op; the
696
+ // connect-time heartbeat covers that case.
697
+ emitDeviceVisibility(true);
698
+ // A socket that reports connected after a freeze may be a zombie whose
699
+ // ping-timeout timer died with the freeze; probe it instead of trusting it.
700
+ verifySocketLiveness();
701
+ scheduleForegroundRecovery(reason, { immediate });
702
+ }
703
+
704
+ // socket.io gives up permanently after an explicit disconnect or a rejected
705
+ // handshake, so a dropped relay never comes back on its own while the app stays in
706
+ // the foreground. This is the safety net for that. It deliberately does not
707
+ // consult navigator.onLine: Android Chrome can report a stale `false` after a
708
+ // standby resume while fetches work fine, and that guard once muzzled the
709
+ // watchdog for the rest of the page's life. A failed connect attempt is cheap;
710
+ // a silently disabled watchdog is not.
711
+ //
712
+ // connect() alone cannot escape every wedged manager state (see
713
+ // relay-watchdog-policy.mjs), so ticks spent disconnected escalate to a hard
714
+ // reset of the socket.
715
+ function startRelayConnectionWatchdog() {
716
+ if (relayConnectionWatchdogTimer || appSharedMode) return;
717
+ relayConnectionWatchdogTimer = setInterval(() => {
718
+ if (!isAppForegrounded()) {
719
+ // Background ticks say nothing about the connection; a count carried
720
+ // across a resume could trigger a reset before reconnection had a chance.
721
+ relayWatchdogDisconnectedTicks = 0;
722
+ return;
723
+ }
724
+ const state = ensureSocketConnected();
725
+ if (state === 'forced') recordStatusEvent('relay-reconnect-forced', {});
726
+ const advice = adviseWatchdogTick({ state, disconnectedTicks: relayWatchdogDisconnectedTicks });
727
+ relayWatchdogDisconnectedTicks = advice.disconnectedTicks;
728
+ if (advice.hardReset) {
729
+ hardResetSocket();
730
+ recordStatusEvent('relay-socket-hard-reset', {
731
+ state,
732
+ afterTicks: RELAY_WATCHDOG_HARD_RESET_TICKS,
733
+ });
734
+ }
735
+ }, RELAY_WATCHDOG_INTERVAL_MS);
736
+ }
737
+
738
+ // Visibility is reported on transitions, but the server ages those reports out
739
+ // after 90s. Without a periodic re-assert, a user who simply sits and reads for
740
+ // longer than that stops looking active, and a turn finishing afterwards pushes a
741
+ // notification to the phone already in their hand.
742
+ function startDeviceVisibilityHeartbeat() {
743
+ if (deviceVisibilityHeartbeatTimer || appSharedMode) return;
744
+ deviceVisibilityHeartbeatTimer = setInterval(() => {
745
+ if (!isAppForegrounded()) return;
746
+ emitDeviceVisibility(true);
747
+ }, DEVICE_VISIBILITY_HEARTBEAT_MS);
748
+ }
749
+
750
+ function initNetworkLifecycleHandling() {
751
+ if (networkLifecycleBound) return;
752
+ networkLifecycleBound = true;
753
+ document.addEventListener('visibilitychange', () => {
754
+ if (document.visibilityState === 'hidden') {
755
+ // The "now hidden" heartbeat goes out immediately — the transport stays
756
+ // up for the grace period, so without it the device would keep looking
757
+ // active and suppress push notifications for every device.
758
+ emitDeviceVisibility(false);
759
+ scheduleBackgroundSuspend();
760
+ return;
761
+ }
762
+ handleForegroundTransition('visibility');
763
+ });
764
+ window.addEventListener('online', () => {
765
+ handleForegroundTransition('online', { immediate: true });
766
+ });
767
+ window.addEventListener('offline', () => {
768
+ cancelBackgroundSuspend();
769
+ setForegroundNetworkWorkEnabled(false);
770
+ });
771
+ window.addEventListener('pageshow', () => {
772
+ handleForegroundTransition('pageshow', { immediate: true });
773
+ });
774
+ window.addEventListener('pagehide', () => {
775
+ // The page is going away; no grace period, suspend immediately.
776
+ cancelBackgroundSuspend();
777
+ setForegroundNetworkWorkEnabled(false);
778
+ });
779
+ // Android Chrome freezes backgrounded PWAs. A freeze/resume cycle can happen
780
+ // without any visibility transition, so resume needs its own recovery trigger.
781
+ document.addEventListener('freeze', () => {
782
+ // Timers will not run while frozen, so the pending grace timer is useless;
783
+ // suspend now while code can still run.
784
+ cancelBackgroundSuspend();
785
+ setForegroundNetworkWorkEnabled(false);
786
+ });
787
+ document.addEventListener('resume', () => {
788
+ handleForegroundTransition('resume', { immediate: true });
789
+ });
790
+ }
791
+
792
+ function syncThemeMenuLabel() {
793
+ const button = document.getElementById('chat-menu-shared-theme-toggle');
794
+ if (!button) return;
795
+ const isLight = document.documentElement.getAttribute('data-theme') === 'light';
796
+ button.textContent = isLight ? '🌙 Night mode' : '☀️ Day mode';
797
+ }
798
+
799
+ function initThemeMenuToggle() {
800
+ const themeBtn = document.getElementById('chat-menu-shared-theme-toggle');
801
+ if (!themeBtn) return;
802
+ themeBtn.hidden = false;
803
+ syncThemeMenuLabel();
804
+ if (themeBtn.dataset.bound === '1') return;
805
+ themeBtn.dataset.bound = '1';
806
+ bindMenuAction(themeBtn, (event) => {
807
+ event.preventDefault();
808
+ event.stopPropagation();
809
+ const isLight = document.documentElement.getAttribute('data-theme') === 'light';
810
+ updateTheme(isLight ? 'dark' : 'light');
811
+ syncThemeMenuLabel();
812
+ closeChatActionsMenu();
813
+ });
814
+ }
815
+
816
+ function disableSharedControl(element, {
817
+ title = 'Shared conversation (read-only)',
818
+ hide = false,
819
+ } = {}) {
820
+ if (!element) return;
821
+ element.disabled = true;
822
+ element.setAttribute('aria-disabled', 'true');
823
+ if (title) element.title = title;
824
+ if (hide) element.hidden = true;
825
+ }
826
+
827
+ function hideSharedMenuEntry(element) {
828
+ if (!element) return;
829
+ element.hidden = true;
830
+ element.setAttribute('aria-hidden', 'true');
831
+ if ('disabled' in element) element.disabled = true;
832
+ if ('tabIndex' in element) element.tabIndex = -1;
833
+ }
834
+
835
+ function applySharedReaderUi() {
836
+ document.body.classList.add('shared-reader-mode', 'sidebar-collapsed');
837
+ closeSidebar();
838
+ const sidebar = document.getElementById('sidebar');
839
+ const sidebarResizer = document.getElementById('sidebar-resizer');
840
+ const sidebarToggle = document.getElementById('sidebar-toggle');
841
+ const newConversationBtn = document.getElementById('new-conv-btn');
842
+ const searchBtn = document.getElementById('message-search-btn');
843
+ const contextBtn = document.getElementById('context-btn');
844
+ const fullscreenBtn = document.getElementById('fullscreen-btn');
845
+ const input = document.getElementById('msg-input');
846
+ const sendBtn = document.getElementById('send-btn');
847
+ const attachBtn = document.getElementById('attach-btn');
848
+ const emojiBtn = document.getElementById('emoji-btn');
849
+ const repoDesktopBtn = document.getElementById('repo-browser-desktop-btn');
850
+ const repoInputBtn = document.getElementById('repo-browser-input-btn');
851
+ const repoFabBtn = document.getElementById('repo-browser-fab');
852
+ const modelSelect = document.getElementById('model-select');
853
+ const effortSelect = document.getElementById('reasoning-effort-select');
854
+ const modeSelect = document.getElementById('mode-select');
855
+ const chatMenuBtn = document.getElementById('chat-actions-menu-btn');
856
+ const chatMenu = document.getElementById('chat-actions-menu');
857
+ const sharedThemeBtn = document.getElementById('chat-menu-shared-theme-toggle');
858
+ const installBtn = document.getElementById('install-btn');
859
+ const activeElement = document.activeElement;
860
+ if (chatMenu && activeElement instanceof Element && chatMenu.contains(activeElement)) {
861
+ activeElement.blur();
862
+ }
863
+ closeChatActionsMenu();
864
+ if (sidebar) sidebar.hidden = true;
865
+ if (sidebarResizer) sidebarResizer.hidden = true;
866
+ if (sidebarToggle) sidebarToggle.hidden = true;
867
+ if (newConversationBtn) newConversationBtn.hidden = true;
868
+ disableSharedControl(searchBtn);
869
+ disableSharedControl(contextBtn);
870
+ if (fullscreenBtn) fullscreenBtn.hidden = true;
871
+ if (input) {
872
+ input.readOnly = true;
873
+ input.disabled = true;
874
+ input.placeholder = 'Shared conversation (read-only)';
875
+ }
876
+ disableSharedControl(sendBtn);
877
+ disableSharedControl(attachBtn);
878
+ disableSharedControl(emojiBtn);
879
+ disableSharedControl(repoDesktopBtn, { hide: true });
880
+ disableSharedControl(repoInputBtn);
881
+ if (repoFabBtn) repoFabBtn.hidden = true;
882
+ disableSharedControl(installBtn, { hide: true, title: 'PWA install is unavailable for shared conversations' });
883
+ if (modelSelect) modelSelect.hidden = true;
884
+ if (effortSelect) effortSelect.hidden = true;
885
+ if (modeSelect) modeSelect.hidden = true;
886
+ if (chatMenu && sharedThemeBtn) {
887
+ for (const child of Array.from(chatMenu.children)) {
888
+ if (child !== sharedThemeBtn) hideSharedMenuEntry(child);
889
+ }
890
+ sharedThemeBtn.hidden = false;
891
+ sharedThemeBtn.removeAttribute('aria-hidden');
892
+ sharedThemeBtn.disabled = false;
893
+ sharedThemeBtn.tabIndex = 0;
894
+ syncThemeMenuLabel();
895
+ } else if (chatMenuBtn) {
896
+ chatMenuBtn.hidden = true;
897
+ }
898
+ if (chatMenuBtn && !sharedThemeBtn) chatMenuBtn.hidden = true;
899
+ }
900
+
901
+ function syncChatTitleWatcherIndicator() {
902
+ const indicator = document.getElementById('chat-title-watch-indicator');
903
+ if (!indicator) return;
904
+ if (isSharedReaderMode()) {
905
+ indicator.hidden = true;
906
+ return;
907
+ }
908
+ const convId = String(currentConvId || '').trim();
909
+ const watcherCount = convId ? getConversationWatcherCount(convId) : 0;
910
+ indicator.hidden = watcherCount <= 0;
911
+ indicator.title = watcherCount > 0 ? `${watcherCount} watcher${watcherCount === 1 ? '' : 's'} currently reading this conversation` : '';
912
+ }
913
+
914
+ function computeSharedConversationRenderKey(response = null) {
915
+ const messages = Array.isArray(response?.messages) ? response.messages : [];
916
+ const ids = messages.map((message) => `${String(message?.id || '').trim()}:${String(message?.timestamp || '').trim()}`).join('|');
917
+ const inFlight = response?.inFlight || null;
918
+ return [
919
+ String(response?.id || '').trim(),
920
+ String(response?.updatedAt || '').trim(),
921
+ messages.length,
922
+ ids,
923
+ String(inFlight?.messageId || '').trim(),
924
+ String(inFlight?.status || '').trim(),
925
+ String(inFlight?.lastStreamSeq || '').trim(),
926
+ inFlight?.streamDone ? '1' : '0',
927
+ ].join('::');
928
+ }
929
+
930
+ function resolveStableScrollTopForLiveRefresh(messagesEl, capturedScrollTop) {
931
+ const captured = Number(capturedScrollTop);
932
+ const current = Number(messagesEl?.scrollTop);
933
+ if (!Number.isFinite(current)) return Number.isFinite(captured) ? captured : 0;
934
+ if (!Number.isFinite(captured)) return current;
935
+ if (Math.abs(current - captured) > 2) return current;
936
+ return captured;
937
+ }
938
+
939
+ async function refreshSharedConversation() {
940
+ if (sharedConversationPollInFlight) return;
941
+ const sharedToken = resolveSharedTokenFromLocation();
942
+ if (!sharedToken) return;
943
+ sharedConversationPollInFlight = true;
944
+ const requestSeq = ++sharedConversationRequestSeq;
945
+ const messagesEl = document.getElementById('messages');
946
+ const preserveBottom = isMessagesAtBottom();
947
+ const savedScrollTop = messagesEl?.scrollTop || 0;
948
+ try {
949
+ const response = await loadSharedConversation(sharedToken, { limit: 120 });
950
+ if (requestSeq < sharedConversationAppliedSeq) return;
951
+ if (!response?.ok) {
952
+ const message = String(response?.error || 'Could not load shared conversation.').trim();
953
+ if (message && message !== sharedConversationLastError) {
954
+ setModelBanner(`⚠️ ${message}`);
955
+ sharedConversationLastError = message;
956
+ }
957
+ return;
958
+ }
959
+ if (sharedConversationLastError) {
960
+ sharedConversationLastError = '';
961
+ setModelBanner('');
962
+ }
963
+ const convId = String(response.id || '').trim();
964
+ if (!convId) return;
965
+ setCurrentConv(convId);
966
+ conversations[convId] = {
967
+ ...(conversations[convId] || {}),
968
+ id: convId,
969
+ title: String(response.title || 'Shared conversation').trim() || 'Shared conversation',
970
+ updatedAt: response.updatedAt || new Date().toISOString(),
971
+ messageCount: Array.isArray(response.messages) ? response.messages.length : 0,
972
+ sdkSessionId: null,
973
+ runtimeSessionId: null,
974
+ };
975
+ const titleEl = document.getElementById('chat-title');
976
+ if (titleEl) titleEl.textContent = conversations[convId].title;
977
+ const renderKey = computeSharedConversationRenderKey(response);
978
+ if (renderKey && renderKey === sharedConversationRenderKey) {
979
+ sharedConversationAppliedSeq = requestSeq;
980
+ return;
981
+ }
982
+ sharedConversationRenderKey = renderKey;
983
+ const stableSavedScrollTop = resolveStableScrollTopForLiveRefresh(messagesEl, savedScrollTop);
984
+ applyLoadedConversationState(convId, response, {
985
+ restoreScroll: !preserveBottom,
986
+ savedScrollTop: preserveBottom ? null : stableSavedScrollTop,
987
+ followLiveUpdates: preserveBottom,
988
+ });
989
+ sharedConversationAppliedSeq = requestSeq;
990
+ syncViewportMetrics();
991
+ } finally {
992
+ sharedConversationPollInFlight = false;
993
+ }
994
+ }
995
+
996
+ async function pulseSharedViewerPresence() {
997
+ const sharedToken = resolveSharedTokenFromLocation();
998
+ if (!sharedToken) return;
999
+ await reportSharedViewerPresence(sharedToken, sharedViewerId()).catch(() => {});
1000
+ }
1001
+
1002
+ async function initSharedConversationReader() {
1003
+ applySharedReaderUi();
1004
+ await refreshSharedConversation();
1005
+ await pulseSharedViewerPresence();
1006
+ stopSharedModeTimers();
1007
+ sharedConversationPollTimer = setInterval(() => {
1008
+ if (!shouldRunForegroundNetworkWork()) return;
1009
+ void refreshSharedConversation();
1010
+ }, 900);
1011
+ sharedPresencePollTimer = setInterval(() => {
1012
+ if (!shouldRunForegroundNetworkWork()) return;
1013
+ void pulseSharedViewerPresence();
1014
+ }, 12_000);
1015
+ }
1016
+
1017
+ function showAuthError(msg) {
1018
+ document.getElementById('auth-error').textContent = msg;
1019
+ }
1020
+
1021
+ function resolveAuthErrorMessage(result = null) {
1022
+ if (result?.status === 401) return 'Invalid token';
1023
+ if (result?.status === 403) return 'Access denied';
1024
+ if (result?.status > 0) return `Authentication failed (${result.status})`;
1025
+ return String(result?.error || 'Could not reach the relay').trim() || 'Could not reach the relay';
1026
+ }
1027
+
1028
+ function syncPwaVersionMenuEntry() {
1029
+ const chip = document.getElementById('chat-menu-pwa-version');
1030
+ const value = document.getElementById('chat-menu-pwa-version-value');
1031
+ if (!chip) return;
1032
+ const version = String(window.__COPILOT_PWA_VERSION || '').trim();
1033
+ if (value) {
1034
+ value.textContent = version ? `v${version}` : 'v?';
1035
+ return;
1036
+ }
1037
+ chip.textContent = version ? `PWA shell version: v${version}` : 'PWA shell version: v?';
1038
+ }
1039
+
1040
+ function normalizeQueueCount(value) {
1041
+ const parsed = Number(value);
1042
+ return Number.isFinite(parsed) && parsed >= 0 ? Math.trunc(parsed) : 0;
1043
+ }
1044
+
1045
+ function updateQueueStatusFromPayload(payload = null) {
1046
+ if (!payload || typeof payload !== 'object') return;
1047
+ latestQueueStatus = {
1048
+ pendingCount: normalizeQueueCount(payload.pendingCount ?? payload.queue?.pendingCount),
1049
+ processingCount: normalizeQueueCount(payload.processingCount ?? payload.queue?.processingCount),
1050
+ parkedCount: normalizeQueueCount(payload.parkedCount ?? payload.queue?.parkedCount),
1051
+ };
1052
+ }
1053
+
1054
+ function syncQueueStatusMenuEntry(payload = null) {
1055
+ if (payload) updateQueueStatusFromPayload(payload);
1056
+ const chip = document.getElementById('chat-menu-queue-status');
1057
+ const value = document.getElementById('chat-menu-queue-status-value');
1058
+ if (!chip) return;
1059
+ const statusText = `pending=${latestQueueStatus.pendingCount}, processing=${latestQueueStatus.processingCount}, parked=${latestQueueStatus.parkedCount}`;
1060
+ if (value) {
1061
+ value.textContent = statusText;
1062
+ return;
1063
+ }
1064
+ chip.textContent = `Queue: ${statusText}`;
1065
+ }
1066
+
1067
+
1068
+ function splitVariantId(modelVariantId = '') {
1069
+ const value = String(modelVariantId || '').trim();
1070
+ if (!value) return { baseModelId: '', reasoningEffort: null };
1071
+ const match = value.match(/^(.*)-(none|low|medium|high|xhigh|max)$/i);
1072
+ if (!match) return { baseModelId: value, reasoningEffort: null };
1073
+ return {
1074
+ baseModelId: String(match[1] || '').trim(),
1075
+ reasoningEffort: String(match[2] || '').trim().toLowerCase(),
1076
+ };
1077
+ }
1078
+
1079
+ function humanizeModelLabel(modelId = '') {
1080
+ const text = String(modelId || '').trim();
1081
+ if (!text) return '';
1082
+ if (/^gpt-/i.test(text)) {
1083
+ return text
1084
+ .replace(/^gpt-/i, 'GPT-')
1085
+ .replace(/-codex$/i, ' Codex')
1086
+ .replace(/-mini$/i, ' Mini');
1087
+ }
1088
+ if (/^claude-/i.test(text)) {
1089
+ return text
1090
+ .replace(/^claude-/i, 'Claude ')
1091
+ .split('-')
1092
+ .map((part) => (/^\d+(\.\d+)?$/.test(part) ? part : (part.charAt(0).toUpperCase() + part.slice(1))))
1093
+ .join(' ');
1094
+ }
1095
+ if (/^gemini-/i.test(text)) {
1096
+ return text
1097
+ .replace(/^gemini-/i, 'Gemini ')
1098
+ .split('-')
1099
+ .map((part) => (/^\d+(\.\d+)?$/.test(part) ? part : (part.charAt(0).toUpperCase() + part.slice(1))))
1100
+ .join(' ');
1101
+ }
1102
+ return text;
1103
+ }
1104
+
1105
+ function modelOptionLabel(modelVariantId = '') {
1106
+ if (String(modelVariantId || '').trim().toLowerCase() === AUTO_MODEL_OPTION) return 'Auto';
1107
+ const { baseModelId, reasoningEffort } = splitVariantId(modelVariantId);
1108
+ if (!baseModelId) return modelVariantId;
1109
+ const baseLabel = humanizeModelLabel(baseModelId);
1110
+ return reasoningEffort ? `${baseLabel} (${reasoningEffort})` : baseLabel;
1111
+ }
1112
+
1113
+ function normalizeReasoningEffortList(efforts = []) {
1114
+ const values = Array.isArray(efforts)
1115
+ ? efforts.map((value) => String(value || '').trim().toLowerCase()).filter(Boolean)
1116
+ : [];
1117
+ return Array.from(new Set(values));
1118
+ }
1119
+
1120
+ function isModelMetadataHealthy(payload = modelCatalogState) {
1121
+ if (!payload || typeof payload !== 'object') return false;
1122
+ if (payload.metadataValid === false) return false;
1123
+ if (payload.stale) return false;
1124
+ const reasoningByModel = payload.reasoningByModel && typeof payload.reasoningByModel === 'object'
1125
+ ? payload.reasoningByModel
1126
+ : {};
1127
+ const modelIds = Object.keys(reasoningByModel).filter((modelId) => modelId !== AUTO_MODEL_OPTION);
1128
+ if (!modelIds.length) return false;
1129
+ return modelIds.every((modelId) => {
1130
+ const efforts = reasoningByModel[modelId];
1131
+ return Array.isArray(efforts) && efforts.length > 0;
1132
+ });
1133
+ }
1134
+
1135
+ function syncModelMetadataBlocker(message = '') {
1136
+ // Shared readers have no model picker, so metadata problems are not actionable there.
1137
+ if (isSharedReaderMode()) {
1138
+ document.getElementById('model-metadata-blocker')?.classList.remove('visible');
1139
+ return;
1140
+ }
1141
+ const blocker = document.getElementById('model-metadata-blocker');
1142
+ const text = document.getElementById('model-metadata-blocker-text');
1143
+ const retryBtn = document.getElementById('model-metadata-retry-btn');
1144
+ const blocked = modelMetadataBlocked || !isModelMetadataHealthy();
1145
+ if (text) {
1146
+ text.textContent = String(message || '').trim()
1147
+ || 'Model metadata is unavailable. Refresh to choose a model and reasoning effort.';
1148
+ }
1149
+ if (retryBtn) retryBtn.disabled = modelMetadataRetryInFlight;
1150
+ blocker?.classList.toggle('visible', blocked);
1151
+ const modelSelect = document.getElementById('model-select');
1152
+ const reasoningSelect = document.getElementById('reasoning-effort-select');
1153
+ if (modelSelect) {
1154
+ modelSelect.disabled = blocked;
1155
+ modelSelect.title = blocked ? 'Model metadata unavailable' : 'Model';
1156
+ }
1157
+ if (reasoningSelect) {
1158
+ reasoningSelect.disabled = blocked;
1159
+ reasoningSelect.title = blocked ? 'Reasoning metadata unavailable' : 'Reasoning effort';
1160
+ }
1161
+ window.syncComposerControlState?.();
1162
+ }
1163
+
1164
+ function currentConversationHasMessages() {
1165
+ const conversation = currentConvId ? conversations[currentConvId] : null;
1166
+ return Number(conversation?.messageCount || 0) > 0;
1167
+ }
1168
+
1169
+ function currentOpenAIModelLock() {
1170
+ const conversation = currentConvId ? conversations[currentConvId] : null;
1171
+ const providerType = String(
1172
+ conversation?.runtimeProviderType
1173
+ || conversation?.runtime_provider_type
1174
+ || '',
1175
+ ).trim().toLowerCase();
1176
+ const providerIsOpenAI = providerType === 'openai' || providerType === 'openai-byok';
1177
+ if (!providerIsOpenAI || !currentConversationHasMessages()) return null;
1178
+ return {
1179
+ model: String(
1180
+ conversation?.runtimeProviderModel
1181
+ || conversation?.runtime_provider_model
1182
+ || '',
1183
+ ).trim(),
1184
+ };
1185
+ }
1186
+
1187
+ // A Grok conversation's model is fixed once the first message exists: the ACP
1188
+ // session cannot switch models mid-session and the relay 409s any attempt, so
1189
+ // the composer pins the picker instead of offering a switch that would fail.
1190
+ function currentGrokModelLock() {
1191
+ const conversation = currentConvId ? conversations[currentConvId] : null;
1192
+ const providerType = String(
1193
+ conversation?.runtimeProviderType
1194
+ || conversation?.runtime_provider_type
1195
+ || '',
1196
+ ).trim().toLowerCase();
1197
+ if (providerType !== 'grok' || !currentConversationHasMessages()) return null;
1198
+ return {
1199
+ model: String(
1200
+ conversation?.runtimeProviderModel
1201
+ || conversation?.runtime_provider_model
1202
+ || '',
1203
+ ).trim(),
1204
+ };
1205
+ }
1206
+
1207
+ function currentRuntimeModelLock() {
1208
+ return currentOpenAIModelLock() || currentGrokModelLock();
1209
+ }
1210
+
1211
+ // The runtime model decides the OpenAI/OpenAI Image distinction. Before the
1212
+ // first message the runtime model can still be rebound, so the composer
1213
+ // selection is the fresher source there.
1214
+ function sessionLockModelForCurrentConversation() {
1215
+ const conversation = currentConvId ? conversations[currentConvId] : null;
1216
+ const runtimeModel = String(
1217
+ conversation?.runtimeProviderModel
1218
+ || conversation?.runtime_provider_model
1219
+ || conversation?.runtimeModel
1220
+ || conversation?.runtime_model
1221
+ || '',
1222
+ ).trim();
1223
+ if (currentConversationHasMessages()) return runtimeModel;
1224
+ return String(document.getElementById('model-select')?.value || '').trim() || runtimeModel;
1225
+ }
1226
+
1227
+ function currentSessionProviderLock({ pinnedModel = '' } = {}) {
1228
+ // Shared readers have no model picker, so a lock note would be noise.
1229
+ if (!currentConvId || isSharedReaderMode()) return null;
1230
+ const providerType = activeComposerProviderType();
1231
+ const model = sessionLockModelForCurrentConversation();
1232
+ const noteText = sessionLockNoteText({ providerType, model, pinnedModel });
1233
+ if (!noteText) return null;
1234
+ return { providerKey: sessionLockProviderKey({ providerType, model }), noteText };
1235
+ }
1236
+
1237
+ function syncAutoModelAvailability() {
1238
+ const select = document.getElementById('model-select');
1239
+ if (!select) return;
1240
+ updateModeSelectorForProvider();
1241
+ const currentProviderScope = normalizeModelSelectorProviderType(activeComposerProviderType());
1242
+ if (select.dataset.providerScope !== currentProviderScope) {
1243
+ updateModelCatalogState(modelCatalogState);
1244
+ return;
1245
+ }
1246
+ const autoOption = Array.from(select.options).find((option) => option.value.toLowerCase() === AUTO_MODEL_OPTION);
1247
+ const hasMessages = currentConversationHasMessages();
1248
+ const runtimeLock = currentRuntimeModelLock();
1249
+ for (const option of Array.from(select.options)) {
1250
+ if (option.dataset.runtimeModelLock === '1' && option.value !== runtimeLock?.model) {
1251
+ option.remove();
1252
+ }
1253
+ }
1254
+ if (autoOption) {
1255
+ autoOption.disabled = hasMessages;
1256
+ autoOption.title = hasMessages ? 'Auto is available only for a new conversation' : '';
1257
+ }
1258
+ if (hasMessages && select.value.toLowerCase() === AUTO_MODEL_OPTION) {
1259
+ const fallback = [
1260
+ modelCatalogState.currentModel,
1261
+ modelCatalogState.defaultModel,
1262
+ ...modelCatalogState.models,
1263
+ ].find((modelId) => String(modelId || '').trim().toLowerCase() !== AUTO_MODEL_OPTION
1264
+ && Array.from(select.options).some((option) => option.value === modelId));
1265
+ if (fallback) select.value = fallback;
1266
+ }
1267
+ if (runtimeLock?.model && !Array.from(select.options).some((option) => option.value === runtimeLock.model)) {
1268
+ const option = document.createElement('option');
1269
+ option.value = runtimeLock.model;
1270
+ option.textContent = `🔒 ${modelOptionLabel(runtimeLock.model)}`;
1271
+ option.dataset.runtimeModelLock = '1';
1272
+ select.appendChild(option);
1273
+ }
1274
+ if (runtimeLock?.model) {
1275
+ select.value = runtimeLock.model;
1276
+ }
1277
+ select.dataset.runtimeModelLocked = runtimeLock ? '1' : '0';
1278
+ const metadataBlocked = modelMetadataBlocked || !isModelMetadataHealthy();
1279
+ select.disabled = metadataBlocked || !!runtimeLock;
1280
+ select.title = runtimeLock
1281
+ ? `Model locked to ${runtimeLock.model || 'the configured provider model'} for this active session`
1282
+ : (metadataBlocked ? 'Model metadata unavailable' : 'Model');
1283
+ syncSessionLockNote({ pinnedModel: runtimeLock?.model || '' });
1284
+ }
1285
+
1286
+ function syncSessionLockNote({ pinnedModel = '' } = {}) {
1287
+ const note = document.getElementById('model-lock-note');
1288
+ if (!note) return;
1289
+ const providerLock = currentSessionProviderLock({ pinnedModel });
1290
+ note.hidden = !providerLock;
1291
+ note.textContent = providerLock?.noteText || '';
1292
+ if (providerLock?.providerKey) {
1293
+ note.dataset.provider = providerLock.providerKey;
1294
+ } else {
1295
+ delete note.dataset.provider;
1296
+ }
1297
+ }
1298
+
1299
+ function applyModelMetadataHardFail(message = '') {
1300
+ if (isSharedReaderMode()) return;
1301
+ modelMetadataBlocked = true;
1302
+ syncModelMetadataBlocker(message);
1303
+ setModelBanner(`⚠️ ${String(message || 'Model metadata is unavailable.').trim()}`);
1304
+ }
1305
+
1306
+ function clearModelMetadataHardFail() {
1307
+ modelMetadataBlocked = false;
1308
+ syncModelMetadataBlocker('');
1309
+ if (!modelCatalogState.warning && !modelCatalogState.stale) {
1310
+ setModelBanner('');
1311
+ }
1312
+ }
1313
+
1314
+ async function startAppWithErrorHandling() {
1315
+ try {
1316
+ await initApp();
1317
+ return true;
1318
+ } catch (error) {
1319
+ console.error('[bootstrap] initApp failed', error);
1320
+ showAuthGate();
1321
+ showAuthError(`App initialization failed: ${String(error?.message || error || 'unknown error')}`);
1322
+ return false;
1323
+ }
1324
+ }
1325
+
1326
+ function activeComposerProviderType() {
1327
+ const conversation = conversations[currentConvId] || null;
1328
+ return String(
1329
+ conversation?.runtimeProviderType
1330
+ || conversation?.runtime_provider_type
1331
+ || 'github',
1332
+ ).trim().toLowerCase();
1333
+ }
1334
+
1335
+ function normalizeModelSelectorProviderType(providerType = '') {
1336
+ const normalized = String(providerType || '').trim().toLowerCase();
1337
+ if (normalized === 'openai' || normalized === 'openai-byok') return 'openai';
1338
+ if (normalized === 'claude') return 'claude';
1339
+ if (normalized === 'cursor') return 'cursor';
1340
+ if (normalized === 'grok') return 'grok';
1341
+ return 'github';
1342
+ }
1343
+
1344
+ function modelProvidersForId(modelId, providersByModel = {}) {
1345
+ const key = String(modelId || '').trim().toLowerCase();
1346
+ if (!key) return [];
1347
+ const providers = providersByModel?.[key];
1348
+ if (!Array.isArray(providers)) return [];
1349
+ return providers.map((provider) => String(provider || '').trim().toLowerCase()).filter(Boolean);
1350
+ }
1351
+
1352
+ function openAIImageSizesForModel(modelId = '') {
1353
+ const normalized = String(modelId || '').trim().toLowerCase().replace(/^openai\//, '');
1354
+ if (normalized.startsWith('dall-e-2')) return ['256x256', '512x512', '1024x1024'];
1355
+ if (normalized.startsWith('dall-e-3')) return ['1024x1024', '1792x1024', '1024x1792'];
1356
+ return ['auto', '1024x1024', '1536x1024', '1024x1536'];
1357
+ }
1358
+
1359
+ function modelVisibleForActiveProvider(modelId, activeProviderType, providersByModel = {}) {
1360
+ const normalizedModelId = String(modelId || '').trim().toLowerCase();
1361
+ if (!normalizedModelId) return false;
1362
+ if (normalizedModelId === AUTO_MODEL_OPTION) return true;
1363
+ const providers = modelProvidersForId(normalizedModelId, providersByModel);
1364
+ const hasOpenAIByok = providers.includes('openai-byok');
1365
+ const hasClaude = providers.includes('claude');
1366
+ const hasCursor = providers.includes('cursor');
1367
+ const hasGrok = providers.includes('grok');
1368
+ const hasNonExclusiveProvider = providers.some((provider) => (
1369
+ provider !== 'openai-byok'
1370
+ && provider !== 'claude'
1371
+ && provider !== 'cursor'
1372
+ && provider !== 'grok'
1373
+ ));
1374
+ const activeProvider = normalizeModelSelectorProviderType(activeProviderType);
1375
+ if (activeProvider === 'openai') return hasOpenAIByok;
1376
+ if (activeProvider === 'claude') return hasClaude;
1377
+ if (activeProvider === 'cursor') return hasCursor;
1378
+ if (activeProvider === 'grok') return hasGrok;
1379
+ return !((hasOpenAIByok || hasClaude || hasCursor || hasGrok) && !hasNonExclusiveProvider);
1380
+ }
1381
+
1382
+ function buildModelSelectorOptions(models = [], providersByModel = {}, activeProviderType = '') {
1383
+ const normalizedOptions = normalizeModelSelectorOptions(models.length ? models : [FALLBACK_MODEL], {
1384
+ autoValue: AUTO_MODEL_OPTION,
1385
+ labelFor: modelOptionLabel,
1386
+ });
1387
+ return normalizedOptions.filter((option) => modelVisibleForActiveProvider(
1388
+ option.value,
1389
+ activeProviderType,
1390
+ providersByModel,
1391
+ ));
1392
+ }
1393
+
1394
+ function reasoningOptionsForModel(modelId = '') {
1395
+ if (!isModelMetadataHealthy()) return [];
1396
+ const key = String(modelId || '').trim().toLowerCase();
1397
+ const provider = activeComposerProviderType();
1398
+ const providerOptions = modelCatalogState.reasoningByProvider?.[provider]?.[key];
1399
+ if (Array.isArray(providerOptions)) return normalizeReasoningEffortList(providerOptions);
1400
+ return normalizeReasoningEffortList(modelCatalogState.reasoningByModel?.[key] || []);
1401
+ }
1402
+
1403
+ function reasoningProviderKey(providerType = '') {
1404
+ const key = String(providerType || '').trim().toLowerCase();
1405
+ if (key === 'openai-byok') return 'openai';
1406
+ if (key === 'github-copilot') return 'github';
1407
+ return key;
1408
+ }
1409
+
1410
+ function reasoningOptionsForProviderModel(providerType = '', modelId = '') {
1411
+ if (!isModelMetadataHealthy()) return [];
1412
+ const key = String(modelId || '').trim().toLowerCase();
1413
+ if (!key) return [];
1414
+ const provider = reasoningProviderKey(providerType);
1415
+ const providerOptions = modelCatalogState.reasoningByProvider?.[provider]?.[key];
1416
+ if (Array.isArray(providerOptions) && providerOptions.length) {
1417
+ return normalizeReasoningEffortList(providerOptions);
1418
+ }
1419
+ return normalizeReasoningEffortList(modelCatalogState.reasoningByModel?.[key] || []);
1420
+ }
1421
+
1422
+ function selectedReasoningEffortValue() {
1423
+ const select = document.getElementById('reasoning-effort-select');
1424
+ const value = String(select?.value || '').trim().toLowerCase();
1425
+ if (value) return value;
1426
+ return FALLBACK_REASONING_EFFORT;
1427
+ }
1428
+
1429
+ // Opt in to persist=true only from a user-initiated change. Every other caller
1430
+ // is re-rendering options (catalog refresh, provider rescope, applying a stored
1431
+ // preference), and letting those write the shared fallback storage is what made
1432
+ // a transient resolution the remembered default.
1433
+ function updateReasoningSelectorForModel(modelId, preferredEffort = '', { persist = false } = {}) {
1434
+ const select = document.getElementById('reasoning-effort-select');
1435
+ if (!select) return;
1436
+ select.title = isOpenAIImageModelId(modelId) ? 'Quality' : 'Reasoning effort';
1437
+ const options = reasoningOptionsForModel(modelId);
1438
+ const currentEffort = String(select.value || '').trim().toLowerCase();
1439
+ const storedEffort = String(localStorage.getItem(REASONING_STORAGE_KEY) || '').trim().toLowerCase();
1440
+ select.innerHTML = '';
1441
+ if (!options.length) {
1442
+ const opt = document.createElement('option');
1443
+ opt.value = '';
1444
+ opt.textContent = 'Unavailable';
1445
+ select.appendChild(opt);
1446
+ select.value = '';
1447
+ return;
1448
+ }
1449
+ const reasoningOffUnsupported = isReasoningOffUnsupported(
1450
+ modelCatalogState,
1451
+ activeComposerProviderType(),
1452
+ modelId,
1453
+ );
1454
+ for (const effort of options) {
1455
+ const opt = document.createElement('option');
1456
+ opt.value = effort;
1457
+ opt.textContent = reasoningEffortOptionLabel(effort, { reasoningOffUnsupported });
1458
+ const optionTitle = reasoningEffortOptionTitle(effort);
1459
+ if (optionTitle) opt.title = optionTitle;
1460
+ select.appendChild(opt);
1461
+ }
1462
+ const resolved = resolveComposerReasoningEffort({
1463
+ preferredEffort,
1464
+ storedEffort,
1465
+ currentEffort,
1466
+ supportedEfforts: options,
1467
+ });
1468
+ select.value = resolved;
1469
+ if (persist && resolved) localStorage.setItem(REASONING_STORAGE_KEY, resolved);
1470
+ }
1471
+
1472
+ // Rebuilds the option list for the active conversation's provider without
1473
+ // touching the selection, mirroring updateModeSelectorForProvider. Preferences
1474
+ // used to be clamped against the previous provider's options, which is how a
1475
+ // Cursor conversation could keep the Claude model that was selected before it.
1476
+ function rebuildModelSelectorOptionsForProvider() {
1477
+ const select = document.getElementById('model-select');
1478
+ if (!select || isSharedReaderMode()) return false;
1479
+ const activeProviderType = activeComposerProviderType();
1480
+ const scope = normalizeModelSelectorProviderType(activeProviderType);
1481
+ if (select.dataset.providerScope === scope) return false;
1482
+ // Same rule as the catalog refresh: never re-render the list out from under
1483
+ // an open picker. The scope stays unset so the rebuild happens on blur.
1484
+ if (document.activeElement === select) return false;
1485
+ const nextOptions = buildModelSelectorOptions(
1486
+ modelCatalogState.models,
1487
+ modelCatalogState.providersByModel,
1488
+ activeProviderType,
1489
+ );
1490
+ const currentOptions = Array.from(select.options)
1491
+ .filter((option) => option.dataset.runtimeModelLock !== '1')
1492
+ .map((option) => ({ value: option.value, label: option.textContent }));
1493
+ if (!modelSelectorOptionsEqual(currentOptions, nextOptions)) {
1494
+ const selectedBefore = String(select.value || '').trim();
1495
+ const lockedOptions = Array.from(select.options).filter((option) => option.dataset.runtimeModelLock === '1');
1496
+ select.innerHTML = '';
1497
+ for (const option of nextOptions) {
1498
+ const opt = document.createElement('option');
1499
+ opt.value = option.value;
1500
+ opt.textContent = option.label;
1501
+ select.appendChild(opt);
1502
+ }
1503
+ for (const locked of lockedOptions) select.appendChild(locked);
1504
+ // Emptying the select resets the value to the first option, which would
1505
+ // hand the clamp below "auto" instead of what was actually selected.
1506
+ if (Array.from(select.options).some((option) => option.value === selectedBefore)) {
1507
+ select.value = selectedBefore;
1508
+ }
1509
+ }
1510
+ select.dataset.providerScope = scope;
1511
+ return true;
1512
+ }
1513
+
1514
+ function updateModelCatalogState(payload) {
1515
+ // Shared readers never load the catalog, so this would only surface
1516
+ // "metadata unavailable" warnings for a picker they cannot see.
1517
+ if (isSharedReaderMode()) return;
1518
+ const select = document.getElementById('model-select');
1519
+ if (!select) return;
1520
+ const models = Array.isArray(payload?.models)
1521
+ ? payload.models.map((m) => String(m || '').trim()).filter(Boolean)
1522
+ : [];
1523
+ const currentModel = String(payload?.currentModel || models[0] || '').trim();
1524
+ const defaultModel = String(payload?.defaultModel || models[0] || '').trim();
1525
+ const normalizedModelOptions = normalizeModelSelectorOptions(models.length ? models : [FALLBACK_MODEL], {
1526
+ autoValue: AUTO_MODEL_OPTION,
1527
+ labelFor: modelOptionLabel,
1528
+ });
1529
+ const normalizedModels = normalizedModelOptions.map((option) => option.value);
1530
+
1531
+ const nextState = {
1532
+ models: normalizedModels,
1533
+ currentModel: currentModel || normalizedModels[0] || FALLBACK_MODEL,
1534
+ defaultModel: defaultModel || normalizedModels[0] || FALLBACK_MODEL,
1535
+ reasoningByModel: payload?.reasoningByModel && typeof payload.reasoningByModel === 'object'
1536
+ ? Object.fromEntries(Object.entries(payload.reasoningByModel).map(([modelId, efforts]) => [
1537
+ String(modelId || '').trim().toLowerCase(),
1538
+ normalizeReasoningEffortList(efforts),
1539
+ ]))
1540
+ : {},
1541
+ reasoningByProvider: payload?.reasoningByProvider && typeof payload.reasoningByProvider === 'object'
1542
+ ? Object.fromEntries(Object.entries(payload.reasoningByProvider).map(([provider, entries]) => [
1543
+ String(provider || '').trim().toLowerCase(),
1544
+ entries && typeof entries === 'object'
1545
+ ? Object.fromEntries(Object.entries(entries).map(([modelId, efforts]) => [
1546
+ String(modelId || '').trim().toLowerCase(),
1547
+ normalizeReasoningEffortList(efforts),
1548
+ ]))
1549
+ : {},
1550
+ ]))
1551
+ : {},
1552
+ reasoningOffUnsupportedByProvider: payload?.reasoningOffUnsupportedByProvider && typeof payload.reasoningOffUnsupportedByProvider === 'object'
1553
+ ? Object.fromEntries(Object.entries(payload.reasoningOffUnsupportedByProvider).map(([provider, entries]) => [
1554
+ String(provider || '').trim().toLowerCase(),
1555
+ entries && typeof entries === 'object'
1556
+ ? Object.fromEntries(Object.entries(entries).map(([modelId, unsupported]) => [
1557
+ String(modelId || '').trim().toLowerCase(),
1558
+ unsupported === true,
1559
+ ]))
1560
+ : {},
1561
+ ]))
1562
+ : {},
1563
+ providersByModel: payload?.providersByModel && typeof payload.providersByModel === 'object'
1564
+ ? Object.fromEntries(Object.entries(payload.providersByModel).map(([modelId, providers]) => [
1565
+ String(modelId || '').trim().toLowerCase(),
1566
+ Array.isArray(providers)
1567
+ ? providers.map((provider) => String(provider || '').trim().toLowerCase()).filter(Boolean)
1568
+ : [],
1569
+ ]))
1570
+ : {},
1571
+ reasoningEfforts: normalizeReasoningEffortList(payload?.reasoningEfforts || []),
1572
+ modelMetadataByModel: payload?.modelMetadataByModel && typeof payload.modelMetadataByModel === 'object'
1573
+ ? payload.modelMetadataByModel
1574
+ : {},
1575
+ claudeContextTiersByModel: payload?.claudeContextTiersByModel && typeof payload.claudeContextTiersByModel === 'object'
1576
+ ? Object.fromEntries(Object.entries(payload.claudeContextTiersByModel).map(([modelId, tiers]) => [
1577
+ String(modelId || '').trim().toLowerCase(),
1578
+ Array.isArray(tiers) ? tiers : [],
1579
+ ]))
1580
+ : {},
1581
+ stale: !!payload?.stale,
1582
+ metadataValid: payload?.metadataValid === true,
1583
+ reasoningMetadataValid: payload?.reasoningMetadataValid === true,
1584
+ warning: payload?.warning ? String(payload.warning) : null,
1585
+ error: payload?.error ? String(payload.error) : null,
1586
+ refreshedAt: payload?.refreshedAt || null,
1587
+ };
1588
+ const activeProviderType = activeComposerProviderType();
1589
+ const nextOptions = buildModelSelectorOptions(
1590
+ normalizedModels,
1591
+ nextState.providersByModel,
1592
+ activeProviderType,
1593
+ );
1594
+ const nextModels = nextOptions.map((option) => option.value);
1595
+ const currentOptions = Array.from(select.options)
1596
+ .filter((option) => option.dataset.runtimeModelLock !== '1')
1597
+ .map((option) => ({
1598
+ value: option.value,
1599
+ label: option.textContent,
1600
+ }));
1601
+ const optionsChanged = !modelSelectorOptionsEqual(currentOptions, nextOptions);
1602
+ if (optionsChanged && document.activeElement === select) {
1603
+ deferredModelCatalogPayload = payload;
1604
+ return;
1605
+ }
1606
+ deferredModelCatalogPayload = null;
1607
+
1608
+ const nextHealthy = isModelMetadataHealthy(nextState);
1609
+ const currentlyHealthy = isModelMetadataHealthy(modelCatalogState);
1610
+ if (!nextHealthy && !currentlyHealthy && isModelMetadataHealthy(lastHealthyModelCatalogState)) {
1611
+ modelCatalogState = { ...lastHealthyModelCatalogState };
1612
+ clearModelMetadataHardFail();
1613
+ setModelBanner('⚠️ Model metadata refresh failed; restored last known good catalog.');
1614
+ syncModelMetadataBlocker();
1615
+ return;
1616
+ }
1617
+ if (!nextHealthy && currentlyHealthy) {
1618
+ setModelBanner('⚠️ Model metadata refresh failed; keeping last known good catalog.');
1619
+ syncModelMetadataBlocker();
1620
+ return;
1621
+ }
1622
+
1623
+ modelCatalogState = nextState;
1624
+
1625
+ if (!nextHealthy) {
1626
+ applyModelMetadataHardFail(
1627
+ modelCatalogState.error
1628
+ ? `Model metadata error: ${modelCatalogState.error}`
1629
+ : (modelCatalogState.warning || 'Model metadata is stale or incomplete.'),
1630
+ );
1631
+ } else {
1632
+ lastHealthyModelCatalogState = modelCatalogState;
1633
+ clearModelMetadataHardFail();
1634
+ }
1635
+
1636
+ const selectedBefore = select.value;
1637
+ if (optionsChanged) {
1638
+ select.innerHTML = '';
1639
+ for (const option of nextOptions) {
1640
+ const opt = document.createElement('option');
1641
+ opt.value = option.value;
1642
+ opt.textContent = option.label;
1643
+ select.appendChild(opt);
1644
+ }
1645
+ }
1646
+ select.dataset.providerScope = normalizeModelSelectorProviderType(activeProviderType);
1647
+
1648
+ // A catalog refresh re-renders options and keeps the current selection valid;
1649
+ // it must not decide what is selected. The active conversation's preferences
1650
+ // are reapplied at the end, so a refresh cannot overwrite the user's pick.
1651
+ const keptSelection = nextModels.includes(selectedBefore) ? selectedBefore : '';
1652
+ const nextSelection = keptSelection
1653
+ || [
1654
+ localStorage.getItem(MODEL_STORAGE_KEY),
1655
+ modelCatalogState.currentModel,
1656
+ modelCatalogState.defaultModel,
1657
+ ].map((value) => String(value || '').trim()).find((value) => value && nextModels.includes(value))
1658
+ || nextModels[0];
1659
+ select.value = nextSelection;
1660
+ updateReasoningSelectorForModel(nextSelection, '');
1661
+ updateContextTierSelector(nextSelection);
1662
+
1663
+ if (modelCatalogState.warning && isModelMetadataHealthy(modelCatalogState)) {
1664
+ setModelBanner(`⚠️ ${modelCatalogState.warning}`);
1665
+ } else if (modelCatalogState.stale && isModelMetadataHealthy(modelCatalogState)) {
1666
+ setModelBanner('⚠️ Model list is cached from CLI; selection may be stale.');
1667
+ } else if (isModelMetadataHealthy(modelCatalogState)) {
1668
+ setModelBanner('');
1669
+ }
1670
+
1671
+ syncModelMetadataBlocker();
1672
+ syncAutoModelAvailability();
1673
+ // The rebuilt catalog may now contain the conversation's preferred model for
1674
+ // the first time (provider models arrive after the initial catalog load).
1675
+ if (currentConvId) applyConversationPreferencesForConversation(currentConvId);
1676
+ }
1677
+
1678
+ function selectedModelValue() {
1679
+ const select = document.getElementById('model-select');
1680
+ const value = String(select?.value || '').trim();
1681
+ if (value) return value;
1682
+ return modelCatalogState.currentModel || modelCatalogState.defaultModel || FALLBACK_MODEL;
1683
+ }
1684
+
1685
+ function updateContextTierSelector(modelId) {
1686
+ const select = document.getElementById('context-tier-select');
1687
+ if (!select) return;
1688
+ if (isOpenAIImageModelId(modelId)) {
1689
+ const current = String(select.value || '').trim().toLowerCase();
1690
+ const imageSizes = openAIImageSizesForModel(modelId);
1691
+ select.innerHTML = '';
1692
+ for (const size of imageSizes) {
1693
+ const option = document.createElement('option');
1694
+ option.value = size;
1695
+ option.textContent = size;
1696
+ select.appendChild(option);
1697
+ }
1698
+ select.value = imageSizes.includes(current) ? current : 'auto';
1699
+ select.title = 'Image size';
1700
+ updateModelPricingDetails(modelId, 'default');
1701
+ return;
1702
+ }
1703
+ const tierOptions = buildContextTierOptions({
1704
+ modelId,
1705
+ providerType: activeComposerProviderType(),
1706
+ metadata: modelCatalogState.modelMetadataByModel?.[modelId] || {},
1707
+ claudeTiers: modelCatalogState.claudeContextTiersByModel,
1708
+ });
1709
+ const current = select.value;
1710
+ select.innerHTML = '';
1711
+ for (const tier of tierOptions) {
1712
+ const option = document.createElement('option');
1713
+ option.value = tier.value;
1714
+ option.textContent = tier.label;
1715
+ select.appendChild(option);
1716
+ }
1717
+ select.value = resolveContextTierValue(tierOptions, current);
1718
+ select.title = 'Context window';
1719
+ updateModelPricingDetails(modelId, select.value);
1720
+ }
1721
+
1722
+ function updateModelPricingDetails(modelId, tier) {
1723
+ const details = document.getElementById('model-pricing-details');
1724
+ const summary = document.getElementById('model-pricing-summary');
1725
+ const grid = document.getElementById('model-pricing-grid');
1726
+ if (!details || !summary || !grid) return;
1727
+ const metadata = modelCatalogState.modelMetadataByModel?.[modelId] || {};
1728
+ const pricing = metadata?.pricing?.[tier === 'long_context' ? 'longContext' : 'default'];
1729
+ if (!pricing || typeof pricing !== 'object') {
1730
+ details.open = false;
1731
+ details.style.display = 'none';
1732
+ grid.replaceChildren();
1733
+ return;
1734
+ }
1735
+ const perMillion = (value) => {
1736
+ const price = Number(value);
1737
+ const batchSize = Number(pricing.batchSize) || 1000000;
1738
+ return Number.isFinite(price) ? (price * 1000000) / batchSize : null;
1739
+ };
1740
+ const input = perMillion(pricing.input);
1741
+ summary.textContent = `${tier === 'long_context' ? 'Long context' : 'Default'} pricing${input !== null ? ` · ${input} credits / 1M input` : ''}`;
1742
+ grid.replaceChildren();
1743
+ for (const [label, value] of [['Input', pricing.input], ['Output', pricing.output], ['Cache read', pricing.cacheRead], ['Cache write', pricing.cacheWrite]]) {
1744
+ const credits = perMillion(value);
1745
+ if (credits === null) continue;
1746
+ const labelEl = document.createElement('span');
1747
+ labelEl.textContent = label;
1748
+ const valueEl = document.createElement('b');
1749
+ valueEl.textContent = String(credits);
1750
+ grid.append(labelEl, valueEl);
1751
+ }
1752
+ details.style.display = grid.childElementCount ? '' : 'none';
1753
+ }
1754
+
1755
+ // Relay modes each provider backend actually honors. All four providers
1756
+ // currently support the full set — Copilot/OpenAI via prompt-context
1757
+ // injection, Claude via permissionMode + system-prompt appends, Cursor via
1758
+ // native plan mode plus message-text nudges for ask/autopilot — but the
1759
+ // composer builds its options from this table so a provider that loses (or
1760
+ // gains) a mode only needs a change here.
1761
+ const RELAY_MODE_LABELS = { agent: 'Agent', ask: 'Ask', plan: 'Plan', autopilot: 'Autopilot' };
1762
+ const RELAY_MODES_BY_PROVIDER = {
1763
+ github: ['agent', 'ask', 'plan', 'autopilot'],
1764
+ openai: ['agent', 'ask', 'plan', 'autopilot'],
1765
+ claude: ['agent', 'ask', 'plan', 'autopilot'],
1766
+ cursor: ['agent', 'ask', 'plan', 'autopilot'],
1767
+ grok: ['agent', 'ask', 'plan', 'autopilot'],
1768
+ };
1769
+
1770
+ function relayModesForProvider(providerType = '') {
1771
+ const scope = normalizeModelSelectorProviderType(providerType);
1772
+ const modes = RELAY_MODES_BY_PROVIDER[scope];
1773
+ return Array.isArray(modes) && modes.length ? modes : RELAY_MODES_BY_PROVIDER.github;
1774
+ }
1775
+
1776
+ // Same cache-and-rebuild idiom as the model select: dataset.providerScope
1777
+ // remembers which provider the current options were built for.
1778
+ function updateModeSelectorForProvider() {
1779
+ const select = document.getElementById('mode-select');
1780
+ if (!select || isSharedReaderMode()) return;
1781
+ const scope = normalizeModelSelectorProviderType(activeComposerProviderType());
1782
+ if (select.dataset.providerScope === scope) return;
1783
+ const modes = relayModesForProvider(scope);
1784
+ const selectedBefore = String(select.value || '').trim().toLowerCase();
1785
+ select.innerHTML = '';
1786
+ for (const mode of modes) {
1787
+ const option = document.createElement('option');
1788
+ option.value = mode;
1789
+ option.textContent = RELAY_MODE_LABELS[mode] || mode;
1790
+ select.appendChild(option);
1791
+ }
1792
+ select.dataset.providerScope = scope;
1793
+ select.value = modes.includes(selectedBefore)
1794
+ ? selectedBefore
1795
+ : (modes.includes(FALLBACK_MODE) ? FALLBACK_MODE : modes[0]);
1796
+ }
1797
+
1798
+ function modeOptions() {
1799
+ return Array.from(document.getElementById('mode-select')?.options || []).map((option) => option.value);
1800
+ }
1801
+
1802
+ function modelOptions() {
1803
+ return Array.from(document.getElementById('model-select')?.options || []).map((option) => option.value);
1804
+ }
1805
+
1806
+ async function persistCurrentConversationPreferences() {
1807
+ const convId = String(currentConvId || '').trim();
1808
+ if (!convId || suppressConversationPreferenceSync) return;
1809
+ const modeSelect = document.getElementById('mode-select');
1810
+ const modelSelect = document.getElementById('model-select');
1811
+ if (!modeSelect || !modelSelect) return;
1812
+ const mode = String(modeSelect.value || '').trim() || FALLBACK_MODE;
1813
+ const model = String(modelSelect.value || '').trim();
1814
+ // The raw value, not selectedReasoningEffortValue(): a model with no reasoning
1815
+ // options leaves the selector empty, and that helper's 'none' fallback would
1816
+ // record an effort the user never picked when they only changed the mode.
1817
+ const reasoningEffort = String(document.getElementById('reasoning-effort-select')?.value || '').trim().toLowerCase();
1818
+ // These keys hold the last explicit choice, so they are not rolled back when
1819
+ // the write below fails: the user did make the choice either way.
1820
+ localStorage.setItem(MODE_STORAGE_KEY, mode);
1821
+ if (model) localStorage.setItem(MODEL_STORAGE_KEY, model);
1822
+ if (reasoningEffort) localStorage.setItem(REASONING_STORAGE_KEY, reasoningEffort);
1823
+
1824
+ // For Claude conversations the 1M context tier is stored as the "[1m]"
1825
+ // variant of the preferred model, so it survives reopening the conversation.
1826
+ const contextTier = String(document.getElementById('context-tier-select')?.value || 'default').trim().toLowerCase();
1827
+ const preferredModelWithTier = model
1828
+ && contextTier === 'long_context'
1829
+ && activeComposerProviderType() === 'claude'
1830
+ && !CLAUDE_LONG_CONTEXT_PATTERN.test(model)
1831
+ ? `${model}[1m]`
1832
+ : model;
1833
+
1834
+ // Record the choice locally before the round-trip: a catalog refresh landing
1835
+ // while the PATCH is in flight reapplies preferences, and it must see the new
1836
+ // selection rather than the one being replaced.
1837
+ const previousRecord = conversations[convId] || null;
1838
+ if (previousRecord) {
1839
+ conversations[convId] = {
1840
+ ...previousRecord,
1841
+ preferredRelayMode: mode,
1842
+ preferredModel: preferredModelWithTier || previousRecord.preferredModel || '',
1843
+ preferredReasoningEffort: reasoningEffort || previousRecord.preferredReasoningEffort || '',
1844
+ };
1845
+ }
1846
+
1847
+ const writeVersion = ++conversationPreferenceWriteVersion;
1848
+ const response = await updateConversationPreferences(convId, {
1849
+ clientId: CLIENT_ID,
1850
+ preferredRelayMode: mode,
1851
+ preferredModel: preferredModelWithTier,
1852
+ preferredReasoningEffort: reasoningEffort,
1853
+ });
1854
+ if (writeVersion !== conversationPreferenceWriteVersion) return;
1855
+ if (!response) {
1856
+ // The server never took the write, so the optimistic preference has to go
1857
+ // back rather than linger as one the next apply would trust. Only the three
1858
+ // preference fields are restored: the poll rewrites the rest of the record
1859
+ // (messageCount, runtime binding, title) during the round trip.
1860
+ if (previousRecord && conversations[convId]) {
1861
+ conversations[convId] = {
1862
+ ...conversations[convId],
1863
+ preferredRelayMode: previousRecord.preferredRelayMode,
1864
+ preferredModel: previousRecord.preferredModel,
1865
+ preferredReasoningEffort: previousRecord.preferredReasoningEffort,
1866
+ };
1867
+ }
1868
+ return;
1869
+ }
1870
+ if (conversations[convId]) {
1871
+ conversations[convId] = {
1872
+ ...conversations[convId],
1873
+ preferredRelayMode: response.preferredRelayMode,
1874
+ preferredModel: response.preferredModel || conversations[convId].preferredModel || '',
1875
+ preferredReasoningEffort: response.preferredReasoningEffort || conversations[convId].preferredReasoningEffort || '',
1876
+ };
1877
+ }
1878
+ }
1879
+
1880
+ function applyConversationPreferences({
1881
+ preferredRelayMode = '',
1882
+ preferredModel = '',
1883
+ preferredReasoningEffort = '',
1884
+ preferredContextTier = 'default',
1885
+ } = {}) {
1886
+ const modeSelect = document.getElementById('mode-select');
1887
+ const modelSelect = document.getElementById('model-select');
1888
+ if (!modeSelect || !modelSelect) return;
1889
+
1890
+ // Options first, so the preference clamp below sees the provider's modes and
1891
+ // models. Clamping before the model rebuild let the previous conversation's
1892
+ // provider decide which models were "supported" for this one.
1893
+ updateModeSelectorForProvider();
1894
+ rebuildModelSelectorOptionsForProvider();
1895
+ const supportedModes = modeOptions();
1896
+ const supportedModels = modelOptions().length ? modelOptions() : modelCatalogState.models;
1897
+ const selection = resolveConversationComposerSelection({
1898
+ preferredRelayMode,
1899
+ preferredModel,
1900
+ selectedMode: modeSelect.value || localStorage.getItem(MODE_STORAGE_KEY) || FALLBACK_MODE,
1901
+ selectedModel: modelSelect.value || localStorage.getItem(MODEL_STORAGE_KEY) || FALLBACK_MODEL,
1902
+ supportedModes,
1903
+ supportedModels,
1904
+ fallbackMode: FALLBACK_MODE,
1905
+ fallbackModel: FALLBACK_MODEL,
1906
+ });
1907
+ // finally, because the flag also gates every composer change handler: leaking
1908
+ // it as true would silently stop the composer from persisting anything at all
1909
+ // until the page is reloaded.
1910
+ suppressConversationPreferenceSync = true;
1911
+ try {
1912
+ modeSelect.value = selection.mode;
1913
+ if (selection.model) modelSelect.value = selection.model;
1914
+ syncAutoModelAvailability();
1915
+ updateReasoningSelectorForModel(
1916
+ selection.model || modelSelect.value,
1917
+ String(preferredReasoningEffort || '').trim().toLowerCase(),
1918
+ );
1919
+ updateContextTierSelector(selection.model || modelSelect.value);
1920
+ const tierSelect = document.getElementById('context-tier-select');
1921
+ const desiredTier = String(preferredContextTier || 'default').trim().toLowerCase();
1922
+ if (tierSelect && Array.from(tierSelect.options).some((option) => option.value === desiredTier)) {
1923
+ tierSelect.value = desiredTier;
1924
+ updateModelPricingDetails(selection.model || modelSelect.value, tierSelect.value);
1925
+ }
1926
+ } finally {
1927
+ suppressConversationPreferenceSync = false;
1928
+ }
1929
+ // Nothing is written to the shared "last used" storage here. Applying a
1930
+ // conversation's stored preferences is not a choice: it runs on every open,
1931
+ // on the ~1s live poll, on a catalog refresh and on another client's edit,
1932
+ // so persisting the clamp let one conversation's resolution become the
1933
+ // default for the next New Chat. Only persistCurrentConversationPreferences
1934
+ // (a user edit) and a successful bootstrap write those keys.
1935
+ }
1936
+
1937
+ function applyConversationPreferencesForConversation(conversationId, payload = {}) {
1938
+ const convId = String(conversationId || currentConvId || '').trim();
1939
+ const conversation = convId ? conversations[convId] : null;
1940
+ // Unset preferences arrive as '', so each source has to fall through rather
1941
+ // than stop at an empty string the way `??` did.
1942
+ const preferredRelayMode = firstDefinedPreference(
1943
+ payload?.preferredRelayMode,
1944
+ conversation?.preferredRelayMode,
1945
+ localStorage.getItem(MODE_STORAGE_KEY),
1946
+ ) || FALLBACK_MODE;
1947
+ const preferredModel = firstDefinedPreference(
1948
+ payload?.preferredModel,
1949
+ conversation?.preferredModel,
1950
+ localStorage.getItem(MODEL_STORAGE_KEY),
1951
+ );
1952
+ // No localStorage fallback here on purpose: updateReasoningSelectorForModel
1953
+ // already consults the shared "last used" effort, but below the conversation's
1954
+ // own value. Feeding it in as `preferredEffort` would promote it above the
1955
+ // selector's current state and let one conversation's clamp leak into the next.
1956
+ const preferredReasoningEffort = firstDefinedPreference(
1957
+ payload?.preferredReasoningEffort,
1958
+ conversation?.preferredReasoningEffort,
1959
+ );
1960
+ const runtimeModel = firstDefinedPreference(
1961
+ payload?.runtimeModel,
1962
+ conversation?.runtimeProviderModel,
1963
+ conversation?.runtime_provider_model,
1964
+ conversation?.runtimeModel,
1965
+ conversation?.runtime_model,
1966
+ );
1967
+ // The runtime binding only stands in for an unstarted conversation that has
1968
+ // no stored preference. Preferring it over one would let every catalog
1969
+ // refresh revert a model the user changed before sending anything.
1970
+ const effectivePreferredModel = normalizePreferenceValue(
1971
+ preferredModel
1972
+ || (Number(conversation?.messageCount || 0) === 0 ? runtimeModel : ''),
1973
+ );
1974
+ // A stored "[1m]" id decomposes into the base model plus the 1M context tier.
1975
+ const isLongContextModel = CLAUDE_LONG_CONTEXT_PATTERN.test(effectivePreferredModel);
1976
+ applyConversationPreferences({
1977
+ preferredRelayMode,
1978
+ preferredModel: effectivePreferredModel.replace(CLAUDE_LONG_CONTEXT_PATTERN, ''),
1979
+ preferredReasoningEffort,
1980
+ preferredContextTier: isLongContextModel ? 'long_context' : 'default',
1981
+ });
1982
+ }
1983
+
1984
+ function initModelSelector() {
1985
+ const select = document.getElementById('model-select');
1986
+ if (!select) return;
1987
+ if (!select.dataset.bound) {
1988
+ select.dataset.bound = '1';
1989
+ select.addEventListener('change', () => {
1990
+ if (suppressConversationPreferenceSync) return;
1991
+ if (currentConversationHasMessages() && select.value.toLowerCase() === AUTO_MODEL_OPTION) {
1992
+ syncAutoModelAvailability();
1993
+ setModelBanner('⚠️ Auto model selection is available only for a new conversation.');
1994
+ return;
1995
+ }
1996
+ // Carry the current effort across the model change so switching models
1997
+ // does not quietly downgrade a deliberate "high" to the model's default.
1998
+ // The raw value, not selectedReasoningEffortValue(): its 'none' fallback
1999
+ // for an empty selector would overwrite a remembered effort.
2000
+ updateReasoningSelectorForModel(
2001
+ select.value,
2002
+ String(document.getElementById('reasoning-effort-select')?.value || '').trim().toLowerCase(),
2003
+ { persist: true },
2004
+ );
2005
+ updateContextTierSelector(select.value);
2006
+ syncSessionLockNote({ pinnedModel: currentRuntimeModelLock()?.model || '' });
2007
+ refreshComposerAttachmentWarning();
2008
+ void persistCurrentConversationPreferences().catch(() => {});
2009
+ });
2010
+ select.addEventListener('blur', () => {
2011
+ if (!deferredModelCatalogPayload) return;
2012
+ const payload = deferredModelCatalogPayload;
2013
+ deferredModelCatalogPayload = null;
2014
+ updateModelCatalogState(payload);
2015
+ });
2016
+ }
2017
+
2018
+ }
2019
+
2020
+ function initContextTierSelector() {
2021
+ const select = document.getElementById('context-tier-select');
2022
+ if (!select || select.dataset.bound === '1') return;
2023
+ select.dataset.bound = '1';
2024
+ select.addEventListener('change', () => {
2025
+ updateModelPricingDetails(selectedModelValue(), select.value);
2026
+ if (suppressConversationPreferenceSync) return;
2027
+ void persistCurrentConversationPreferences().catch(() => {});
2028
+ });
2029
+ }
2030
+
2031
+ function initReasoningSelector() {
2032
+ const select = document.getElementById('reasoning-effort-select');
2033
+ if (!select || select.dataset.bound === '1') return;
2034
+ select.dataset.bound = '1';
2035
+ select.addEventListener('change', () => {
2036
+ if (suppressConversationPreferenceSync) return;
2037
+ void persistCurrentConversationPreferences().catch(() => {});
2038
+ });
2039
+ }
2040
+
2041
+ function initModeSelector() {
2042
+ const select = document.getElementById('mode-select');
2043
+ if (!select) return;
2044
+ const saved = localStorage.getItem(MODE_STORAGE_KEY);
2045
+ const available = Array.from(select.options).map(o => o.value);
2046
+ if (saved && available.includes(saved)) {
2047
+ select.value = saved;
2048
+ } else if (!saved && available.includes(FALLBACK_MODE)) {
2049
+ select.value = FALLBACK_MODE;
2050
+ }
2051
+ if (select.dataset.bound === '1') return;
2052
+ select.dataset.bound = '1';
2053
+ select.addEventListener('change', () => {
2054
+ if (suppressConversationPreferenceSync) return;
2055
+ void persistCurrentConversationPreferences().catch(() => {});
2056
+ });
2057
+ }
2058
+
2059
+ function refreshModelCatalog(force = false) {
2060
+ return loadModelCatalog().then((r) => {
2061
+ if (!r) {
2062
+ if (isModelMetadataHealthy(modelCatalogState) || isModelMetadataHealthy(lastHealthyModelCatalogState)) {
2063
+ setModelBanner('⚠️ Could not refresh model metadata; using last known good catalog.');
2064
+ syncModelMetadataBlocker();
2065
+ return null;
2066
+ }
2067
+ applyModelMetadataHardFail(force
2068
+ ? 'Could not refresh live model metadata.'
2069
+ : 'Could not load model metadata.');
2070
+ return null;
2071
+ }
2072
+ updateModelCatalogState(r);
2073
+ return r;
2074
+ });
2075
+ }
2076
+
2077
+ function reportOpenAIModelDiscoveryFailure(payload) {
2078
+ const discovery = payload?.openAIModelDiscovery;
2079
+ if (discovery && discovery.ok === false) {
2080
+ showTransientRelayNotice(
2081
+ `GitHub models refreshed, but OpenAI model discovery failed. Cached OpenAI models were kept: ${discovery.error || 'unknown error'}`,
2082
+ 8000,
2083
+ );
2084
+ }
2085
+ const claudeDiscovery = payload?.claudeModelDiscovery;
2086
+ if (claudeDiscovery && claudeDiscovery.ok === false && !claudeDiscovery.skipped) {
2087
+ showTransientRelayNotice(
2088
+ `Claude model discovery failed. Cached Claude models were kept: ${claudeDiscovery.error || 'unknown error'}`,
2089
+ 8000,
2090
+ );
2091
+ }
2092
+ const cursorDiscovery = payload?.cursorModelDiscovery;
2093
+ if (cursorDiscovery && cursorDiscovery.ok === false && !cursorDiscovery.skipped) {
2094
+ showTransientRelayNotice(
2095
+ `Cursor model discovery failed. Cached Cursor models were kept: ${cursorDiscovery.error || 'unknown error'}`,
2096
+ 8000,
2097
+ );
2098
+ }
2099
+ const grokDiscovery = payload?.grokModelDiscovery;
2100
+ if (grokDiscovery && grokDiscovery.ok === false && !grokDiscovery.skipped) {
2101
+ showTransientRelayNotice(
2102
+ `Grok model discovery failed. Cached Grok models were kept: ${grokDiscovery.error || 'unknown error'}`,
2103
+ 8000,
2104
+ );
2105
+ }
2106
+ }
2107
+
2108
+ async function retryModelMetadataRefresh() {
2109
+ if (modelMetadataRetryInFlight) return;
2110
+ modelMetadataRetryInFlight = true;
2111
+ syncModelMetadataBlocker('Refreshing model metadata…');
2112
+ try {
2113
+ let variantError = null;
2114
+ try {
2115
+ const refreshed = await refreshModelVariantCatalog();
2116
+ if (!refreshed) variantError = new Error('Model variant refresh returned empty');
2117
+ reportOpenAIModelDiscoveryFailure(refreshed);
2118
+ } catch (e) {
2119
+ variantError = e;
2120
+ }
2121
+ await refreshModelCatalog(true);
2122
+ if (variantError) console.warn('[retryModelMetadataRefresh] variant refresh issue:', variantError.message);
2123
+ if (modelCatalogState?.refreshedAt) console.log('[retryModelMetadataRefresh] refreshedAt:', modelCatalogState.refreshedAt);
2124
+ if (!isModelMetadataHealthy()) {
2125
+ throw new Error('Model metadata is still unavailable after refresh');
2126
+ }
2127
+ showTransientRelayNotice('Model metadata refreshed.');
2128
+ } catch (error) {
2129
+ applyModelMetadataHardFail(error?.message || 'Model metadata refresh failed.');
2130
+ } finally {
2131
+ modelMetadataRetryInFlight = false;
2132
+ syncModelMetadataBlocker();
2133
+ }
2134
+ }
2135
+
2136
+ function applyModelVariantCatalogState(payload) {
2137
+ const rawVariants = Array.isArray(payload?.variants)
2138
+ ? payload.variants.map((entry) => ({
2139
+ variantId: String(entry?.variantId || '').trim(),
2140
+ baseModelId: String(entry?.baseModelId || '').trim(),
2141
+ provider: String(entry?.provider || 'other').trim().toLowerCase() || 'other',
2142
+ label: String(entry?.label || '').trim(),
2143
+ releaseStatus: String(entry?.releaseStatus || '').trim().toLowerCase() || null,
2144
+ reasoningEffort: String(entry?.reasoningEffort || '').trim().toLowerCase() || null,
2145
+ selectable: entry?.selectable !== false,
2146
+ enabled: !!entry?.enabled,
2147
+ sortOrder: Number.isFinite(Number(entry?.sortOrder)) ? Math.max(0, Math.trunc(Number(entry.sortOrder))) : 0,
2148
+ })).filter((entry) => entry.variantId && entry.baseModelId)
2149
+ : [];
2150
+ const canonicalizeId = (value) => String(value || '').trim().toLowerCase();
2151
+ const dedupedVariantsMap = new Map();
2152
+ for (const entry of rawVariants) {
2153
+ const dedupeKey = canonicalizeId(entry.variantId);
2154
+ const existing = dedupedVariantsMap.get(dedupeKey);
2155
+ if (!existing) {
2156
+ dedupedVariantsMap.set(dedupeKey, {
2157
+ ...entry,
2158
+ variantId: dedupeKey,
2159
+ baseModelId: canonicalizeId(entry.baseModelId),
2160
+ });
2161
+ continue;
2162
+ }
2163
+ dedupedVariantsMap.set(dedupeKey, {
2164
+ ...existing,
2165
+ enabled: existing.enabled || entry.enabled,
2166
+ releaseStatus: (existing.releaseStatus === null || entry.releaseStatus === null)
2167
+ ? null
2168
+ : (existing.releaseStatus || entry.releaseStatus),
2169
+ sortOrder: Math.min(existing.sortOrder, entry.sortOrder),
2170
+ label: existing.label || entry.label,
2171
+ provider: existing.provider || entry.provider,
2172
+ });
2173
+ }
2174
+ const variants = Array.from(dedupedVariantsMap.values());
2175
+ const requestedEnabledVariantIds = Array.isArray(payload?.enabledVariantIds)
2176
+ ? payload.enabledVariantIds.map((value) => canonicalizeId(value)).filter(Boolean)
2177
+ : variants.filter((entry) => entry.enabled).map((entry) => entry.variantId);
2178
+ const enabledVariantIds = new Set(requestedEnabledVariantIds.filter((value) => dedupedVariantsMap.has(value)));
2179
+ modelVariantCatalogState = {
2180
+ variants,
2181
+ enabledVariantIds: Array.from(enabledVariantIds),
2182
+ reasoningByModel: payload?.reasoningByModel && typeof payload.reasoningByModel === 'object'
2183
+ ? Object.fromEntries(Object.entries(payload.reasoningByModel).map(([modelId, efforts]) => [
2184
+ String(modelId || '').trim().toLowerCase(),
2185
+ normalizeReasoningEffortList(efforts),
2186
+ ]))
2187
+ : {},
2188
+ source: String(payload?.source || '').trim() || null,
2189
+ refreshedAt: payload?.refreshedAt || null,
2190
+ warning: payload?.warning ? String(payload.warning) : null,
2191
+ error: payload?.error ? String(payload.error) : null,
2192
+ reasoningEfforts: Array.isArray(payload?.reasoningEfforts)
2193
+ ? payload.reasoningEfforts.map((value) => String(value || '').trim().toLowerCase()).filter(Boolean)
2194
+ : [],
2195
+ claudeModels: payload?.claudeModels && typeof payload.claudeModels === 'object'
2196
+ ? {
2197
+ defaultModel: String(payload.claudeModels.defaultModel || '').trim(),
2198
+ availableModels: Array.isArray(payload.claudeModels.availableModels)
2199
+ ? payload.claudeModels.availableModels.map((value) => String(value || '').trim()).filter(Boolean)
2200
+ : [],
2201
+ enabledModels: Array.isArray(payload.claudeModels.enabledModels)
2202
+ ? payload.claudeModels.enabledModels.map((value) => String(value || '').trim()).filter(Boolean)
2203
+ : [],
2204
+ }
2205
+ : null,
2206
+ cursorModels: payload?.cursorModels && typeof payload.cursorModels === 'object'
2207
+ ? {
2208
+ defaultModel: String(payload.cursorModels.defaultModel || '').trim(),
2209
+ availableModels: Array.isArray(payload.cursorModels.availableModels)
2210
+ ? payload.cursorModels.availableModels.map((value) => String(value || '').trim()).filter(Boolean)
2211
+ : [],
2212
+ enabledModels: Array.isArray(payload.cursorModels.enabledModels)
2213
+ ? payload.cursorModels.enabledModels.map((value) => String(value || '').trim()).filter(Boolean)
2214
+ : [],
2215
+ }
2216
+ : null,
2217
+ grokModels: payload?.grokModels && typeof payload.grokModels === 'object'
2218
+ ? {
2219
+ defaultModel: String(payload.grokModels.defaultModel || '').trim(),
2220
+ availableModels: Array.isArray(payload.grokModels.availableModels)
2221
+ ? payload.grokModels.availableModels.map((value) => String(value || '').trim()).filter(Boolean)
2222
+ : [],
2223
+ enabledModels: Array.isArray(payload.grokModels.enabledModels)
2224
+ ? payload.grokModels.enabledModels.map((value) => String(value || '').trim()).filter(Boolean)
2225
+ : [],
2226
+ }
2227
+ : null,
2228
+ };
2229
+ }
2230
+
2231
+ function buildReasoningEffortsByBaseModel(variants = [], reasoningByModel = {}) {
2232
+ const map = new Map();
2233
+ for (const entry of variants) {
2234
+ const baseModelId = String(entry.baseModelId || '').trim().toLowerCase();
2235
+ if (!baseModelId) continue;
2236
+ if (!map.has(baseModelId)) {
2237
+ map.set(baseModelId, {
2238
+ label: entry.label,
2239
+ efforts: new Set(),
2240
+ });
2241
+ }
2242
+ const effort = String(entry.reasoningEffort || '').trim().toLowerCase();
2243
+ if (effort) map.get(baseModelId).efforts.add(effort);
2244
+ }
2245
+ for (const [baseModelId, efforts] of Object.entries(reasoningByModel || {})) {
2246
+ const key = String(baseModelId || '').trim().toLowerCase();
2247
+ if (!key) continue;
2248
+ if (!map.has(key)) {
2249
+ map.set(key, { label: humanizeModelLabel(key), efforts: new Set() });
2250
+ }
2251
+ for (const effort of Array.isArray(efforts) ? efforts : []) {
2252
+ const normalized = String(effort || '').trim().toLowerCase();
2253
+ if (normalized) map.get(key).efforts.add(normalized);
2254
+ }
2255
+ }
2256
+ return map;
2257
+ }
2258
+
2259
+ function renderReasoningEffortChipRow(efforts = []) {
2260
+ const list = Array.from(efforts).sort();
2261
+ if (!list.length) {
2262
+ return '<span class="model-reasoning-chip model-reasoning-chip-muted">standard</span>';
2263
+ }
2264
+ return list.map((effort) => `<span class="model-reasoning-chip">${escHtml(effort)}</span>`).join('');
2265
+ }
2266
+
2267
+ async function reconcileOpenModelVariantModal() {
2268
+ const modal = document.getElementById('summary-modal');
2269
+ if (!modal?.classList.contains('visible')) return;
2270
+ if (summaryModalState.kind !== 'select-models') return;
2271
+ const payload = await loadModelVariantCatalog();
2272
+ if (!payload) return;
2273
+ applyModelVariantCatalogState(payload);
2274
+ renderModelVariantCatalogBody();
2275
+ }
2276
+
2277
+ function renderModelVariantCatalogBody() {
2278
+ const providersByModel = modelCatalogState.providersByModel || {};
2279
+ const variantBelongsToTab = (entry, tab) => {
2280
+ const activeTab = String(tab || 'copilot').trim().toLowerCase();
2281
+ const baseModelId = String(entry?.baseModelId || '').trim().toLowerCase();
2282
+ const entryProvider = String(entry?.provider || '').trim().toLowerCase();
2283
+ const providers = modelProvidersForId(baseModelId, providersByModel);
2284
+ const hasOpenAIByok = providers.includes('openai-byok');
2285
+ // Each tab lists only rows sourced from that runtime: the Claude SDK tab
2286
+ // shows Claude-SDK rows exclusively, the Cursor SDK tab Cursor rows, the
2287
+ // Grok tab Grok rows, and Copilot never shows rows that a different runtime
2288
+ // contributed (there is no cross-runtime switching).
2289
+ if (activeTab === 'anthropic') {
2290
+ return entryProvider === 'claude';
2291
+ }
2292
+ if (entryProvider === 'claude') return false;
2293
+ if (activeTab === 'cursor') {
2294
+ return entryProvider === 'cursor';
2295
+ }
2296
+ if (entryProvider === 'cursor') return false;
2297
+ if (activeTab === 'grok') {
2298
+ return entryProvider === 'grok';
2299
+ }
2300
+ if (entryProvider === 'grok') return false;
2301
+ if (activeTab === 'openai') {
2302
+ return hasOpenAIByok || entryProvider === 'openai-byok';
2303
+ }
2304
+ // Copilot tab: only models the Copilot CLI itself serves.
2305
+ if (entryProvider === 'openai-byok') return false;
2306
+ if (hasOpenAIByok) {
2307
+ return providers.some((provider) => (
2308
+ provider !== 'openai-byok'
2309
+ && provider !== 'claude'
2310
+ && provider !== 'cursor'
2311
+ && provider !== 'grok'
2312
+ ));
2313
+ }
2314
+ return true;
2315
+ };
2316
+ const grouped = new Map();
2317
+ for (const entry of modelVariantCatalogState.variants) {
2318
+ const providerKey = String(entry.provider || 'other').trim().toLowerCase() || 'other';
2319
+ if (!grouped.has(providerKey)) grouped.set(providerKey, []);
2320
+ grouped.get(providerKey).push(entry);
2321
+ }
2322
+ const openAIBaseModelsAlreadyListed = new Set(
2323
+ modelVariantCatalogState.variants
2324
+ .filter((entry) => variantBelongsToTab(entry, 'openai'))
2325
+ .map((entry) => String(entry.baseModelId || '').trim().toLowerCase())
2326
+ .filter(Boolean),
2327
+ );
2328
+ for (const [modelId, providers] of Object.entries(modelCatalogState.providersByModel || {})) {
2329
+ const baseModelId = String(modelId || '').trim().toLowerCase();
2330
+ if (!baseModelId) continue;
2331
+ const providerList = Array.isArray(providers) ? providers : [];
2332
+ if (!providerList.includes('openai-byok')) continue;
2333
+ if (openAIBaseModelsAlreadyListed.has(baseModelId)) continue;
2334
+ const providerRows = grouped.get('openai-byok') || [];
2335
+ providerRows.push({
2336
+ variantId: `${baseModelId}--provider-openai-byok`,
2337
+ baseModelId,
2338
+ provider: 'openai-byok',
2339
+ label: humanizeModelLabel(baseModelId),
2340
+ releaseStatus: null,
2341
+ reasoningEffort: null,
2342
+ selectable: false,
2343
+ enabled: false,
2344
+ sortOrder: Number.MAX_SAFE_INTEGER,
2345
+ });
2346
+ grouped.set('openai-byok', providerRows);
2347
+ openAIBaseModelsAlreadyListed.add(baseModelId);
2348
+ }
2349
+ const claudeCatalog = modelVariantCatalogState.claudeModels;
2350
+ if (claudeCatalog?.availableModels?.length) {
2351
+ const claudeEnabledSet = new Set(claudeCatalog.enabledModels || []);
2352
+ const claudeDefaultModel = String(claudeCatalog.defaultModel || '').trim();
2353
+ const providerRows = [];
2354
+ for (const [index, claudeModelId] of claudeCatalog.availableModels.entries()) {
2355
+ const isDefaultModel = claudeModelId === claudeDefaultModel;
2356
+ providerRows.push({
2357
+ variantId: `${claudeModelId}--provider-claude`,
2358
+ baseModelId: claudeModelId,
2359
+ provider: 'claude',
2360
+ label: humanizeModelLabel(claudeModelId),
2361
+ releaseStatus: null,
2362
+ reasoningEffort: null,
2363
+ // The default model always stays enabled; deselecting it would leave
2364
+ // Claude conversations without a model.
2365
+ selectable: !isDefaultModel,
2366
+ enabled: claudeEnabledSet.has(claudeModelId) || isDefaultModel,
2367
+ claudeModelId,
2368
+ sortOrder: index,
2369
+ });
2370
+ }
2371
+ grouped.set('claude', providerRows);
2372
+ }
2373
+ const cursorCatalog = modelVariantCatalogState.cursorModels;
2374
+ if (cursorCatalog?.availableModels?.length) {
2375
+ const cursorEnabledSet = new Set(cursorCatalog.enabledModels || []);
2376
+ const cursorDefaultModel = String(cursorCatalog.defaultModel || '').trim();
2377
+ const providerRows = [];
2378
+ for (const [index, cursorModelId] of cursorCatalog.availableModels.entries()) {
2379
+ const isDefaultModel = cursorModelId === cursorDefaultModel;
2380
+ providerRows.push({
2381
+ variantId: `${cursorModelId}--provider-cursor`,
2382
+ baseModelId: cursorModelId,
2383
+ provider: 'cursor',
2384
+ label: humanizeModelLabel(cursorModelId),
2385
+ releaseStatus: null,
2386
+ reasoningEffort: null,
2387
+ // The default model always stays enabled; deselecting it would leave
2388
+ // Cursor conversations without a model.
2389
+ selectable: !isDefaultModel,
2390
+ enabled: cursorEnabledSet.has(cursorModelId) || isDefaultModel,
2391
+ cursorModelId,
2392
+ sortOrder: index,
2393
+ });
2394
+ }
2395
+ grouped.set('cursor', providerRows);
2396
+ }
2397
+ const grokCatalog = modelVariantCatalogState.grokModels;
2398
+ if (grokCatalog?.availableModels?.length) {
2399
+ const grokEnabledSet = new Set(grokCatalog.enabledModels || []);
2400
+ const grokDefaultModel = String(grokCatalog.defaultModel || '').trim();
2401
+ const providerRows = [];
2402
+ for (const [index, grokModelId] of grokCatalog.availableModels.entries()) {
2403
+ const isDefaultModel = grokModelId === grokDefaultModel;
2404
+ providerRows.push({
2405
+ variantId: `${grokModelId}--provider-grok`,
2406
+ baseModelId: grokModelId,
2407
+ provider: 'grok',
2408
+ label: humanizeModelLabel(grokModelId),
2409
+ releaseStatus: null,
2410
+ reasoningEffort: null,
2411
+ // The default model always stays enabled; deselecting it would leave
2412
+ // Grok conversations without a model.
2413
+ selectable: !isDefaultModel,
2414
+ enabled: grokEnabledSet.has(grokModelId) || isDefaultModel,
2415
+ grokModelId,
2416
+ sortOrder: index,
2417
+ });
2418
+ }
2419
+ grouped.set('grok', providerRows);
2420
+ }
2421
+ const selected = new Set(modelVariantCatalogState.enabledVariantIds);
2422
+ const selectedOrder = new Map(
2423
+ modelVariantCatalogState.enabledVariantIds.map((variantId, index) => [variantId, index]),
2424
+ );
2425
+ const providerSortMeta = (providerKey) => {
2426
+ const rows = grouped.get(providerKey) || [];
2427
+ const selectedPositions = rows
2428
+ .filter((row) => selected.has(row.variantId))
2429
+ .map((row) => selectedOrder.get(row.variantId))
2430
+ .filter((value) => Number.isFinite(value));
2431
+ const hasSelected = selectedPositions.length > 0;
2432
+ const firstSelectedPos = hasSelected ? Math.min(...selectedPositions) : Number.POSITIVE_INFINITY;
2433
+ return { hasSelected, firstSelectedPos };
2434
+ };
2435
+ const providerOrder = Array.from(grouped.keys()).sort((a, b) => {
2436
+ const aMeta = providerSortMeta(a);
2437
+ const bMeta = providerSortMeta(b);
2438
+ if (aMeta.hasSelected !== bMeta.hasSelected) return aMeta.hasSelected ? -1 : 1;
2439
+ if (aMeta.firstSelectedPos !== bMeta.firstSelectedPos) return aMeta.firstSelectedPos - bMeta.firstSelectedPos;
2440
+ const aLabel = PROVIDER_LABELS[a] || a;
2441
+ const bLabel = PROVIDER_LABELS[b] || b;
2442
+ return aLabel.localeCompare(bLabel);
2443
+ });
2444
+ const refreshedLabel = modelVariantCatalogState.refreshedAt
2445
+ ? new Date(modelVariantCatalogState.refreshedAt).toLocaleString()
2446
+ : 'Never';
2447
+ const providerBelongsToTab = (providerKey, tab) => (
2448
+ (grouped.get(String(providerKey || '').trim().toLowerCase()) || [])
2449
+ .some((entry) => variantBelongsToTab(entry, tab))
2450
+ );
2451
+ const hasOpenAITab = providerOrder.some((providerKey) => providerBelongsToTab(providerKey, 'openai'));
2452
+ const hasCopilotTab = providerOrder.some((providerKey) => providerBelongsToTab(providerKey, 'copilot'));
2453
+ const hasAnthropicTab = providerOrder.some((providerKey) => providerBelongsToTab(providerKey, 'anthropic'));
2454
+ const hasCursorTab = providerOrder.some((providerKey) => providerBelongsToTab(providerKey, 'cursor'));
2455
+ const hasGrokTab = providerOrder.some((providerKey) => providerBelongsToTab(providerKey, 'grok'));
2456
+ if (modelVariantCatalogProviderTab === 'openai' && !hasOpenAITab) modelVariantCatalogProviderTab = 'copilot';
2457
+ if (modelVariantCatalogProviderTab === 'anthropic' && !hasAnthropicTab) modelVariantCatalogProviderTab = 'copilot';
2458
+ if (modelVariantCatalogProviderTab === 'cursor' && !hasCursorTab) modelVariantCatalogProviderTab = 'copilot';
2459
+ if (modelVariantCatalogProviderTab === 'grok' && !hasGrokTab) modelVariantCatalogProviderTab = 'copilot';
2460
+ if (modelVariantCatalogProviderTab === 'copilot' && !hasCopilotTab) {
2461
+ if (hasOpenAITab) modelVariantCatalogProviderTab = 'openai';
2462
+ else if (hasAnthropicTab) modelVariantCatalogProviderTab = 'anthropic';
2463
+ else if (hasCursorTab) modelVariantCatalogProviderTab = 'cursor';
2464
+ else if (hasGrokTab) modelVariantCatalogProviderTab = 'grok';
2465
+ }
2466
+ const visibleProviderOrder = providerOrder.filter((providerKey) => providerBelongsToTab(providerKey, modelVariantCatalogProviderTab));
2467
+ const providerTabButtons = `
2468
+ <div class="summary-tab-strip" style="display:flex;gap:8px;align-items:center;">
2469
+ <button type="button" class="summary-btn model-provider-tab${modelVariantCatalogProviderTab === 'copilot' ? ' active' : ''}" data-model-provider-tab="copilot">Copilot</button>
2470
+ <button type="button" class="summary-btn model-provider-tab${modelVariantCatalogProviderTab === 'openai' ? ' active' : ''}" data-model-provider-tab="openai" ${hasOpenAITab ? '' : 'disabled'}>OpenAI</button>
2471
+ <button type="button" class="summary-btn model-provider-tab${modelVariantCatalogProviderTab === 'anthropic' ? ' active' : ''}" data-model-provider-tab="anthropic" ${hasAnthropicTab ? '' : 'disabled'}>Claude SDK</button>
2472
+ <button type="button" class="summary-btn model-provider-tab${modelVariantCatalogProviderTab === 'cursor' ? ' active' : ''}" data-model-provider-tab="cursor" ${hasCursorTab ? '' : 'disabled'}>Cursor SDK</button>
2473
+ <button type="button" class="summary-btn model-provider-tab${modelVariantCatalogProviderTab === 'grok' ? ' active' : ''}" data-model-provider-tab="grok" ${hasGrokTab ? '' : 'disabled'}>Grok</button>
2474
+ </div>
2475
+ `;
2476
+ const warnings = [
2477
+ modelVariantCatalogState.warning ? `⚠️ ${escHtml(modelVariantCatalogState.warning)}` : '',
2478
+ modelVariantCatalogState.error ? `⚠️ ${escHtml(modelVariantCatalogState.error)}` : '',
2479
+ ].filter(Boolean);
2480
+ const reasoningByBaseModel = buildReasoningEffortsByBaseModel(
2481
+ modelVariantCatalogState.variants,
2482
+ modelVariantCatalogState.reasoningByModel,
2483
+ );
2484
+ const groupsHtml = visibleProviderOrder.map((providerKey) => {
2485
+ const providerLabel = PROVIDER_LABELS[providerKey] || providerKey;
2486
+ const rows = (grouped.get(providerKey) || [])
2487
+ .filter((entry) => variantBelongsToTab(entry, modelVariantCatalogProviderTab));
2488
+ rows.sort((a, b) => {
2489
+ const aSelected = selected.has(a.variantId);
2490
+ const bSelected = selected.has(b.variantId);
2491
+ if (aSelected !== bSelected) return aSelected ? -1 : 1;
2492
+
2493
+ const aSelectedPos = selectedOrder.has(a.variantId)
2494
+ ? selectedOrder.get(a.variantId)
2495
+ : Number.POSITIVE_INFINITY;
2496
+ const bSelectedPos = selectedOrder.has(b.variantId)
2497
+ ? selectedOrder.get(b.variantId)
2498
+ : Number.POSITIVE_INFINITY;
2499
+ if (aSelectedPos !== bSelectedPos) return aSelectedPos - bSelectedPos;
2500
+
2501
+ if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder;
2502
+ return a.variantId.localeCompare(b.variantId);
2503
+ });
2504
+ const byBaseModel = new Map();
2505
+ for (const row of rows) {
2506
+ const baseModelId = String(row.baseModelId || '').trim().toLowerCase();
2507
+ if (!byBaseModel.has(baseModelId)) byBaseModel.set(baseModelId, []);
2508
+ byBaseModel.get(baseModelId).push(row);
2509
+ }
2510
+ const baseModelBlocks = Array.from(byBaseModel.entries()).map(([baseModelId, variantRows]) => {
2511
+ const providerEfforts = reasoningOptionsForProviderModel(providerKey, baseModelId);
2512
+ const fallbackMeta = reasoningByBaseModel.get(baseModelId) || { label: humanizeModelLabel(baseModelId), efforts: new Set() };
2513
+ const meta = providerEfforts.length
2514
+ ? { ...fallbackMeta, efforts: new Set(providerEfforts) }
2515
+ : fallbackMeta;
2516
+ const label = meta.label || humanizeModelLabel(baseModelId) || baseModelId;
2517
+ const variantRowsHtml = variantRows.map((row) => {
2518
+ const selectable = row.selectable !== false;
2519
+ // SDK-provider rows (Claude/Cursor/Grok) carry their own enabled flag; only
2520
+ // Copilot variant rows read the enabledVariantIds selection.
2521
+ const sdkProviderModelId = row.claudeModelId || row.cursorModelId || row.grokModelId || '';
2522
+ const checked = sdkProviderModelId
2523
+ ? row.enabled === true
2524
+ : (selectable && selected.has(row.variantId));
2525
+ const effortChip = row.reasoningEffort
2526
+ ? ` <span class="model-reasoning-chip">${escHtml(row.reasoningEffort)}</span>`
2527
+ : '';
2528
+ const unavailable = row.releaseStatus === 'unavailable';
2529
+ const statusChip = unavailable
2530
+ ? ' <span class="model-reasoning-chip model-reasoning-chip-warn">unavailable</span>'
2531
+ : '';
2532
+ const fixedChip = !selectable
2533
+ ? (sdkProviderModelId
2534
+ ? ' <span class="model-reasoning-chip">default model</span>'
2535
+ : ' <span class="model-reasoning-chip">managed in settings</span>')
2536
+ : '';
2537
+ const sdkModelAttr = row.claudeModelId
2538
+ ? ` data-claude-model="${escHtml(row.claudeModelId)}"`
2539
+ : (row.cursorModelId
2540
+ ? ` data-cursor-model="${escHtml(row.cursorModelId)}"`
2541
+ : (row.grokModelId ? ` data-grok-model="${escHtml(row.grokModelId)}"` : ''));
2542
+ return `
2543
+ <label class="model-variant-row">
2544
+ <input class="model-variant-checkbox" type="checkbox" data-selectable="${selectable ? '1' : '0'}" data-variant-id="${escHtml(row.variantId)}"${sdkModelAttr} ${checked ? 'checked' : ''} ${selectable ? '' : 'disabled'}>
2545
+ <span class="model-variant-row-copy">
2546
+ <span class="model-variant-row-title">${escHtml(row.variantId)}${effortChip}${statusChip}${fixedChip}</span>
2547
+ </span>
2548
+ </label>
2549
+ `;
2550
+ }).join('');
2551
+ return `
2552
+ <div class="model-base-group">
2553
+ <div class="model-base-header">
2554
+ <div class="model-base-title">${escHtml(label)}</div>
2555
+ <div class="model-base-reasoning">
2556
+ <span class="model-base-reasoning-label">Reasoning</span>
2557
+ ${renderReasoningEffortChipRow(meta.efforts)}
2558
+ </div>
2559
+ <code class="model-base-id">${escHtml(baseModelId)}</code>
2560
+ </div>
2561
+ <div class="model-variant-list">${variantRowsHtml}</div>
2562
+ </div>
2563
+ `;
2564
+ }).join('');
2565
+ return `
2566
+ <section class="model-provider-group">
2567
+ <div class="model-provider-title">${escHtml(providerLabel)}</div>
2568
+ ${baseModelBlocks || '<div class="model-provider-empty">No models</div>'}
2569
+ </section>
2570
+ `;
2571
+ }).join('');
2572
+ const bodyHtml = `
2573
+ <div style="display:flex;flex-direction:column;gap:10px">
2574
+ ${providerTabButtons}
2575
+ <div style="font-size:0.8rem;color:var(--muted)">
2576
+ Saved globally for this relay. Refreshed: <strong>${escHtml(refreshedLabel)}</strong>
2577
+ </div>
2578
+ <div style="font-size:0.78rem;color:var(--muted)">
2579
+ Variants marked unavailable are preserved from earlier selections so updates do not reset your models.
2580
+ </div>
2581
+ <div style="font-size:0.78rem;color:var(--muted)">
2582
+ Each model shows all supported reasoning efforts above its selectable variants.
2583
+ </div>
2584
+ ${warnings.map((line) => `<div style="font-size:0.78rem;color:var(--warn,#f7c873)">${line}</div>`).join('')}
2585
+ <div style="display:grid;gap:10px">${groupsHtml || '<div style="color:var(--muted)">No models discovered yet. Click Refresh.</div>'}</div>
2586
+ </div>
2587
+ `;
2588
+ renderSummaryModalContent({
2589
+ title: '🤗 Select Models',
2590
+ subtitle: 'Choose model variants shown in the composer selector',
2591
+ bodyHtml,
2592
+ refresh: async () => {
2593
+ const refreshed = await refreshModelVariantCatalog();
2594
+ if (!refreshed) throw new Error('Failed to refresh model variants');
2595
+ reportOpenAIModelDiscoveryFailure(refreshed);
2596
+ applyModelVariantCatalogState(refreshed);
2597
+ renderModelVariantCatalogBody();
2598
+ await refreshModelCatalog(true);
2599
+ },
2600
+ kind: 'select-models',
2601
+ });
2602
+ const headerActions = document.querySelector('#summary-modal .summary-header-actions');
2603
+ const refreshBtn = document.getElementById('summary-modal-refresh');
2604
+ if (headerActions && refreshBtn && !document.getElementById('summary-modal-save-models')) {
2605
+ const saveBtn = document.createElement('button');
2606
+ saveBtn.id = 'summary-modal-save-models';
2607
+ saveBtn.className = 'summary-btn';
2608
+ saveBtn.type = 'button';
2609
+ saveBtn.textContent = '💾 Save enabled models';
2610
+ saveBtn.onclick = () => saveSelectedModelsFromModal();
2611
+ headerActions.insertBefore(saveBtn, refreshBtn);
2612
+ }
2613
+ for (const button of Array.from(document.querySelectorAll('[data-model-provider-tab]'))) {
2614
+ button.onclick = () => {
2615
+ const nextTab = String(button.dataset.modelProviderTab || '').trim().toLowerCase();
2616
+ if (!nextTab || nextTab === modelVariantCatalogProviderTab) return;
2617
+ modelVariantCatalogProviderTab = nextTab;
2618
+ renderModelVariantCatalogBody();
2619
+ };
2620
+ }
2621
+ }
2622
+
2623
+ async function openSelectModelsModal() {
2624
+ modelVariantCatalogProviderTab = 'copilot';
2625
+ openSummaryModal({
2626
+ title: '🤗 Select Models',
2627
+ subtitle: 'Loading…',
2628
+ bodyHtml: '<div class="summary-loading">Loading saved model variants…</div>',
2629
+ kind: 'select-models',
2630
+ });
2631
+ setSummaryModalLoading(true);
2632
+ try {
2633
+ const payload = await loadModelVariantCatalog();
2634
+ if (!payload) throw new Error('Failed to load model variants');
2635
+ applyModelVariantCatalogState(payload);
2636
+ renderModelVariantCatalogBody();
2637
+ } catch (error) {
2638
+ renderSummaryModalContent({
2639
+ title: '🤗 Select Models',
2640
+ subtitle: 'Unable to load',
2641
+ bodyHtml: `<div class="summary-error">Failed to load model variants: ${escHtml(error?.message || 'Unknown error')}</div>`,
2642
+ kind: 'select-models',
2643
+ });
2644
+ } finally {
2645
+ setSummaryModalLoading(false);
2646
+ }
2647
+ }
2648
+
2649
+ async function saveSelectedModelsFromModal() {
2650
+ const body = document.getElementById('summary-modal-body');
2651
+ if (!body) return;
2652
+ const claudeCheckboxes = Array.from(body.querySelectorAll('.model-variant-checkbox[data-claude-model]'));
2653
+ const selectedClaudeModels = claudeCheckboxes
2654
+ .filter((input) => input.checked)
2655
+ .map((input) => String(input.getAttribute('data-claude-model') || '').trim())
2656
+ .filter(Boolean);
2657
+ const cursorCheckboxes = Array.from(body.querySelectorAll('.model-variant-checkbox[data-cursor-model]'));
2658
+ const selectedCursorModels = cursorCheckboxes
2659
+ .filter((input) => input.checked)
2660
+ .map((input) => String(input.getAttribute('data-cursor-model') || '').trim())
2661
+ .filter(Boolean);
2662
+ const grokCheckboxes = Array.from(body.querySelectorAll('.model-variant-checkbox[data-grok-model]'));
2663
+ const selectedGrokModels = grokCheckboxes
2664
+ .filter((input) => input.checked)
2665
+ .map((input) => String(input.getAttribute('data-grok-model') || '').trim())
2666
+ .filter(Boolean);
2667
+ // Only the active provider tab's rows are in the DOM, but the PATCH
2668
+ // replaces the WHOLE enabled set — so the save must only change rows the
2669
+ // user could actually see. Off-screen variants keep their stored state,
2670
+ // otherwise saving from e.g. the OpenAI tab silently disables every
2671
+ // Copilot-only vendor group (Anthropic, Google, …).
2672
+ const renderedVariantCheckboxes = Array.from(body.querySelectorAll('.model-variant-checkbox[data-selectable="1"]:not([data-claude-model]):not([data-cursor-model]):not([data-grok-model])'));
2673
+ const renderedVariantIds = new Set(renderedVariantCheckboxes
2674
+ .map((input) => String(input.getAttribute('data-variant-id') || '').trim())
2675
+ .filter(Boolean));
2676
+ const checkedVariantIds = renderedVariantCheckboxes
2677
+ .filter((input) => input.checked)
2678
+ .map((input) => String(input.getAttribute('data-variant-id') || '').trim())
2679
+ .filter(Boolean);
2680
+ const checkedVariantSet = new Set(checkedVariantIds);
2681
+ const selectedVariantIds = (modelVariantCatalogState.enabledVariantIds || [])
2682
+ .filter((variantId) => !renderedVariantIds.has(variantId) || checkedVariantSet.has(variantId));
2683
+ for (const variantId of checkedVariantIds) {
2684
+ if (!selectedVariantIds.includes(variantId)) selectedVariantIds.push(variantId);
2685
+ }
2686
+ const hasVariantRows = renderedVariantCheckboxes.length > 0;
2687
+ if (hasVariantRows && !selectedVariantIds.length) {
2688
+ alert('Select at least one model variant.');
2689
+ return;
2690
+ }
2691
+ setSummaryModalLoading(true);
2692
+ try {
2693
+ if (hasVariantRows) {
2694
+ const saved = await saveEnabledModelVariants(selectedVariantIds);
2695
+ if (!saved) throw new Error('Failed to save model selection');
2696
+ applyModelVariantCatalogState(saved);
2697
+ }
2698
+ if (claudeCheckboxes.length) {
2699
+ const savedClaude = await updateClaudeSettings({ enabledModels: selectedClaudeModels });
2700
+ if (!savedClaude) throw new Error('Failed to save Claude model selection');
2701
+ applyClaudeSettingsState(savedClaude);
2702
+ }
2703
+ if (cursorCheckboxes.length) {
2704
+ const savedCursor = await updateCursorSettings({ enabledModels: selectedCursorModels });
2705
+ if (!savedCursor) throw new Error('Failed to save Cursor model selection');
2706
+ applyCursorSettingsState(savedCursor);
2707
+ }
2708
+ if (grokCheckboxes.length) {
2709
+ const savedGrok = await updateGrokSettings({ enabledModels: selectedGrokModels });
2710
+ if (!savedGrok) throw new Error('Failed to save Grok model selection');
2711
+ applyGrokSettingsState(savedGrok);
2712
+ }
2713
+ if (claudeCheckboxes.length || cursorCheckboxes.length || grokCheckboxes.length) {
2714
+ const refreshedCatalog = await loadModelVariantCatalog();
2715
+ if (refreshedCatalog) applyModelVariantCatalogState(refreshedCatalog);
2716
+ }
2717
+ renderModelVariantCatalogBody();
2718
+ await refreshModelCatalog(true);
2719
+ showTransientRelayNotice('Saved model selection.');
2720
+ } catch (error) {
2721
+ alert(error?.message || 'Failed to save model selection');
2722
+ } finally {
2723
+ setSummaryModalLoading(false);
2724
+ }
2725
+ }
2726
+
2727
+ // Which usage tab is showing. Null means "follow the session's provider"; a
2728
+ // click pins a provider so the modal's Refresh re-renders onto the same tab
2729
+ // instead of snapping back. Reset on every fresh open of the modal.
2730
+ let usageTabProviderOverride = null;
2731
+
2732
+ /**
2733
+ * Delegated because #summary-modal-body's innerHTML is replaced on every render
2734
+ * and refresh. Scoped to [data-usage-tab], which only the usage body emits, so
2735
+ * the shared shell's context/git renders never reach it.
2736
+ */
2737
+ function initUsageTabControl() {
2738
+ const body = document.getElementById('summary-modal-body');
2739
+ if (!body || body.dataset.usageTabsBound === '1') return;
2740
+ body.dataset.usageTabsBound = '1';
2741
+ body.addEventListener('click', (event) => {
2742
+ const btn = event.target?.closest?.('[data-usage-tab]');
2743
+ if (!btn || !body.contains(btn) || summaryModalState.kind !== 'usage') return;
2744
+ selectUsageTab(body, btn.dataset.usageTab);
2745
+ });
2746
+ }
2747
+
2748
+ /** Pure DOM toggle: switching tabs never refetches `/api/usage`. */
2749
+ function selectUsageTab(root, provider) {
2750
+ const target = String(provider || '').trim();
2751
+ if (!target) return;
2752
+ const tabs = Array.from(root.querySelectorAll('[data-usage-tab]'));
2753
+ if (!tabs.some((tab) => tab.dataset.usageTab === target)) return;
2754
+ for (const tab of tabs) {
2755
+ const active = tab.dataset.usageTab === target;
2756
+ tab.classList.toggle('active', active);
2757
+ tab.setAttribute('aria-selected', active ? 'true' : 'false');
2758
+ tab.tabIndex = active ? 0 : -1;
2759
+ }
2760
+ for (const panel of root.querySelectorAll('[data-usage-panel]')) {
2761
+ panel.hidden = panel.dataset.usagePanel !== target;
2762
+ }
2763
+ usageTabProviderOverride = target;
2764
+ }
2765
+
2766
+ /**
2767
+ * `{loggedIn:false}` and "the status call failed" are different answers, so a
2768
+ * failure returns null (the card then says nothing) rather than a logged-out
2769
+ * claim we cannot back up.
2770
+ */
2771
+ function claudeAccountFromAuthStatus(payload) {
2772
+ const status = payload?.status;
2773
+ if (!status || typeof status !== 'object') return null;
2774
+ if (status.loggedIn !== true) return { loggedIn: false };
2775
+ return {
2776
+ loggedIn: true,
2777
+ email: status.email || '',
2778
+ plan: status.subscriptionType || '',
2779
+ };
2780
+ }
2781
+
2782
+ /** Joins the Claude account onto its usage card without mutating the payload. */
2783
+ function withClaudeAccount(report, account) {
2784
+ if (!account || !Array.isArray(report?.providers)) return report;
2785
+ return {
2786
+ ...report,
2787
+ providers: report.providers.map((card) => (
2788
+ card && normalizeUsageProvider(card.provider) === 'claude' ? { ...card, account } : card
2789
+ )),
2790
+ };
2791
+ }
2792
+
2793
+ async function loadUsageSummaryAndRender() {
2794
+ // Usage is the point of the modal; the account line is a garnish, so the two
2795
+ // are settled independently and a failing auth probe cannot blank the cards.
2796
+ const [usageResult, authResult] = await Promise.allSettled([
2797
+ loadUsageSummary(),
2798
+ getClaudeAuthStatus(),
2799
+ ]);
2800
+ if (usageResult.status === 'rejected') throw usageResult.reason;
2801
+ const d = usageResult.value;
2802
+ if (!d) throw new Error('Unable to load usage data');
2803
+ const account = authResult.status === 'fulfilled'
2804
+ ? claudeAccountFromAuthStatus(authResult.value)
2805
+ : null;
2806
+ const activeProvider = usageTabProviderOverride
2807
+ || normalizeUsageProvider(activeComposerProviderType());
2808
+ const planHtml = renderPlanUsageHtml(withClaudeAccount(d, account), { activeProvider });
2809
+ if (planHtml) {
2810
+ initUsageTabControl();
2811
+ renderSummaryModalContent({
2812
+ title: 'Plan usage',
2813
+ subtitle: planUsageSubtitle(d),
2814
+ bodyHtml: planHtml,
2815
+ refresh: loadUsageSummaryAndRender,
2816
+ kind: 'usage',
2817
+ });
2818
+ return;
2819
+ }
2820
+ // Legacy relay (or a relay without the plan-usage service): fall back to the
2821
+ // Copilot-only text summary rather than showing an empty modal.
2822
+ const pct = d.premiumInteractions?.percentRemaining != null
2823
+ ? ` (${d.premiumInteractions.percentRemaining.toFixed(1)}% left)`
2824
+ : '';
2825
+ const msg = `Chat/Completions: ${d.chat?.unlimited ? 'Unlimited ✅' : `${d.chat?.remaining} remaining`}\n` +
2826
+ `Premium interactions: ${d.premiumInteractions?.remaining} / ${d.premiumInteractions?.entitlement} remaining${pct}`;
2827
+ renderSummaryModalContent({
2828
+ title: 'Copilot Usage',
2829
+ subtitle: `Resets ${d.resetDate || 'unknown'}`,
2830
+ bodyHtml: `<pre>${escHtml(msg)}</pre>`,
2831
+ refresh: loadUsageSummaryAndRender,
2832
+ kind: 'usage',
2833
+ });
2834
+ }
2835
+
2836
+ // The context modal is rendered from scratch on every refresh, so the slider's
2837
+ // conversation binding lives here rather than on the element.
2838
+ let autoCompactControlConversationId = '';
2839
+ let autoCompactWindowUpdateInFlight = false;
2840
+ // The server's last known window for the rendered control. Kept so a rejected
2841
+ // PATCH can put the slider back instead of leaving the rejected value on
2842
+ // screen until the next refresh.
2843
+ let autoCompactControlStoredWindow = null;
2844
+
2845
+ // Snaps both the range input and its label back to `window`.
2846
+ function setAutoCompactControlValue(window) {
2847
+ const slider = document.getElementById('ctx-autocompact-slider');
2848
+ if (slider) slider.value = String(autoCompactWindowToIndex(window));
2849
+ const label = document.getElementById('ctx-autocompact-value');
2850
+ if (label) label.textContent = formatAutoCompactWindowLabel(window);
2851
+ }
2852
+
2853
+ /**
2854
+ * Delegated because #summary-modal-body's innerHTML is replaced on every modal
2855
+ * render (store.js renderSummaryModalContent) — a listener on the input itself
2856
+ * would die with the first refresh.
2857
+ */
2858
+ function initAutoCompactWindowControl() {
2859
+ const body = document.getElementById('summary-modal-body');
2860
+ if (!body || body.dataset.autoCompactBound === '1') return;
2861
+ body.dataset.autoCompactBound = '1';
2862
+ const sliderFrom = (event) => {
2863
+ const target = event?.target;
2864
+ return target && target.id === 'ctx-autocompact-slider' ? target : null;
2865
+ };
2866
+ // Dragging only previews the label; nothing is persisted until the value
2867
+ // settles, matching the settings-modal sliders.
2868
+ body.addEventListener('input', (event) => {
2869
+ const slider = sliderFrom(event);
2870
+ if (!slider) return;
2871
+ const label = document.getElementById('ctx-autocompact-value');
2872
+ if (label) label.textContent = formatAutoCompactWindowLabel(autoCompactWindowFromIndex(slider.value));
2873
+ });
2874
+ body.addEventListener('change', (event) => {
2875
+ const slider = sliderFrom(event);
2876
+ if (!slider) return;
2877
+ void applyAutoCompactWindowSelection(autoCompactWindowFromIndex(slider.value));
2878
+ });
2879
+ }
2880
+
2881
+ // Same lifecycle as the auto-compact control: the modal body is re-rendered
2882
+ // from scratch, so state lives here. Written on every context render.
2883
+ let thinkingControlStored = { enabled: true, display: 'summarized' };
2884
+ let thinkingControlUpdateInFlight = false;
2885
+
2886
+ function setThinkingControlValue({ enabled, display }) {
2887
+ const activeKeys = {
2888
+ enabled: thinkingEnabledToKey(enabled),
2889
+ display: String(display || 'summarized'),
2890
+ };
2891
+ for (const btn of document.querySelectorAll('.ctx-thinking-btn')) {
2892
+ const axis = btn.dataset.thinkingAxis;
2893
+ const active = btn.dataset.thinkingValue === activeKeys[axis];
2894
+ btn.classList.toggle('is-active', active);
2895
+ btn.setAttribute('aria-pressed', active ? 'true' : 'false');
2896
+ }
2897
+ }
2898
+
2899
+ function initThinkingControl() {
2900
+ const body = document.getElementById('summary-modal-body');
2901
+ if (!body || body.dataset.thinkingBound === '1') return;
2902
+ body.dataset.thinkingBound = '1';
2903
+ body.addEventListener('click', (event) => {
2904
+ const btn = event.target?.closest?.('.ctx-thinking-btn');
2905
+ if (!btn || !body.contains(btn)) return;
2906
+ void applyThinkingSelection(btn.dataset.thinkingAxis, btn.dataset.thinkingValue);
2907
+ });
2908
+ }
2909
+
2910
+ async function applyThinkingSelection(axis, key) {
2911
+ // The control shares the auto-compact control's conversation binding: both
2912
+ // render only for Claude conversations from the same payload.
2913
+ const convId = String(autoCompactControlConversationId || '').trim();
2914
+ if (!convId || thinkingControlUpdateInFlight) {
2915
+ setThinkingControlValue(thinkingControlStored);
2916
+ return;
2917
+ }
2918
+ // Only the touched axis is sent: the route keeps every unmentioned key as
2919
+ // stored, so an enabled click can never clobber the display (or the
2920
+ // composer's model/effort).
2921
+ const patch = axis === 'enabled'
2922
+ ? { thinkingEnabled: thinkingEnabledFromKey(key) }
2923
+ : { thinkingDisplay: key === 'omitted' ? 'omitted' : 'summarized' };
2924
+ thinkingControlUpdateInFlight = true;
2925
+ try {
2926
+ const response = await updateConversationPreferences(convId, {
2927
+ clientId: CLIENT_ID,
2928
+ ...patch,
2929
+ });
2930
+ if (!response) throw new Error('Failed to update the thinking setting');
2931
+ thinkingControlStored = {
2932
+ enabled: response.thinkingEnabled !== false,
2933
+ display: response.thinkingDisplay || 'summarized',
2934
+ };
2935
+ setThinkingControlValue(thinkingControlStored);
2936
+ // Per-transition truth (probed): enabling and display changes are live on
2937
+ // the next message; disabling/reverting only reaches the CLI at its next
2938
+ // process start.
2939
+ showTransientRelayNotice(axis === 'enabled'
2940
+ ? (thinkingControlStored.enabled
2941
+ ? 'Thinking on; applies to the next message.'
2942
+ : 'Thinking off; applies when the CLI session next restarts.')
2943
+ : (thinkingControlStored.display === 'summarized'
2944
+ ? 'Thinking text visible (summarized); applies to the next message.'
2945
+ : 'Thinking text hidden; applies to the next message.'));
2946
+ } catch (error) {
2947
+ setThinkingControlValue(thinkingControlStored);
2948
+ alert(error?.message || 'Failed to update the thinking setting.');
2949
+ } finally {
2950
+ thinkingControlUpdateInFlight = false;
2951
+ }
2952
+ }
2953
+
2954
+ async function applyAutoCompactWindowSelection(autoCompactWindow) {
2955
+ const convId = String(autoCompactControlConversationId || '').trim();
2956
+ const slider = document.getElementById('ctx-autocompact-slider');
2957
+ if (!convId || autoCompactWindowUpdateInFlight) {
2958
+ // Nothing will be written, so leave the control showing what is stored
2959
+ // rather than a position the server never heard about.
2960
+ setAutoCompactControlValue(autoCompactControlStoredWindow);
2961
+ return;
2962
+ }
2963
+ autoCompactWindowUpdateInFlight = true;
2964
+ if (slider) slider.disabled = true;
2965
+ try {
2966
+ // Only the window is sent: the route keeps every unmentioned preference as
2967
+ // stored, so this cannot clobber the composer's model/effort choice.
2968
+ const response = await updateConversationPreferences(convId, {
2969
+ clientId: CLIENT_ID,
2970
+ autoCompactWindow,
2971
+ });
2972
+ if (!response) throw new Error('Failed to update the auto-compact window');
2973
+ // The server, not the slider, decides what got stored (values snap to a
2974
+ // stop), so the control follows the response.
2975
+ const stored = response.autoCompactWindow ?? null;
2976
+ autoCompactControlStoredWindow = stored;
2977
+ setAutoCompactControlValue(stored);
2978
+ showTransientRelayNotice(stored === null
2979
+ ? 'Auto-compact window back to the model default; applies to the next message.'
2980
+ : `Auto-compact window set to ${formatAutoCompactWindowLabel(stored)} tokens; applies to the next message.`);
2981
+ } catch (error) {
2982
+ // The write was refused, so the rejected position is a lie: put the
2983
+ // control back on the last value the server confirmed.
2984
+ setAutoCompactControlValue(autoCompactControlStoredWindow);
2985
+ alert(error?.message || 'Failed to update the auto-compact window.');
2986
+ } finally {
2987
+ autoCompactWindowUpdateInFlight = false;
2988
+ if (slider) slider.disabled = false;
2989
+ }
2990
+ }
2991
+
2992
+ async function loadContextSummaryAndRender(convId) {
2993
+ const trimmedConvId = String(convId || '').trim();
2994
+ const payload = await loadContextSummary(trimmedConvId);
2995
+ if (!payload) throw new Error('Unable to load context');
2996
+ const sessionId = String(payload.copilotSessionId || payload.snapshot?.copilot_session_id || '').trim();
2997
+ const refreshLookupId = sessionId || trimmedConvId || null;
2998
+ const providerLabel = payload.providerType === 'claude'
2999
+ ? 'Claude'
3000
+ : (payload.providerType === 'cursor'
3001
+ ? 'Cursor'
3002
+ : (payload.providerType === 'grok' ? 'Grok' : 'Copilot'));
3003
+ const subtitle = sessionId
3004
+ ? `${providerLabel} session ${sessionId.slice(0, 8)}`
3005
+ : (trimmedConvId ? `Conversation ${trimmedConvId.slice(0, 8)}` : 'No conversation selected');
3006
+
3007
+ const usageHtml = renderContextUsageHtml(payload.contextUsage);
3008
+ // Claude-only: no other provider exposes a compaction window (or any
3009
+ // session-settings surface) to steer.
3010
+ const autoCompactHtml = payload.providerType === 'claude'
3011
+ ? renderAutoCompactControlHtml({
3012
+ autoCompactWindow: payload.autoCompactWindow ?? null,
3013
+ autoCompactThreshold: payload.contextUsage?.autoCompactThreshold ?? null,
3014
+ autocompactSource: payload.contextUsage?.autocompactSource ?? null,
3015
+ // Tri-state: only a real snapshot can say "disabled". Collapsing this to
3016
+ // a boolean made a brand-new conversation (no snapshot yet) claim
3017
+ // auto-compact was off, which is both false and self-contradictory next
3018
+ // to "Effective: — (known after the first turn)".
3019
+ isAutoCompactEnabled: payload.contextUsage
3020
+ ? (payload.contextUsage.isAutoCompactEnabled ?? null)
3021
+ : null,
3022
+ maxTokens: payload.contextUsage?.maxTokens ?? null,
3023
+ })
3024
+ : '';
3025
+ const thinkingHtml = payload.providerType === 'claude'
3026
+ ? renderThinkingControlHtml({
3027
+ thinkingEnabled: payload.thinkingEnabled !== false,
3028
+ thinkingDisplay: payload.thinkingDisplay ?? null,
3029
+ })
3030
+ : '';
3031
+ // The slider's writes need the conversation id even when the modal was
3032
+ // opened through an sdk_session_id.
3033
+ autoCompactControlConversationId = String(
3034
+ payload.resolvedConversationId || (payload.providerType === 'claude' ? trimmedConvId : '') || '',
3035
+ ).trim();
3036
+ // Baseline for the failure path below: what the server says is stored right
3037
+ // now, refreshed every time the control is re-rendered.
3038
+ autoCompactControlStoredWindow = payload.autoCompactWindow ?? null;
3039
+ thinkingControlStored = {
3040
+ enabled: payload.thinkingEnabled !== false,
3041
+ display: payload.thinkingDisplay || 'summarized',
3042
+ };
3043
+ const detailText = String(payload.text || '').trim();
3044
+ // The structured breakdown is the answer; the runtime's own text dump stays
3045
+ // available underneath for the details it carries that categories don't.
3046
+ const bodyHtml = usageHtml
3047
+ ? `${usageHtml}${autoCompactHtml}${thinkingHtml}${detailText
3048
+ ? `<details class="ctx-usage-raw"><summary>Raw details</summary><pre>${escHtml(detailText)}</pre></details>`
3049
+ : ''}`
3050
+ : `${autoCompactHtml}${thinkingHtml}<pre>${escHtml(detailText || 'No context data available.')}</pre>`;
3051
+
3052
+ renderSummaryModalContent({
3053
+ title: 'Context usage',
3054
+ subtitle,
3055
+ bodyHtml,
3056
+ refresh: () => loadContextSummaryAndRender(refreshLookupId),
3057
+ kind: 'context',
3058
+ });
3059
+ }
3060
+
3061
+ async function showUsage() {
3062
+ // A fresh open follows the current session's provider again; only a click
3063
+ // inside the modal pins a tab (for the Refresh button's sake).
3064
+ usageTabProviderOverride = null;
3065
+ const btn = document.getElementById('chat-menu-usage') || document.getElementById('usage-btn');
3066
+ if (btn) {
3067
+ btn.textContent = '⏳';
3068
+ btn.disabled = true;
3069
+ }
3070
+ openSummaryModal({
3071
+ title: 'Plan usage',
3072
+ subtitle: 'Loading…',
3073
+ bodyHtml: '<div class="summary-loading">Fetching plan usage across providers…</div>',
3074
+ refresh: loadUsageSummaryAndRender,
3075
+ kind: 'usage',
3076
+ });
3077
+ setSummaryModalLoading(true);
3078
+ try {
3079
+ await loadUsageSummaryAndRender();
3080
+ } catch (e) {
3081
+ renderSummaryModalContent({
3082
+ title: 'Plan usage',
3083
+ subtitle: 'Unable to load',
3084
+ bodyHtml: `<div class="summary-error">Failed to fetch usage: ${escHtml(e.message || 'Unknown error')}</div>`,
3085
+ refresh: loadUsageSummaryAndRender,
3086
+ kind: 'usage',
3087
+ });
3088
+ } finally {
3089
+ if (btn) {
3090
+ btn.textContent = btn.id === 'chat-menu-usage' ? '📊 Check Usage' : '📊';
3091
+ btn.disabled = false;
3092
+ }
3093
+ }
3094
+ }
3095
+
3096
+ async function showContext() {
3097
+ const btn = document.getElementById('context-btn');
3098
+ const convId = String(currentConvId || '').trim();
3099
+ if (!convId) {
3100
+ // /api/context has no data without a conversation, so say so rather than
3101
+ // opening a modal that can only ever render an error.
3102
+ openSummaryModal({
3103
+ title: 'Context usage',
3104
+ subtitle: 'No conversation selected',
3105
+ bodyHtml: '<div class="summary-loading">Open a conversation to see its context usage.</div>',
3106
+ kind: 'context',
3107
+ });
3108
+ return;
3109
+ }
3110
+ if (btn) {
3111
+ btn.textContent = '⏳';
3112
+ btn.disabled = true;
3113
+ }
3114
+ openSummaryModal({
3115
+ title: 'Context usage',
3116
+ subtitle: `Conversation ${convId.slice(0, 8)}`,
3117
+ bodyHtml: '<div class="summary-loading">Fetching context snapshot…</div>',
3118
+ refresh: () => loadContextSummaryAndRender(convId),
3119
+ kind: 'context',
3120
+ });
3121
+ setSummaryModalLoading(true);
3122
+ try {
3123
+ await loadContextSummaryAndRender(convId);
3124
+ } catch (e) {
3125
+ renderSummaryModalContent({
3126
+ title: 'Context usage',
3127
+ subtitle: 'Unable to load',
3128
+ bodyHtml: `<div class="summary-error">Failed to fetch context: ${escHtml(e.message || 'Unknown error')}</div>`,
3129
+ refresh: () => loadContextSummaryAndRender(convId || null),
3130
+ kind: 'context',
3131
+ });
3132
+ } finally {
3133
+ if (btn) {
3134
+ btn.textContent = '🧠';
3135
+ btn.disabled = false;
3136
+ }
3137
+ }
3138
+ }
3139
+
3140
+ function renderSessionInstructionDocs(docs) {
3141
+ const items = Array.isArray(docs) ? docs : [];
3142
+ if (!items.length) {
3143
+ return '<div class="summary-loading">No session instruction files were found.</div>';
3144
+ }
3145
+
3146
+ return `<div style="display:flex;flex-direction:column;gap:12px">${
3147
+ items.map((doc) => {
3148
+ const title = String(doc?.title || doc?.sessionId || 'Session instructions').trim();
3149
+ const name = String(doc?.name || '').trim();
3150
+ const gender = String(doc?.gender || '').trim();
3151
+ const summary = [name, gender].filter(Boolean).join(' · ');
3152
+ const sessionId = String(doc?.sessionId || '').trim();
3153
+ const updatedAt = String(doc?.updatedAt || '').trim();
3154
+ const content = escHtml(String(doc?.content || '').trim());
3155
+ return `
3156
+ <details open style="border:1px solid var(--border);border-radius:10px;background:var(--bg3);padding:10px 12px">
3157
+ <summary style="cursor:pointer;font-weight:600;outline:none">${escHtml(title)}</summary>
3158
+ <div style="margin-top:6px;font-size:0.78rem;color:var(--muted)">
3159
+ ${escHtml(summary || sessionId)}
3160
+ ${updatedAt ? ` · ${escHtml(updatedAt)}` : ''}
3161
+ </div>
3162
+ <pre style="margin-top:10px;white-space:pre-wrap;word-break:break-word">${content}</pre>
3163
+ </details>
3164
+ `;
3165
+ }).join('')
3166
+ }</div>`;
3167
+ }
3168
+
3169
+
3170
+ function startRelayQuestionPolling() {
3171
+ if (relayQuestionPollTimer) return;
3172
+ relayQuestionPollTimer = setInterval(() => {
3173
+ if (!shouldRunForegroundNetworkWork()) return;
3174
+ loadRelayQuestions(currentConvId).catch(() => {});
3175
+ }, 3000);
3176
+ }
3177
+
3178
+ function startRelayBoardPolling() {
3179
+ if (relayBoardPollTimer) return;
3180
+ relayBoardPollTimer = setInterval(() => {
3181
+ if (!shouldRunForegroundNetworkWork()) return;
3182
+ loadRelayBoards().catch(() => {});
3183
+ }, 3000);
3184
+ }
3185
+
3186
+ async function refreshSessionWorkerStatus() {
3187
+ const currentId = String(currentConvId || '').trim();
3188
+ const currentConversation = currentId ? conversations[currentId] : null;
3189
+ const currentSdkSessionId = String(currentConversation?.sdkSessionId || currentConversation?.sdk_session_id || '').trim();
3190
+ const previousWorkerState = currentSdkSessionId ? getSessionWorkerState(currentSdkSessionId) : null;
3191
+ const previousWorkerStatus = String(previousWorkerState?.status || '').trim().toLowerCase();
3192
+ const status = await refreshWorkspaceRootHints();
3193
+ if (!status) return;
3194
+ syncQueueStatusMenuEntry(status);
3195
+ if (setSessionWorkerStatesFromStatusPayload(status.sessionWorker)) {
3196
+ renderConvList();
3197
+ }
3198
+ updateCliStatus();
3199
+ const nextWorkerState = currentSdkSessionId ? getSessionWorkerState(currentSdkSessionId) : null;
3200
+ const transitionedToOffline = (
3201
+ ['starting', 'ready', 'processing'].includes(previousWorkerStatus)
3202
+ && !nextWorkerState
3203
+ );
3204
+ const hasSessionUsageSummary = !!(
3205
+ currentConversation?.sessionUsageSummary
3206
+ || currentConversation?.session_usage_summary
3207
+ );
3208
+ if (transitionedToOffline && currentId && !hasSessionUsageSummary) {
3209
+ refreshCurrentView().catch(() => {});
3210
+ }
3211
+ }
3212
+
3213
+ function startSessionWorkerStatusPolling() {
3214
+ if (sessionWorkerStatusPollTimer) return;
3215
+ sessionWorkerStatusPollTimer = setInterval(() => {
3216
+ if (!shouldRunForegroundNetworkWork()) return;
3217
+ refreshSessionWorkerStatus().catch(() => {});
3218
+ }, 4000);
3219
+ }
3220
+
3221
+ async function pollAuthenticatedCurrentConversationLive() {
3222
+ if (liveConversationPollInFlight) return;
3223
+ if (isSharedReaderMode()) return;
3224
+ const currentId = String(currentConvId || '').trim();
3225
+ if (!currentId) return;
3226
+ const currentConversation = conversations[currentId] || null;
3227
+ const isProcessing = String(currentConversation?.localTurnStatus || '').trim().toLowerCase() === 'processing'
3228
+ || !!document.getElementById('thinking-indicator');
3229
+ if (!isProcessing) return;
3230
+ // Refreshing while the user selects or drags in the chat would rebuild the
3231
+ // DOM under the selection; the guard's release callback re-polls right away.
3232
+ if (isChatInteractionHeld()) return;
3233
+
3234
+ liveConversationPollInFlight = true;
3235
+ try {
3236
+ const messagesEl = document.getElementById('messages');
3237
+ const preserveBottom = isMessagesAtBottom();
3238
+ const savedScrollTop = messagesEl?.scrollTop || 0;
3239
+ const savedLoadedCount = loadConversationLoadedMessageCount(currentId);
3240
+ const requestLimit = Math.max(20, getConversationLoadedMessageCount() || 0, savedLoadedCount || 0);
3241
+ const response = await loadConversation(currentId, { limit: requestLimit });
3242
+ if (!response) return;
3243
+ if (String(currentConvId || '').trim() !== currentId) return;
3244
+ const stableSavedScrollTop = resolveStableScrollTopForLiveRefresh(messagesEl, savedScrollTop);
3245
+ applyLoadedConversationState(currentId, response, {
3246
+ restoreScroll: !preserveBottom,
3247
+ savedScrollTop: preserveBottom ? null : stableSavedScrollTop,
3248
+ followLiveUpdates: preserveBottom,
3249
+ });
3250
+ } finally {
3251
+ liveConversationPollInFlight = false;
3252
+ }
3253
+ }
3254
+
3255
+ function startLiveConversationPolling() {
3256
+ if (liveConversationPollTimer || isSharedReaderMode()) return;
3257
+ liveConversationPollTimer = setInterval(() => {
3258
+ if (!shouldRunForegroundNetworkWork()) return;
3259
+ pollAuthenticatedCurrentConversationLive().catch(() => {});
3260
+ }, 900);
3261
+ }
3262
+
3263
+ function setupViewportTracking() {
3264
+ syncViewportMetrics();
3265
+ const update = () => syncViewportMetrics();
3266
+ window.addEventListener('resize', update, { passive: true });
3267
+ window.addEventListener('orientationchange', update, { passive: true });
3268
+ if (window.visualViewport) {
3269
+ window.visualViewport.addEventListener('resize', update, { passive: true });
3270
+ window.visualViewport.addEventListener('scroll', update, { passive: true });
3271
+ }
3272
+
3273
+ // The page root must never scroll: #app is viewport-sized and body hides its
3274
+ // scrollbar, but programmatic scrolls (scrollIntoView, focus reveals) can
3275
+ // still shift the root and push the header off-screen with no way back.
3276
+ const clampRootScroll = () => {
3277
+ if (window.scrollY || document.documentElement.scrollTop || document.body.scrollTop) {
3278
+ window.scrollTo(0, 0);
3279
+ }
3280
+ };
3281
+ clampRootScroll();
3282
+ window.addEventListener('scroll', clampRootScroll, { passive: true });
3283
+
3284
+ // The relay question cards create their reply controls dynamically, so the
3285
+ // keyboard-open bookkeeping is delegated instead of bound per input.
3286
+ const isQuestionTextControl = (node) => node instanceof Element
3287
+ && (node.tagName === 'TEXTAREA' || node.tagName === 'INPUT')
3288
+ && !!node.closest('.relay-question-container');
3289
+ document.addEventListener('focusin', (event) => {
3290
+ if (!isQuestionTextControl(event.target)) return;
3291
+ document.body.classList.add('keyboard-open');
3292
+ syncViewportMetrics();
3293
+ });
3294
+ document.addEventListener('focusout', (event) => {
3295
+ if (!isQuestionTextControl(event.target)) return;
3296
+ document.body.classList.remove('keyboard-open');
3297
+ syncViewportMetrics();
3298
+ });
3299
+
3300
+ const input = document.getElementById('msg-input');
3301
+ if (input && input.dataset.viewportBound !== '1') {
3302
+ input.dataset.viewportBound = '1';
3303
+ input.addEventListener('input', () => {
3304
+ syncComposerControlState();
3305
+ }, { passive: true });
3306
+ input.addEventListener('focus', () => {
3307
+ document.body.classList.add('keyboard-open');
3308
+ syncViewportMetrics();
3309
+ }, { passive: true });
3310
+ input.addEventListener('blur', () => {
3311
+ document.body.classList.remove('keyboard-open');
3312
+ syncViewportMetrics();
3313
+ void flushConversationDraft(currentConvId);
3314
+ }, { passive: true });
3315
+ }
3316
+ }
3317
+
3318
+ function composerAttachmentsAllowed() {
3319
+ // Shared read-only viewers must never be able to attach files.
3320
+ return !appSharedMode && !IS_SHARED_VIEW;
3321
+ }
3322
+
3323
+ function initComposerAttachmentInput() {
3324
+ const input = document.getElementById('msg-input');
3325
+ if (input && input.dataset.pasteBound !== '1') {
3326
+ input.dataset.pasteBound = '1';
3327
+ input.addEventListener('paste', (event) => {
3328
+ if (!composerAttachmentsAllowed()) return;
3329
+ void handleComposerPaste(event);
3330
+ });
3331
+ }
3332
+
3333
+ const dropZone = document.getElementById('input-area');
3334
+ if (!dropZone || dropZone.dataset.dropBound === '1') return;
3335
+ dropZone.dataset.dropBound = '1';
3336
+
3337
+ // dragenter/dragleave fire for every child element, so the highlight is
3338
+ // reference counted instead of toggled, otherwise it flickers on each hover.
3339
+ let dragDepth = 0;
3340
+ const clearHighlight = () => {
3341
+ dragDepth = 0;
3342
+ dropZone.classList.remove('composer-dropzone-active');
3343
+ };
3344
+
3345
+ dropZone.addEventListener('dragenter', (event) => {
3346
+ if (!composerAttachmentsAllowed() || !dataTransferHasFiles(event.dataTransfer)) return;
3347
+ event.preventDefault();
3348
+ dragDepth += 1;
3349
+ dropZone.classList.add('composer-dropzone-active');
3350
+ });
3351
+
3352
+ dropZone.addEventListener('dragover', (event) => {
3353
+ if (!composerAttachmentsAllowed() || !dataTransferHasFiles(event.dataTransfer)) return;
3354
+ // Without preventDefault the browser refuses the drop entirely.
3355
+ event.preventDefault();
3356
+ if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
3357
+ });
3358
+
3359
+ dropZone.addEventListener('dragleave', (event) => {
3360
+ if (!dragDepth) return;
3361
+ dragDepth = Math.max(0, dragDepth - 1);
3362
+ if (!dragDepth) dropZone.classList.remove('composer-dropzone-active');
3363
+ });
3364
+
3365
+ dropZone.addEventListener('drop', (event) => {
3366
+ clearHighlight();
3367
+ if (!composerAttachmentsAllowed()) return;
3368
+ void handleComposerDrop(event);
3369
+ });
3370
+
3371
+ window.addEventListener('dragend', clearHighlight);
3372
+ }
3373
+
3374
+ function initMessageScrollPersistence() {
3375
+ const el = document.getElementById('messages');
3376
+ if (!el || el.dataset.scrollPersistenceBound === '1') return;
3377
+ el.dataset.scrollPersistenceBound = '1';
3378
+ el.addEventListener('scroll', () => {
3379
+ const convId = String(currentConvId || '').trim();
3380
+ if (!convId) return;
3381
+ saveConversationScrollTop(convId, el.scrollTop);
3382
+ }, { passive: true });
3383
+ }
3384
+
3385
+ async function runConversationHistoryRefresh({ source = 'menu' } = {}) {
3386
+ const currentId = String(currentConvId || '').trim();
3387
+ if (!currentId) {
3388
+ if (source !== 'pull') showTransientRelayNotice('Select a conversation first.');
3389
+ return false;
3390
+ }
3391
+ const eligibility = canRefreshConversationHistory(currentId);
3392
+ if (!eligibility.ok) {
3393
+ showTransientRelayNotice(eligibility.reason);
3394
+ return false;
3395
+ }
3396
+
3397
+ const messagesEl = document.getElementById('messages');
3398
+ const scrollTop = messagesEl?.scrollTop || 0;
3399
+ const savedLoadedCount = loadConversationLoadedMessageCount(currentId);
3400
+ const requestLimit = Math.max(20, getConversationLoadedMessageCount() || 0, savedLoadedCount || 0);
3401
+
3402
+ setHistoryRefreshInFlight(true);
3403
+ syncRefreshHistoryMenuState();
3404
+ try {
3405
+ const refreshed = await refreshConversationHistory(currentId, { limit: requestLimit });
3406
+ if (!refreshed) {
3407
+ throw new Error('History refresh request failed.');
3408
+ }
3409
+ if (String(currentConvId || '').trim() === currentId) {
3410
+ applyLoadedConversationState(currentId, refreshed, { restoreScroll: false });
3411
+ }
3412
+ await refreshConversations();
3413
+ await loadRelayQuestions(currentId);
3414
+ await loadRelayBoards();
3415
+ scheduleContextUsageRefresh(currentId, 0);
3416
+
3417
+ if (messagesEl && String(currentConvId || '').trim() === currentId) {
3418
+ const savedScrollTop = loadConversationScrollTop(currentId);
3419
+ const nextScrollTop = Number.isFinite(savedScrollTop) ? savedScrollTop : scrollTop;
3420
+ messagesEl.scrollTop = nextScrollTop;
3421
+ if (Number.isFinite(nextScrollTop) && nextScrollTop >= 0) {
3422
+ saveConversationScrollTop(currentId, nextScrollTop);
3423
+ }
3424
+ }
3425
+ showTransientRelayNotice('Conversation history refreshed.');
3426
+ return true;
3427
+ } catch (error) {
3428
+ const message = String(error?.message || '').trim() || 'Could not refresh conversation history.';
3429
+ showTransientRelayNotice(message);
3430
+ return false;
3431
+ } finally {
3432
+ setHistoryRefreshInFlight(false);
3433
+ syncRefreshHistoryMenuState();
3434
+ }
3435
+ }
3436
+
3437
+ let refreshViewVersion = 0;
3438
+
3439
+ async function refreshCurrentView() {
3440
+ const capturedVersion = ++refreshViewVersion;
3441
+ const messagesEl = document.getElementById('messages');
3442
+ const preserveBottom = isMessagesAtBottom();
3443
+ const scrollTop = messagesEl?.scrollTop || 0;
3444
+ const stableSavedScrollTop = resolveStableScrollTopForLiveRefresh(messagesEl, scrollTop);
3445
+
3446
+ await refreshConversations();
3447
+
3448
+ const currentId = String(currentConvId || '').trim();
3449
+ if (!currentId) {
3450
+ setRepoBrowserSessionInfo('', '');
3451
+ renderMessages([]);
3452
+ restoreInFlightThinking(null);
3453
+ scheduleContextUsageRefresh(null);
3454
+ syncRefreshHistoryMenuState();
3455
+ return;
3456
+ }
3457
+
3458
+ const savedLoadedCount = loadConversationLoadedMessageCount(currentId);
3459
+ const requestLimit = Math.max(20, getConversationLoadedMessageCount() || 0, savedLoadedCount || 0);
3460
+ const r = await loadConversation(currentId, { limit: requestLimit });
3461
+ if (capturedVersion < refreshViewVersion) return;
3462
+ if (String(currentConvId || '').trim() !== currentId) return;
3463
+ if (r) {
3464
+ applyLoadedConversationState(currentId, r, {
3465
+ restoreScroll: !preserveBottom,
3466
+ savedScrollTop: preserveBottom ? null : stableSavedScrollTop,
3467
+ followLiveUpdates: preserveBottom,
3468
+ });
3469
+ } else {
3470
+ setRepoBrowserSessionInfo('', '');
3471
+ restoreInFlightThinking(null);
3472
+ }
3473
+ await loadRelayQuestions(currentId);
3474
+ await loadRelayBoards();
3475
+ scheduleContextUsageRefresh(currentId, 0);
3476
+ if (messagesEl && String(currentConvId || '').trim() === currentId) {
3477
+ if (!preserveBottom && Number.isFinite(stableSavedScrollTop) && stableSavedScrollTop >= 0) {
3478
+ messagesEl.scrollTop = stableSavedScrollTop;
3479
+ }
3480
+ saveConversationScrollTop(currentId, messagesEl.scrollTop);
3481
+ }
3482
+ syncRefreshHistoryMenuState();
3483
+ }
3484
+
3485
+ async function copyTextToClipboard(text) {
3486
+ const value = String(text || '').trim();
3487
+ if (!value) return false;
3488
+ if (navigator?.clipboard?.writeText) {
3489
+ try {
3490
+ await navigator.clipboard.writeText(value);
3491
+ return true;
3492
+ } catch {}
3493
+ }
3494
+ try {
3495
+ const el = document.createElement('textarea');
3496
+ el.value = value;
3497
+ el.setAttribute('readonly', 'readonly');
3498
+ el.style.position = 'fixed';
3499
+ el.style.opacity = '0';
3500
+ document.body.appendChild(el);
3501
+ el.focus();
3502
+ el.select();
3503
+ const ok = document.execCommand('copy');
3504
+ document.body.removeChild(el);
3505
+ return !!ok;
3506
+ } catch {
3507
+ return false;
3508
+ }
3509
+ }
3510
+
3511
+ function initChatTitleCopy() {
3512
+ const title = document.getElementById('chat-title');
3513
+ if (!title || title.dataset.copyBound === '1') return;
3514
+ title.dataset.copyBound = '1';
3515
+ if (title.dataset.fullscreenBound) delete title.dataset.fullscreenBound;
3516
+
3517
+ title.addEventListener('click', (event) => {
3518
+ event.preventDefault();
3519
+ event.stopPropagation();
3520
+ event.stopImmediatePropagation();
3521
+ const sessionId = String(title.dataset.copilotSessionId || '').trim();
3522
+ if (!sessionId) return;
3523
+ copyTextToClipboard(sessionId).then((ok) => {
3524
+ if (ok) {
3525
+ showTransientRelayNotice(`Copied Copilot session ID: ${sessionId.slice(0, 8)}…`);
3526
+ } else {
3527
+ showTransientRelayNotice('Could not copy session ID.');
3528
+ }
3529
+ }).catch(() => {});
3530
+ }, true);
3531
+ }
3532
+
3533
+ function getChatTitleElements() {
3534
+ return {
3535
+ wrap: document.getElementById('chat-title-wrap'),
3536
+ title: document.getElementById('chat-title'),
3537
+ editBtn: document.getElementById('chat-actions-menu-btn'),
3538
+ editor: document.getElementById('chat-title-editor'),
3539
+ input: document.getElementById('chat-title-input'),
3540
+ saveBtn: document.getElementById('chat-title-save-btn'),
3541
+ cancelBtn: document.getElementById('chat-title-cancel-btn'),
3542
+ };
3543
+ }
3544
+
3545
+ function closeChatActionsMenu() {
3546
+ const menu = document.getElementById('chat-actions-menu');
3547
+ const trigger = document.getElementById('chat-actions-menu-btn');
3548
+ const backdrop = document.getElementById('chat-actions-menu-backdrop');
3549
+ if (menu) menu.hidden = true;
3550
+ if (trigger) trigger.setAttribute('aria-expanded', 'false');
3551
+ if (backdrop && !window.__chatActionsMenuShieldTimer) backdrop.classList.remove('visible');
3552
+ }
3553
+
3554
+ function toggleChatActionsMenu() {
3555
+ const menu = document.getElementById('chat-actions-menu');
3556
+ const trigger = document.getElementById('chat-actions-menu-btn');
3557
+ if (!menu || !trigger || trigger.hidden || trigger.disabled) return;
3558
+ syncRefreshHistoryMenuState();
3559
+ syncThemeMenuLabel();
3560
+ const willOpen = !!menu.hidden;
3561
+ menu.hidden = !willOpen;
3562
+ trigger.setAttribute('aria-expanded', willOpen ? 'true' : 'false');
3563
+ }
3564
+
3565
+ function isConversationLocallyProcessing(conversation) {
3566
+ if (!conversation || typeof conversation !== 'object') return false;
3567
+ const localTurnStatus = String(conversation?.localTurnStatus || '').trim().toLowerCase();
3568
+ if (localTurnStatus === 'processing') {
3569
+ const updatedAtMs = Number(conversation?.localTurnStatusUpdatedAt || 0);
3570
+ if (!Number.isFinite(updatedAtMs) || updatedAtMs <= 0 || (Date.now() - updatedAtMs) < LOCAL_PROCESSING_STALE_MS) {
3571
+ return true;
3572
+ }
3573
+ }
3574
+ const runtimeStatus = String(
3575
+ conversation?.runtimeSessionStatus
3576
+ || conversation?.runtime_session_status
3577
+ || conversation?.status
3578
+ || '',
3579
+ ).trim().toLowerCase();
3580
+ return runtimeStatus === 'processing';
3581
+ }
3582
+
3583
+ function canRefreshConversationHistory(conversationId) {
3584
+ const currentId = String(conversationId || '').trim();
3585
+ if (!currentId) {
3586
+ return { ok: false, reason: 'Select a conversation first.' };
3587
+ }
3588
+ if (isHistoryRefreshInFlight()) {
3589
+ return { ok: false, reason: 'History refresh is already running.' };
3590
+ }
3591
+ if (isSendInFlight()) {
3592
+ return { ok: false, reason: 'Wait for the current send to finish before refreshing history.' };
3593
+ }
3594
+ const conversation = conversations[currentId] || null;
3595
+ if (isConversationLocallyProcessing(conversation)) {
3596
+ return { ok: false, reason: 'Wait for the current turn to finish before refreshing history.' };
3597
+ }
3598
+ return { ok: true };
3599
+ }
3600
+
3601
+ function syncRefreshHistoryMenuState() {
3602
+ const button = document.getElementById('chat-menu-refresh-history');
3603
+ if (!button) return;
3604
+ if (button.hidden || button.getAttribute('aria-hidden') === 'true') {
3605
+ button.disabled = true;
3606
+ button.tabIndex = -1;
3607
+ button.setAttribute('aria-disabled', 'true');
3608
+ return;
3609
+ }
3610
+ if (isHistoryRefreshInFlight()) {
3611
+ button.disabled = true;
3612
+ button.title = 'Refreshing conversation history';
3613
+ return;
3614
+ }
3615
+ const eligibility = canRefreshConversationHistory(currentConvId);
3616
+ button.disabled = !eligibility.ok;
3617
+ button.title = eligibility.ok ? 'Rebuild this conversation history from SDK events.' : eligibility.reason;
3618
+ }
3619
+
3620
+ function lockChatActionsMenuShield(ms = 300) {
3621
+ const backdrop = document.getElementById('chat-actions-menu-backdrop');
3622
+ if (!backdrop) return;
3623
+ if (window.__chatActionsMenuShieldTimer) {
3624
+ window.clearTimeout(window.__chatActionsMenuShieldTimer);
3625
+ }
3626
+ backdrop.classList.add('visible');
3627
+ window.__chatActionsMenuShieldTimer = window.setTimeout(() => {
3628
+ window.__chatActionsMenuShieldTimer = null;
3629
+ const menu = document.getElementById('chat-actions-menu');
3630
+ if (menu?.hidden) backdrop.classList.remove('visible');
3631
+ }, Math.max(150, Number(ms) || 300));
3632
+ }
3633
+
3634
+
3635
+ function syncChatTitleControls() {
3636
+ const { title, editBtn, editor, input } = getChatTitleElements();
3637
+ const convId = String(currentConvId || '').trim();
3638
+ const killBtn = document.getElementById('chat-menu-kill-session');
3639
+ const sharedMode = isSharedReaderMode();
3640
+ const conversation = convId ? (conversations[convId] || null) : null;
3641
+ const sdkSessionId = String(conversation?.sdkSessionId || '').trim();
3642
+ if (title) {
3643
+ if (!convId || !conversation) {
3644
+ delete title.dataset.uiState;
3645
+ } else {
3646
+ const pendingByConversation = getPendingQuestionCountsByConversation();
3647
+ const pendingCount = Number(pendingByConversation[convId] || 0);
3648
+ const workerState = sdkSessionId ? getSessionWorkerState(sdkSessionId) : null;
3649
+ const uiState = resolveConversationUiState({
3650
+ conversation,
3651
+ workerState,
3652
+ hasPendingQuestion: pendingCount > 0,
3653
+ });
3654
+ title.dataset.uiState = uiState;
3655
+ }
3656
+ }
3657
+ if (chatTitleEditingConversationId && chatTitleEditingConversationId !== convId) {
3658
+ chatTitleEditingConversationId = null;
3659
+ }
3660
+ const editing = convId && chatTitleEditingConversationId === convId;
3661
+ document.body.classList.toggle('chat-title-editing', !!editing);
3662
+ if (title) title.hidden = editing;
3663
+ if (editBtn) {
3664
+ editBtn.hidden = !convId;
3665
+ editBtn.disabled = !convId || editing;
3666
+ }
3667
+ if (killBtn) {
3668
+ killBtn.disabled = sharedMode || !convId || !sdkSessionId;
3669
+ killBtn.hidden = sharedMode || !convId;
3670
+ }
3671
+ if (editor) {
3672
+ editor.hidden = !editing;
3673
+ }
3674
+ if (input) {
3675
+ input.maxLength = CHAT_TITLE_MAX_LENGTH;
3676
+ }
3677
+ if (!convId && chatTitleEditingConversationId) {
3678
+ chatTitleEditingConversationId = null;
3679
+ }
3680
+ if (!editing && editor && !editor.hidden) {
3681
+ editor.hidden = true;
3682
+ }
3683
+ if (!isStatusViewActive()) {
3684
+ syncChatHeaderWorkspaceLabel();
3685
+ }
3686
+ syncChatTitleWatcherIndicator();
3687
+ }
3688
+
3689
+ function openChatTitleEditor() {
3690
+ const convId = String(currentConvId || '').trim();
3691
+ if (!convId) return;
3692
+ const { title, editBtn, editor, input } = getChatTitleElements();
3693
+ closeChatActionsMenu();
3694
+ const currentTitle = String(conversations[convId]?.title || title?.textContent || convId).trim() || convId;
3695
+ chatTitleEditingConversationId = convId;
3696
+ document.body.classList.add('chat-title-editing');
3697
+ if (title) title.hidden = true;
3698
+ if (editBtn) editBtn.disabled = true;
3699
+ if (editor) editor.hidden = false;
3700
+ if (input) {
3701
+ input.maxLength = CHAT_TITLE_MAX_LENGTH;
3702
+ input.value = currentTitle;
3703
+ requestAnimationFrame(() => {
3704
+ if (chatTitleEditingConversationId !== convId) return;
3705
+ window.setTimeout(() => {
3706
+ if (chatTitleEditingConversationId !== convId) return;
3707
+ input.focus({ preventScroll: true });
3708
+ input.select();
3709
+ }, 50);
3710
+ });
3711
+ }
3712
+ }
3713
+
3714
+ function closeChatTitleEditor() {
3715
+ chatTitleEditingConversationId = null;
3716
+ document.body.classList.remove('chat-title-editing');
3717
+ const { title, editBtn, editor, input } = getChatTitleElements();
3718
+ closeChatActionsMenu();
3719
+ if (editor) editor.hidden = true;
3720
+ if (title) title.hidden = !String(currentConvId || '').trim();
3721
+ if (editBtn) editBtn.disabled = !String(currentConvId || '').trim();
3722
+ if (input) {
3723
+ const convId = String(currentConvId || '').trim();
3724
+ input.value = convId ? String(conversations[convId]?.title || title?.textContent || convId) : '';
3725
+ }
3726
+ syncChatTitleControls();
3727
+ }
3728
+
3729
+ function applyConversationTitleUpdate(conversationId, title, updatedAt) {
3730
+ const id = String(conversationId || '').trim();
3731
+ const nextTitle = String(title || '').trim();
3732
+ if (!id || !nextTitle) return;
3733
+ const existing = conversations[id] || { id, archived: false, messageCount: 0 };
3734
+ conversations[id] = {
3735
+ ...existing,
3736
+ title: nextTitle,
3737
+ updatedAt: String(updatedAt || existing.updatedAt || new Date().toISOString()),
3738
+ };
3739
+ if (currentConvId === id) {
3740
+ const titleEl = document.getElementById('chat-title');
3741
+ if (titleEl) titleEl.textContent = nextTitle;
3742
+ }
3743
+ renderConvList();
3744
+ }
3745
+
3746
+ function applyConversationWorkspaceRootUpdate(payload = {}) {
3747
+ const conversationId = String(payload.conversationId || '').trim();
3748
+ if (!conversationId) return;
3749
+ const existing = conversations[conversationId] || { id: conversationId, archived: false, messageCount: 0 };
3750
+ const previousCurrentPath = String(existing.currentWorkspaceRootPath || '').trim();
3751
+ // A relaunch into a new CWD must not keep the old runtime root alive through
3752
+ // the `existing` fallback: the payload is the post-relaunch truth.
3753
+ const nextRuntimePath = Object.prototype.hasOwnProperty.call(payload, 'runtimeWorkspaceRootPath')
3754
+ ? String(payload.runtimeWorkspaceRootPath || '').trim()
3755
+ : String(existing.runtimeWorkspaceRootPath || '').trim();
3756
+ const nextRuntimeName = Object.prototype.hasOwnProperty.call(payload, 'runtimeWorkspaceRootName')
3757
+ ? String(payload.runtimeWorkspaceRootName || '').trim()
3758
+ : String(existing.runtimeWorkspaceRootName || '').trim();
3759
+ conversations[conversationId] = {
3760
+ ...existing,
3761
+ configuredWorkspaceRootPath: String(payload.configuredWorkspaceRootPath || existing.configuredWorkspaceRootPath || '').trim() || null,
3762
+ configuredWorkspaceRootName: String(payload.configuredWorkspaceRootName || existing.configuredWorkspaceRootName || '').trim() || null,
3763
+ runtimeWorkspaceRootPath: nextRuntimePath || null,
3764
+ runtimeWorkspaceRootName: nextRuntimeName || null,
3765
+ currentWorkspaceRootPath: String(payload.currentWorkspaceRootPath || existing.currentWorkspaceRootPath || '').trim() || null,
3766
+ currentWorkspaceRootName: String(payload.currentWorkspaceRootName || existing.currentWorkspaceRootName || '').trim() || null,
3767
+ };
3768
+ if (currentConvId !== conversationId) return;
3769
+ syncChatHeaderWorkspaceLabel();
3770
+ const nextCurrentPath = String(conversations[conversationId].currentWorkspaceRootPath || '').trim();
3771
+ const rootChanged = nextCurrentPath.toLowerCase() !== previousCurrentPath.toLowerCase();
3772
+ if (rootChanged) {
3773
+ resetWorkspaceRepoBrowserForRootChange();
3774
+ } else if (repoBrowserState.activeRoot === 'workspace' && repoBrowserState.open) {
3775
+ // Same root: refresh through the restoring path so open folders and the
3776
+ // current selection survive.
3777
+ refreshRepoBrowser();
3778
+ }
3779
+ }
3780
+
3781
+ async function submitChatTitleEditor() {
3782
+ const convId = String(chatTitleEditingConversationId || currentConvId || '').trim();
3783
+ if (!convId) return;
3784
+ const { input } = getChatTitleElements();
3785
+ const nextTitle = String(input?.value || '').replace(/[\r\n]+/g, ' ').trim();
3786
+ if (!nextTitle) {
3787
+ setModelBanner('⚠️ Conversation title cannot be empty.');
3788
+ input?.focus();
3789
+ return;
3790
+ }
3791
+ if (nextTitle.length > CHAT_TITLE_MAX_LENGTH) {
3792
+ setModelBanner(`⚠️ Conversation title must be ${CHAT_TITLE_MAX_LENGTH} characters or fewer.`);
3793
+ input?.focus();
3794
+ return;
3795
+ }
3796
+
3797
+ const result = await updateConversationTitle(convId, nextTitle);
3798
+ if (!result) {
3799
+ alert('Failed to update conversation title');
3800
+ return;
3801
+ }
3802
+
3803
+ applyConversationTitleUpdate(result.conversationId || convId, result.title || nextTitle, result.updatedAt);
3804
+ closeChatTitleEditor();
3805
+ }
3806
+
3807
+
3808
+ initSocketHandlers({
3809
+ refreshCurrentView,
3810
+ refreshSessionWorkerStatus,
3811
+ refreshModelCatalog,
3812
+ updateModelCatalogState,
3813
+ reconcileOpenModelVariantModal,
3814
+ applyConversationWorkspaceRootUpdate,
3815
+ applyConversationTitleUpdate,
3816
+ syncChatTitleControls,
3817
+ applyConversationPreferencesForConversation,
3818
+ applyOpenAISettingsState,
3819
+ applyClaudeSettingsState,
3820
+ applyCopilotSettingsState,
3821
+ applyGrokSettingsState,
3822
+ applyCursorSettingsState,
3823
+ });
3824
+
3825
+ initCwdPicker({
3826
+ applyConversationWorkspaceRootUpdate,
3827
+ refreshSessionWorkerStatus,
3828
+ });
3829
+
3830
+ initActionConfirmations({
3831
+ lockChatActionsMenuShield,
3832
+ closeChatActionsMenu,
3833
+ syncQueueStatusMenuEntry,
3834
+ refreshSessionWorkerStatus,
3835
+ exposeOnWindow: false,
3836
+ });
3837
+ initTmuxInspectorView({
3838
+ bindMenuAction,
3839
+ lockChatActionsMenuShield,
3840
+ closeChatActionsMenu,
3841
+ });
3842
+
3843
+ function showAuthGate(error = '') {
3844
+ document.getElementById('startup-loading')?.remove();
3845
+ document.getElementById('auth-gate').style.display = 'flex';
3846
+ document.getElementById('app').classList.remove('visible');
3847
+ showAuthError(error);
3848
+ document.getElementById('token-input')?.focus();
3849
+ }
3850
+
3851
+ // Runs before anything reads MODEL_STORAGE_KEY so the first load after an
3852
+ // upgrade already sees the model the user last picked in the New Chat modal.
3853
+ function migrateLegacyModelStorageKey() {
3854
+ try {
3855
+ const legacy = String(localStorage.getItem(LEGACY_MODEL_STORAGE_KEY) || '').trim();
3856
+ if (!legacy) return;
3857
+ if (!String(localStorage.getItem(MODEL_STORAGE_KEY) || '').trim()) {
3858
+ localStorage.setItem(MODEL_STORAGE_KEY, legacy);
3859
+ }
3860
+ localStorage.removeItem(LEGACY_MODEL_STORAGE_KEY);
3861
+ } catch {}
3862
+ }
3863
+
3864
+ async function initApp() {
3865
+ migrateLegacyModelStorageKey();
3866
+ initClientDiagnostics();
3867
+ installExternalLinkPolicy({ onFallback: showExternalLinkFallback });
3868
+ const sharedMode = isSharedReaderMode();
3869
+ appSharedMode = sharedMode;
3870
+ initNetworkLifecycleHandling();
3871
+ setForegroundNetworkWorkEnabled(document.visibilityState === 'visible');
3872
+ const sharedThemeBtn = document.getElementById('chat-menu-shared-theme-toggle');
3873
+ initTheme();
3874
+ initThemeMenuToggle();
3875
+ if (sharedThemeBtn && sharedMode) sharedThemeBtn.hidden = false;
3876
+ initFontScaling();
3877
+ if (!sharedMode) {
3878
+ clearLegacyKnownCwdHistoryStorage();
3879
+ }
3880
+ syncPwaVersionMenuEntry();
3881
+ syncQueueStatusMenuEntry();
3882
+ if (!sharedMode) {
3883
+ syncSuspendHostVisibility();
3884
+ }
3885
+ setupViewportTracking();
3886
+ bindChatSelectionGuard();
3887
+ chatSelectionGuard.onRelease(() => {
3888
+ flushDeferredMessageRender();
3889
+ pollAuthenticatedCurrentConversationLive().catch(() => {});
3890
+ });
3891
+ window.addEventListener('pagehide', () => {
3892
+ stopSharedModeTimers();
3893
+ // The direct flush below can be killed mid-flight by the browser; the
3894
+ // queued copy survives and is replayed by Background Sync. If the direct
3895
+ // write wins, the replay hits a draft version conflict and is dropped.
3896
+ if (!appSharedMode) void enqueueDraftFlushForBackgroundSync(currentConvId);
3897
+ void flushConversationDraft(currentConvId);
3898
+ closeTmuxInspectorView();
3899
+ });
3900
+ document.getElementById('startup-loading')?.remove();
3901
+ document.getElementById('auth-gate').style.display = 'none';
3902
+ document.getElementById('app').classList.add('visible');
3903
+ initSidebarLayout();
3904
+ const chatActionsMenuBtn = document.getElementById('chat-actions-menu-btn');
3905
+ bindTapAction(chatActionsMenuBtn, (event) => {
3906
+ event.preventDefault();
3907
+ event.stopPropagation();
3908
+ toggleChatActionsMenu();
3909
+ });
3910
+ if (!sharedMode) {
3911
+ const chatMenuEditBtn = document.getElementById('chat-menu-edit-title');
3912
+ const chatMenuUsageBtn = document.getElementById('chat-menu-usage');
3913
+ bindMenuAction(chatMenuUsageBtn, (event) => {
3914
+ event.preventDefault();
3915
+ event.stopPropagation();
3916
+ lockChatActionsMenuShield(350);
3917
+ closeChatActionsMenu();
3918
+ showUsage().catch((error) => {
3919
+ alert(error?.message || 'Failed to load usage');
3920
+ });
3921
+ });
3922
+ bindMenuAction(chatMenuEditBtn, (event) => {
3923
+ event.preventDefault();
3924
+ event.stopPropagation();
3925
+ lockChatActionsMenuShield(350);
3926
+ closeChatActionsMenu();
3927
+ openChatTitleEditor();
3928
+ });
3929
+ const chatMenuCompactBtn = document.getElementById('chat-menu-compact');
3930
+ if (chatMenuCompactBtn && !chatMenuCompactBtn.hidden && !chatMenuCompactBtn.disabled) {
3931
+ bindMenuAction(chatMenuCompactBtn, (event) => {
3932
+ event.preventDefault();
3933
+ event.stopPropagation();
3934
+ lockChatActionsMenuShield(350);
3935
+ closeChatActionsMenu();
3936
+ compactCurrentConversation().catch((error) => {
3937
+ alert(error?.message || 'Failed to compact conversation');
3938
+ });
3939
+ });
3940
+ }
3941
+ const chatMenuShareConversationBtn = document.getElementById('chat-menu-share-conversation');
3942
+ bindMenuAction(chatMenuShareConversationBtn, (event) => {
3943
+ event.preventDefault();
3944
+ event.stopPropagation();
3945
+ lockChatActionsMenuShield(350);
3946
+ closeChatActionsMenu();
3947
+ const convId = String(currentConvId || '').trim();
3948
+ if (!convId) {
3949
+ showTransientRelayNotice('Select a conversation first.');
3950
+ return;
3951
+ }
3952
+ createConversationShareLink(convId).then(async (result) => {
3953
+ const shareUrl = String(result?.shareUrl || '').trim();
3954
+ if (!shareUrl) {
3955
+ showTransientRelayNotice(result?.error || 'Could not create share link.');
3956
+ return;
3957
+ }
3958
+ const copied = await copyTextToClipboard(shareUrl);
3959
+ showTransientRelayNotice(copied ? 'Share link copied to clipboard.' : shareUrl);
3960
+ }).catch((error) => {
3961
+ showTransientRelayNotice(error?.message || 'Could not create share link.');
3962
+ });
3963
+ });
3964
+ const chatMenuRefreshHistoryBtn = document.getElementById('chat-menu-refresh-history');
3965
+ if (chatMenuRefreshHistoryBtn && !chatMenuRefreshHistoryBtn.hidden && !chatMenuRefreshHistoryBtn.disabled) {
3966
+ bindMenuAction(chatMenuRefreshHistoryBtn, (event) => {
3967
+ event.preventDefault();
3968
+ event.stopPropagation();
3969
+ lockChatActionsMenuShield(350);
3970
+ closeChatActionsMenu();
3971
+ runConversationHistoryRefresh({ source: 'menu' }).catch(() => {});
3972
+ });
3973
+ }
3974
+ const chatMenuSelectModelsBtn = document.getElementById('chat-menu-select-models');
3975
+ bindMenuAction(chatMenuSelectModelsBtn, (event) => {
3976
+ event.preventDefault();
3977
+ event.stopPropagation();
3978
+ lockChatActionsMenuShield(350);
3979
+ closeChatActionsMenu();
3980
+ openSelectModelsModal().catch((error) => {
3981
+ alert(error?.message || 'Failed to open model selector');
3982
+ });
3983
+ });
3984
+ const chatMenuChangeCwdBtn = document.getElementById('chat-menu-change-cwd');
3985
+ bindMenuAction(chatMenuChangeCwdBtn, (event) => {
3986
+ event.preventDefault();
3987
+ event.stopPropagation();
3988
+ lockChatActionsMenuShield(350);
3989
+ closeChatActionsMenu();
3990
+ openChangeCwdModal();
3991
+ });
3992
+ const chatMenuGitChangesBtn = document.getElementById('chat-menu-git-changes');
3993
+ bindMenuAction(chatMenuGitChangesBtn, (event) => {
3994
+ event.preventDefault();
3995
+ event.stopPropagation();
3996
+ lockChatActionsMenuShield(350);
3997
+ closeChatActionsMenu();
3998
+ openGitChangesModal();
3999
+ });
4000
+ const chatMenuSettingsBtn = document.getElementById('chat-menu-settings');
4001
+ bindMenuAction(chatMenuSettingsBtn, (event) => {
4002
+ event.preventDefault();
4003
+ event.stopPropagation();
4004
+ lockChatActionsMenuShield(350);
4005
+ closeChatActionsMenu();
4006
+ openSettingsModal();
4007
+ });
4008
+ const chatMenuRestartRelayBtn = document.getElementById('chat-menu-restart-relay');
4009
+ bindMenuAction(chatMenuRestartRelayBtn, (event) => {
4010
+ event.preventDefault();
4011
+ event.stopPropagation();
4012
+ lockChatActionsMenuShield(350);
4013
+ closeChatActionsMenu();
4014
+ openRestartRelayConfirmation();
4015
+ });
4016
+ const chatMenuEmptyQueueBtn = document.getElementById('chat-menu-empty-queue');
4017
+ bindMenuAction(chatMenuEmptyQueueBtn, (event) => {
4018
+ event.preventDefault();
4019
+ event.stopPropagation();
4020
+ openEmptyQueueConfirmation();
4021
+ });
4022
+ const chatMenuKillBtn = document.getElementById('chat-menu-kill-session');
4023
+ bindMenuAction(chatMenuKillBtn, (event) => {
4024
+ event.preventDefault();
4025
+ event.stopPropagation();
4026
+ lockChatActionsMenuShield(350);
4027
+ closeChatActionsMenu();
4028
+ openKillSessionConfirmation();
4029
+ });
4030
+ }
4031
+ const sidebarToggleBtn = document.getElementById('sidebar-toggle');
4032
+ bindTapAction(sidebarToggleBtn, (event) => {
4033
+ event.preventDefault();
4034
+ event.stopPropagation();
4035
+ toggleSidebar();
4036
+ });
4037
+ if (!window.__chatActionsMenuBound) {
4038
+ window.__chatActionsMenuBound = true;
4039
+ document.addEventListener('click', (event) => {
4040
+ const menuWrap = document.getElementById('chat-actions-menu-wrap');
4041
+ if (!menuWrap) return;
4042
+ if (menuWrap.contains(event.target)) return;
4043
+ closeChatActionsMenu();
4044
+ });
4045
+ document.addEventListener('keydown', (event) => {
4046
+ if (event.key !== 'Escape') return;
4047
+ closeChatActionsMenu();
4048
+ });
4049
+ }
4050
+ const chatTitleEditor = document.getElementById('chat-title-editor');
4051
+ if (chatTitleEditor && chatTitleEditor.dataset.bound !== '1') {
4052
+ chatTitleEditor.dataset.bound = '1';
4053
+ chatTitleEditor.addEventListener('submit', (event) => {
4054
+ event.preventDefault();
4055
+ submitChatTitleEditor().catch((error) => {
4056
+ alert(error?.message || 'Failed to update conversation title');
4057
+ });
4058
+ });
4059
+ }
4060
+ const chatTitleInput = document.getElementById('chat-title-input');
4061
+ if (chatTitleInput && chatTitleInput.dataset.bound !== '1') {
4062
+ chatTitleInput.dataset.bound = '1';
4063
+ chatTitleInput.addEventListener('keydown', (event) => {
4064
+ if (event.key === 'Escape') {
4065
+ event.preventDefault();
4066
+ closeChatTitleEditor();
4067
+ }
4068
+ });
4069
+ }
4070
+ const chatTitleCancelBtn = document.getElementById('chat-title-cancel-btn');
4071
+ if (chatTitleCancelBtn && chatTitleCancelBtn.dataset.bound !== '1') {
4072
+ chatTitleCancelBtn.dataset.bound = '1';
4073
+ chatTitleCancelBtn.addEventListener('click', (event) => {
4074
+ event.preventDefault();
4075
+ event.stopPropagation();
4076
+ closeChatTitleEditor();
4077
+ });
4078
+ }
4079
+ if (sharedMode) {
4080
+ initFullscreenButton();
4081
+ initConversationHistoryLazyLoading();
4082
+ initBubbleActionHandlers();
4083
+ initMessageScrollPersistence();
4084
+ syncChatTitleControls();
4085
+ await initSharedConversationReader();
4086
+ return;
4087
+ }
4088
+ initAutoCompactWindowControl();
4089
+ initThinkingControl();
4090
+ initModeSelector();
4091
+ initModelSelector();
4092
+ initReasoningSelector();
4093
+ initContextTierSelector();
4094
+ const modelMetadataRetryBtn = document.getElementById('model-metadata-retry-btn');
4095
+ if (modelMetadataRetryBtn && modelMetadataRetryBtn.dataset.bound !== '1') {
4096
+ modelMetadataRetryBtn.dataset.bound = '1';
4097
+ modelMetadataRetryBtn.addEventListener('click', () => {
4098
+ void retryModelMetadataRefresh();
4099
+ });
4100
+ }
4101
+ syncModelMetadataBlocker();
4102
+ const status = await refreshWorkspaceRootHints();
4103
+ syncQueueStatusMenuEntry(status);
4104
+ setSessionWorkerStatesFromStatusPayload(status?.sessionWorker || null);
4105
+ await refreshOpenAISettingsState();
4106
+ await refreshCursorSettingsState();
4107
+ await refreshGrokSettingsState();
4108
+ await refreshModelCatalog(true);
4109
+ initFullscreenButton();
4110
+ initInstallButton();
4111
+ syncRefreshHistoryMenuState();
4112
+ initChatTitleCopy();
4113
+ initEmojiPicker();
4114
+ initSlashAutocomplete();
4115
+ initConversationListLazyLoading();
4116
+ initConversationHistoryLazyLoading();
4117
+ initBubbleActionHandlers();
4118
+ initMessageScrollPersistence();
4119
+ initComposerAttachmentInput();
4120
+ initMessageSearchView({ openConversation });
4121
+ initGitChangesView();
4122
+ syncChatTitleControls();
4123
+ connectSocket();
4124
+ startRelayConnectionWatchdog();
4125
+ startDeviceVisibilityHeartbeat();
4126
+ startRelayQuestionPolling();
4127
+ startRelayBoardPolling();
4128
+ startSessionWorkerStatusPolling();
4129
+ startLiveConversationPolling();
4130
+ initPushNotificationClientHooks();
4131
+ // Browsers without Background Sync replay the outbox from the page instead.
4132
+ void initOutboxFallbackReplay();
4133
+ await loadConversations();
4134
+ const pushConversationId = consumePushConversationDeepLink();
4135
+ if (pushConversationId) {
4136
+ await openConversation(pushConversationId).catch(() => {});
4137
+ }
4138
+ await loadRelayQuestions(currentConvId);
4139
+ await loadRelayBoards();
4140
+ updateCompactButton();
4141
+ document.getElementById('msg-input').focus();
4142
+ }
4143
+
4144
+ async function doAuth() {
4145
+ const tokenInput = document.getElementById('token-input');
4146
+ const val = tokenInput.value.trim() || getTokenFromUrl();
4147
+ if (!val) return showAuthError('Please enter a token');
4148
+ const result = await verifyToken(val);
4149
+ if (result?.ok) {
4150
+ setToken('');
4151
+ tokenInput.value = '';
4152
+ await startAppWithErrorHandling();
4153
+ } else {
4154
+ showAuthError(resolveAuthErrorMessage(result));
4155
+ }
4156
+ }
4157
+
4158
+
4159
+ async function bootstrap() {
4160
+ if (ensureTrailingSlashPath()) return;
4161
+ const sharedMode = isSharedReaderMode();
4162
+ if (sharedMode && navigator.serviceWorker?.getRegistrations) {
4163
+ navigator.serviceWorker.getRegistrations()
4164
+ .then((registrations) => Promise.all(registrations.map((registration) => registration.unregister().catch(() => false))))
4165
+ .catch(() => {});
4166
+ }
4167
+ if (!sharedMode) {
4168
+ await applyPwaManifestFromSettings();
4169
+ }
4170
+ if (!sharedMode) {
4171
+ registerPwaShell();
4172
+ initInstallButton();
4173
+ }
4174
+ if (sharedMode) {
4175
+ await startAppWithErrorHandling();
4176
+ return;
4177
+ }
4178
+ const urlToken = getTokenFromUrl();
4179
+ if (urlToken) stripTokenFromUrl();
4180
+ const persistedToken = consumeLegacyPersistedAuthToken();
4181
+ const bootstrapToken = String(urlToken || persistedToken || '').trim();
4182
+ const existingSession = await verifyExistingSession(bootstrapToken);
4183
+ if (existingSession?.ok) {
4184
+ setToken('');
4185
+ await startAppWithErrorHandling();
4186
+ return;
4187
+ }
4188
+ if (bootstrapToken) {
4189
+ setToken('');
4190
+ document.getElementById('token-input').value = '';
4191
+ showAuthGate(resolveAuthErrorMessage(existingSession));
4192
+ return;
4193
+ }
4194
+ showAuthGate();
4195
+ }
4196
+
4197
+ window.updateTheme = updateTheme;
4198
+ window.updateFontScaleFromSelect = updateFontScaleFromSelect;
4199
+ window.updatePwaAppName = updatePwaAppName;
4200
+ window.updateDefaultSessionWorkspaceRootSetting = updateDefaultSessionWorkspaceRootSetting;
4201
+ window.saveOpenAISettings = saveOpenAISettings;
4202
+ window.removeOpenAISettings = removeOpenAISettings;
4203
+ window.toggleOpenAIProvider = toggleOpenAIProvider;
4204
+ window.saveClaudeSettings = saveClaudeSettings;
4205
+ window.saveCopilotSettings = saveCopilotSettings;
4206
+ window.toggleClaudeProvider = toggleClaudeProvider;
4207
+ window.startClaudeRelogin = startClaudeRelogin;
4208
+ window.submitClaudeLoginCodeFromInput = submitClaudeLoginCodeFromInput;
4209
+ window.handleClaudeLoginCodeKey = handleClaudeLoginCodeKey;
4210
+ window.cancelClaudeRelogin = cancelClaudeRelogin;
4211
+ window.copyClaudeLoginUrl = copyClaudeLoginUrl;
4212
+ window.openClaudeLogoutConfirmation = openClaudeLogoutConfirmation;
4213
+ window.confirmClaudeLogout = confirmClaudeLogout;
4214
+ window.startGrokSignIn = startGrokSignIn;
4215
+ window.cancelGrokSignIn = cancelGrokSignIn;
4216
+ window.copyGrokLoginUrl = copyGrokLoginUrl;
4217
+ window.openGrokLogoutConfirmation = openGrokLogoutConfirmation;
4218
+ window.confirmGrokLogout = confirmGrokLogout;
4219
+ window.confirmCliInstall = confirmCliInstall;
4220
+ window.runCliInstall = runCliInstall;
4221
+ window.cancelCliInstallRun = cancelCliInstallRun;
4222
+ window.saveGrokSettings = saveGrokSettings;
4223
+ window.toggleGrokProvider = toggleGrokProvider;
4224
+ window.saveCursorSettings = saveCursorSettings;
4225
+ window.removeCursorSettings = removeCursorSettings;
4226
+ window.saveCursorAllowanceSettings = saveCursorAllowanceSettings;
4227
+ window.resetCursorAllowanceAccounting = resetCursorAllowanceAccounting;
4228
+ window.saveCursorDashboardToken = saveCursorDashboardToken;
4229
+ window.removeCursorDashboardToken = removeCursorDashboardToken;
4230
+ window.saveGrokAllowanceSettings = saveGrokAllowanceSettings;
4231
+ window.resetGrokAllowanceAccounting = resetGrokAllowanceAccounting;
4232
+ window.toggleCursorProvider = toggleCursorProvider;
4233
+ window.updateShowSuspendHostSetting = updateShowSuspendHostSetting;
4234
+ window.updateWindowsAutostartSettingFromToggle = updateWindowsAutostartSettingFromToggle;
4235
+ window.previewTurnCeilingSetting = previewTurnCeilingSetting;
4236
+ window.updateTurnCeilingSetting = updateTurnCeilingSetting;
4237
+ window.previewBackgroundTaskTimeoutSetting = previewBackgroundTaskTimeoutSetting;
4238
+ window.updateBackgroundTaskTimeoutSetting = updateBackgroundTaskTimeoutSetting;
4239
+ window.togglePushOnThisDevice = togglePushOnThisDevice;
4240
+ window.updatePushPreferencesFromControls = updatePushPreferencesFromControls;
4241
+ window.openSettingsModal = openSettingsModal;
4242
+ window.closeSettingsModal = closeSettingsModal;
4243
+ window.doAuth = doAuth;
4244
+ window.initApp = initApp;
4245
+ window.connectSocket = connectSocket;
4246
+ window.openSidebar = openSidebar;
4247
+ window.closeSidebar = closeSidebar;
4248
+ window.toggleSidebar = toggleSidebar;
4249
+ window.showUsage = showUsage;
4250
+ window.openChangeCwdModal = openChangeCwdModal;
4251
+ window.confirmChangeCwd = confirmChangeCwd;
4252
+ window.confirmChangeCwdAndLaunch = confirmChangeCwdAndLaunch;
4253
+ window.showContext = showContext;
4254
+ window.promptInstallApp = promptInstallApp;
4255
+ window.toggleFullscreen = toggleFullscreen;
4256
+ window.syncAutoModelAvailability = syncAutoModelAvailability;
4257
+ window.openRepoBrowser = openRepoBrowser;
4258
+ window.closeRepoBrowser = closeRepoBrowser;
4259
+ window.loadRepoBrowserTree = loadRepoBrowserTree;
4260
+ window.refreshRepoBrowser = refreshRepoBrowser;
4261
+ window.initModeSelector = initModeSelector;
4262
+ window.initModelSelector = initModelSelector;
4263
+ window.refreshModelCatalog = refreshModelCatalog;
4264
+ window.retryModelMetadataRefresh = retryModelMetadataRefresh;
4265
+ window.isModelMetadataBlocked = () => modelMetadataBlocked || !isModelMetadataHealthy();
4266
+ window.saveSelectedModelsFromModal = saveSelectedModelsFromModal;
4267
+ window.selectedModelValue = selectedModelValue;
4268
+ window.selectedReasoningEffortValue = selectedReasoningEffortValue;
4269
+ window.getPreferredModelSelection = () => selectedModelValue();
4270
+ window.applyConversationPreferences = applyConversationPreferencesForConversation;
4271
+ window.applyModelCatalogState = updateModelCatalogState;
4272
+ window.updateCliStatus = updateCliStatus;
4273
+ window.showAuthError = showAuthError;
4274
+ window.registerPwaShell = registerPwaShell;
4275
+ window.newConversation = newConversation;
4276
+ window.confirmNewConversationModel = confirmNewConversationModel;
4277
+ window.closeNewConversationModelModal = closeNewConversationModelModal;
4278
+ window.deleteConv = deleteConv;
4279
+ window.openConversation = openConversation;
4280
+ window.toggleStatusView = toggleStatusView;
4281
+ window.refreshConversations = refreshConversations;
4282
+ window.renderConvList = renderConvList;
4283
+ window.handleAttachmentInput = handleAttachmentInput;
4284
+ window.retryAttachmentUpload = retryAttachmentUpload;
4285
+ window.removeAttachment = removeAttachment;
4286
+ window.clearAttachments = clearAttachments;
4287
+ window.openUploadedAttachmentViewer = openUploadedAttachmentViewer;
4288
+ window.setFilePreviewMode = setFilePreviewMode;
4289
+ window.toggleFilePreviewHtml = toggleFilePreviewHtml;
4290
+ window.closeFilePreview = closeFilePreview;
4291
+ window.goBackFilePreview = goBackFilePreview;
4292
+ window.openWorkspaceFilePreview = openWorkspaceFilePreview;
4293
+ window.openWorkspaceFilePreviewFromRepo = openWorkspaceFilePreviewFromRepo;
4294
+ window.setRepoBrowserRoot = setRepoBrowserRoot;
4295
+ window.setRepoBrowserViewMode = setRepoBrowserViewMode;
4296
+ window.toggleRepoBrowserHidden = toggleRepoBrowserHidden;
4297
+ window.toggleRepoBrowserHeavy = toggleRepoBrowserHeavy;
4298
+ window.focusRepoTree = focusRepoTree;
4299
+ window.setRepoCurrentPath = setRepoCurrentPath;
4300
+ window.confirmRepoBrowserCwdPick = confirmRepoBrowserCwdPick;
4301
+ window.toggleEmojiPicker = toggleEmojiPicker;
4302
+ window.submitRelayQuestionChoice = submitRelayQuestionChoice;
4303
+ window.submitRelayQuestionAnswer = submitRelayQuestionAnswer;
4304
+ window.submitRelayStructuredAnswer = submitRelayStructuredAnswer;
4305
+ window.onRelayQuestionDraftInput = onRelayQuestionDraftInput;
4306
+ window.handleRelayQuestionKey = handleRelayQuestionKey;
4307
+ window.openPendingQuestionFromBanner = openPendingQuestionFromBanner;
4308
+ window.submitRelayBoardAction = submitRelayBoardAction;
4309
+ window.compactCurrentConversation = compactCurrentConversation;
4310
+ window.sendMessage = sendMessage;
4311
+ window.syncComposerControlState = syncComposerControlState;
4312
+ window.persistComposerAttachments = persistComposerAttachments;
4313
+ window.appendMessage = appendMessage;
4314
+ window.loadOlderConversationMessages = loadOlderConversationMessages;
4315
+ window.handleKey = handleKey;
4316
+ window.setImageEditTarget = setImageEditTarget;
4317
+ window.clearImageEditTarget = clearImageEditTarget;
4318
+ window.jumpToImageParent = jumpToImageParent;
4319
+ window.autoResize = autoResize;
4320
+ window.updateComposerSlashMenu = updateComposerSlashMenu;
4321
+ window.closeSummaryModal = closeSummaryModal;
4322
+ window.refreshSummaryModal = refreshSummaryModal;
4323
+ window.renderSummaryModalContent = renderSummaryModalContent;
4324
+ window.setSummaryModalLoading = setSummaryModalLoading;
4325
+ window.openSummaryModal = openSummaryModal;
4326
+ window.syncChatTitleControls = syncChatTitleControls;
4327
+ window.closeChatActionsMenu = closeChatActionsMenu;
4328
+ window.openSuspendHostConfirmation = openSuspendHostConfirmation;
4329
+ window.confirmKillCurrentSession = confirmKillCurrentSession;
4330
+ window.confirmRestartWebRelay = confirmRestartWebRelay;
4331
+ window.confirmSuspendHost = confirmSuspendHost;
4332
+ window.confirmEmptyQueue = confirmEmptyQueue;
4333
+ window.openMessageSearchModal = openMessageSearchModal;
4334
+ window.closeMessageSearchModal = closeMessageSearchModal;
4335
+ window.openGitChangesModal = openGitChangesModal;
4336
+ window.closeGitChangesModal = closeGitChangesModal;
4337
+ window.openGitDiffViewer = openGitDiffViewer;
4338
+ window.closeGitDiffViewer = closeGitDiffViewer;
4339
+ window.setGitDiffMode = setGitDiffMode;
4340
+ window.copyExternalLinkUrl = copyExternalLinkUrl;
4341
+ window.retryExternalLinkOpen = retryExternalLinkOpen;
4342
+
4343
+ window.addEventListener('copilot:external-link-fallback', (event) => {
4344
+ showExternalLinkFallback(event.detail?.url);
4345
+ });
4346
+
4347
+ bootstrap();