@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,1141 @@
1
+ import {
2
+ conversations,
3
+ currentConvId,
4
+ workspaceRootPath,
5
+ defaultSessionWorkspaceRootPath,
6
+ getConversationCurrentWorkspaceRootPath,
7
+ getRecentWorkspaceRoots,
8
+ fmtDate,
9
+ parseTimestampMs,
10
+ escHtml,
11
+ repoBrowserState,
12
+ getSessionWorkerState,
13
+ resolveConversationUiState,
14
+ setCurrentConv,
15
+ showTransientRelayNotice,
16
+ updateSessionPill,
17
+ updateCompactButton,
18
+ closeSidebar,
19
+ applyContextUsageBar,
20
+ isMobileComposerViewport,
21
+ releaseComposerFocusAfterSend,
22
+ loadConversationScrollTop,
23
+ loadConversationLoadedMessageCount,
24
+ saveConversationScrollTop,
25
+ IS_SHARED_VIEW,
26
+ } from './store.js';
27
+ import {
28
+ loadConversations as loadConversationsApi,
29
+ loadConversation,
30
+ deleteConversation as deleteConversationApi,
31
+ bootstrapConversationSession,
32
+ scheduleContextUsageRefresh,
33
+ loadModelCatalog,
34
+ loadClaudeSettings,
35
+ loadCursorSettings,
36
+ loadGrokSettings,
37
+ loadOpenAISettings,
38
+ } from './api-client.js';
39
+ import { renderMessages, restoreInFlightThinking, focusConversationMessageById, flushConversationDraft, hydrateConversationDraft } from './conversation-view.js';
40
+ import { setBackgroundTasksConversation, setConversationBackgroundTasks } from './background-tasks-view.mjs';
41
+ import { mergeConversationPreviews } from './preview-cards.mjs';
42
+ import { loadRelayQuestions, getPendingQuestionCountsByConversation } from './ask-user-view.js';
43
+ import { loadRelayBoards } from './relay-board-view.js';
44
+ import { clearAttachments, setRepoBrowserSessionInfo, loadRepoBrowserTree, getRepoBrowserLaunchCwdPath, openRepoBrowserForCwdPick } from './attachments-view.js';
45
+ import { buildKnownCwdOptions, normalizeKnownCwdPath } from './known-cwd-options.mjs';
46
+ import { shouldApplyConversationLoad } from './activity-replay-state.mjs';
47
+ import { createInfiniteLoader } from './infinite-loader.js';
48
+ import {
49
+ buildNewConversationModelChoices,
50
+ reasoningChoicesForProviderModel,
51
+ resolvePreferredReasoningEffort,
52
+ } from './new-conversation-model-choice.mjs';
53
+ import { isReasoningOffUnsupported, reasoningEffortOptionLabel, reasoningEffortOptionTitle } from './reasoning-effort-labels.mjs';
54
+ import {
55
+ conversationProviderIndicatorKey,
56
+ conversationProviderIndicatorLabel,
57
+ } from './conversation-provider-indicator.mjs';
58
+ import { leaveStatusView } from './status-view.mjs';
59
+
60
+ const PROCESSING_DOT_FRAMES = [' ', '. ', '.. ', '...'];
61
+ const PROCESSING_DOT_INTERVAL_MS = 1000;
62
+ const LOCAL_PROCESSING_STALE_MS = 5 * 60 * 1000;
63
+ const CONVERSATION_LIST_PAGE_SIZE = 40;
64
+ const REASONING_STORAGE_KEY = 'copilot_selected_reasoning_effort';
65
+ // Shared with the composer (bootstrap.js). The modal used to read and write its
66
+ // own 'copilot_model' key, so a New Chat selection never reached the composer.
67
+ // (bootstrap.js migrates the old key on startup.)
68
+ const MODEL_STORAGE_KEY = 'copilot_selected_model';
69
+ const MODE_STORAGE_KEY = 'copilot_selected_mode';
70
+ // Claude 1M-context ids are stored as "model[1m]"; the modal only offers base ids.
71
+ const CLAUDE_LONG_CONTEXT_PATTERN = /\[1m\]$/i;
72
+ const OPENAI_IMAGE_SIZE_STORAGE_KEY = 'copilot_openai_image_size';
73
+ const NEW_CHAT_CWD_STORAGE_KEY = 'copilot_new_chat_cwd';
74
+ const NEW_CHAT_CUSTOM_CWD_VALUE = '__custom__';
75
+ let processingDotFrame = 0;
76
+ let processingDotTimer = null;
77
+ let lastConvListHtml = '';
78
+ let openConversationVersion = 0;
79
+ let newConversationInFlight = false;
80
+
81
+ // New Chat stays in flight until the created conversation is open, which takes
82
+ // a sidebar refresh and a full message load. The button has to say so: it used
83
+ // to look enabled the whole time while silently ignoring clicks.
84
+ function setNewConversationInFlight(inFlight) {
85
+ newConversationInFlight = inFlight;
86
+ const button = document.getElementById('new-conv-btn');
87
+ if (!button) return;
88
+ button.disabled = inFlight;
89
+ if (inFlight) button.setAttribute('aria-busy', 'true');
90
+ else button.removeAttribute('aria-busy');
91
+ }
92
+ let newConversationCatalogCache = null;
93
+ let newConversationOpenAISettingsCache = null;
94
+ let newConversationClaudeSettingsCache = null;
95
+ let newConversationCursorSettingsCache = null;
96
+ let newConversationGrokSettingsCache = null;
97
+ let conversationListBoundaryCheckFrame = 0;
98
+ let conversationListAutoLoadBlockedUntil = 0;
99
+ let conversationListPaginationState = {
100
+ hasMore: false,
101
+ nextCursor: null,
102
+ hasPrefetchedPage: false,
103
+ isLoading: false,
104
+ isPrefetching: false,
105
+ hasLoadedOlderPages: false,
106
+ };
107
+
108
+ function mergeConversationRecord(current, next) {
109
+ const merged = {
110
+ ...(current && typeof current === 'object' ? current : {}),
111
+ ...(next && typeof next === 'object' ? next : {}),
112
+ };
113
+ // localTurnStatus is optimistic client state driven by one-shot message_status
114
+ // socket events. If the socket drops between a turn finishing and the event
115
+ // being delivered — a relay restart is the obvious case — the flag would stay
116
+ // 'processing' until it aged out, leaving the list spinner running forever.
117
+ // The server's activeTurn flag is authoritative, so let it clear the flag.
118
+ if (next && typeof next === 'object' && next.activeTurn === false) {
119
+ delete merged.localTurnStatus;
120
+ delete merged.localTurnStatusUpdatedAt;
121
+ }
122
+ return merged;
123
+ }
124
+
125
+ function upsertConversationRecord(record) {
126
+ const id = String(record?.id || '').trim();
127
+ if (!id) return false;
128
+ const hadExistingRecord = !!conversations[id];
129
+ conversations[id] = mergeConversationRecord(conversations[id], record);
130
+ return !hadExistingRecord;
131
+ }
132
+
133
+ function getConversationListElement() {
134
+ return document.getElementById('conv-list');
135
+ }
136
+
137
+ function getConversationListBoundaryDistance() {
138
+ const el = getConversationListElement();
139
+ if (!el) return Number.POSITIVE_INFINITY;
140
+ return Math.max(0, el.scrollHeight - el.clientHeight - el.scrollTop);
141
+ }
142
+
143
+ function scheduleConversationListBoundaryCheck() {
144
+ if (conversationListBoundaryCheckFrame) return;
145
+ conversationListBoundaryCheckFrame = requestAnimationFrame(() => {
146
+ conversationListBoundaryCheckFrame = 0;
147
+ if (Date.now() < conversationListAutoLoadBlockedUntil) return;
148
+ void conversationListLoader.handleBoundaryDistance(getConversationListBoundaryDistance());
149
+ });
150
+ }
151
+
152
+ function applyConversationPage(items = []) {
153
+ for (const conversation of Array.isArray(items) ? items : []) {
154
+ upsertConversationRecord(conversation);
155
+ }
156
+ renderConvList();
157
+ updateCompactButton();
158
+ scheduleConversationListBoundaryCheck();
159
+ }
160
+
161
+ const conversationListLoader = createInfiniteLoader({
162
+ fetchPage: async (cursor) => {
163
+ const response = await loadConversationsApi({
164
+ limit: CONVERSATION_LIST_PAGE_SIZE,
165
+ beforeConversationId: String(cursor?.beforeConversationId || '').trim(),
166
+ beforeUpdatedAt: String(cursor?.beforeUpdatedAt || '').trim(),
167
+ });
168
+ if (!response) throw new Error('Could not load conversations');
169
+ return {
170
+ items: response.conversations || [],
171
+ hasMore: !!response.pageInfo?.hasMore,
172
+ nextCursor: response.pageInfo?.nextCursor || null,
173
+ };
174
+ },
175
+ applyPage: async (page) => {
176
+ applyConversationPage(page.items);
177
+ conversationListPaginationState.hasLoadedOlderPages = true;
178
+ },
179
+ onError: (error) => {
180
+ conversationListAutoLoadBlockedUntil = Date.now() + 2000;
181
+ console.error('Conversation list paging failed:', error);
182
+ },
183
+ onStateChange: (state) => {
184
+ conversationListPaginationState = {
185
+ ...conversationListPaginationState,
186
+ ...state,
187
+ };
188
+ renderConvList();
189
+ },
190
+ });
191
+
192
+ function resetConversationListPageState(pageInfo = null, { preserveProgress = false } = {}) {
193
+ const currentState = conversationListLoader.getState();
194
+ const nextState = preserveProgress && conversationListPaginationState.hasLoadedOlderPages
195
+ ? currentState
196
+ : {
197
+ hasMore: !!pageInfo?.hasMore,
198
+ nextCursor: pageInfo?.nextCursor || null,
199
+ };
200
+ if (!preserveProgress) {
201
+ conversationListPaginationState.hasLoadedOlderPages = false;
202
+ }
203
+ conversationListAutoLoadBlockedUntil = 0;
204
+ conversationListLoader.reset(nextState);
205
+ scheduleConversationListBoundaryCheck();
206
+ }
207
+
208
+ function isConversationProcessing(conversation, workerState) {
209
+ const workerStatus = String(workerState?.status || '').trim().toLowerCase();
210
+ if (workerStatus === 'processing') return true;
211
+ const localTurnStatus = String(conversation?.localTurnStatus || '').trim().toLowerCase();
212
+ if (localTurnStatus === 'processing') {
213
+ const updatedAtMs = Number(conversation?.localTurnStatusUpdatedAt || 0);
214
+ if (!Number.isFinite(updatedAtMs) || updatedAtMs <= 0 || (Date.now() - updatedAtMs) < LOCAL_PROCESSING_STALE_MS) {
215
+ return true;
216
+ }
217
+ }
218
+ const runtimeStatus = String(
219
+ conversation?.runtimeSessionStatus
220
+ || conversation?.runtime_session_status
221
+ || conversation?.status
222
+ || '',
223
+ ).trim().toLowerCase();
224
+ return runtimeStatus === 'processing';
225
+ }
226
+
227
+ function ensureProcessingDotTimer(enabled) {
228
+ if (enabled) {
229
+ if (processingDotTimer) return;
230
+ processingDotTimer = setInterval(() => {
231
+ processingDotFrame = (processingDotFrame + 1) % PROCESSING_DOT_FRAMES.length;
232
+ // Touch only the dot spans: rewriting the whole list every second kills
233
+ // sidebar selections and burns battery for a spinner frame.
234
+ const frame = PROCESSING_DOT_FRAMES[processingDotFrame];
235
+ document.querySelectorAll('#conv-list .conv-processing-dots').forEach((el) => {
236
+ el.textContent = ` ${frame}`;
237
+ });
238
+ }, PROCESSING_DOT_INTERVAL_MS);
239
+ return;
240
+ }
241
+ if (processingDotTimer) {
242
+ clearInterval(processingDotTimer);
243
+ processingDotTimer = null;
244
+ }
245
+ processingDotFrame = 0;
246
+ }
247
+
248
+ export async function loadConversations() {
249
+ await refreshConversations({ preservePagination: false });
250
+ const lastId = localStorage.getItem('copilot_last_conv');
251
+ if (lastId) await openConversation(lastId, { restoreScroll: true });
252
+ }
253
+
254
+ export async function refreshConversations(options = {}) {
255
+ const preservePagination = options?.preservePagination !== false;
256
+ const r = await loadConversationsApi({ limit: CONVERSATION_LIST_PAGE_SIZE });
257
+ if (!r) return;
258
+ if (Array.isArray(r.knownConversationIds)) {
259
+ const knownConversationIds = new Set(
260
+ r.knownConversationIds
261
+ .map((id) => String(id || '').trim())
262
+ .filter(Boolean),
263
+ );
264
+ for (const id of Object.keys(conversations)) {
265
+ if (!knownConversationIds.has(id)) delete conversations[id];
266
+ }
267
+ }
268
+ applyConversationPage(r.conversations || []);
269
+ resetConversationListPageState(r.pageInfo || null, {
270
+ preserveProgress: preservePagination,
271
+ });
272
+ renderConvList();
273
+ updateCompactButton();
274
+ }
275
+
276
+ export function renderConvList() {
277
+ const list = document.getElementById('conv-list');
278
+ const sorted = Object.values(conversations).sort((a, b) => {
279
+ const updatedAtDelta = parseTimestampMs(b.updatedAt) - parseTimestampMs(a.updatedAt);
280
+ if (updatedAtDelta !== 0) return updatedAtDelta;
281
+ return String(b?.id || '').trim().localeCompare(String(a?.id || '').trim());
282
+ });
283
+ const pendingByConversation = getPendingQuestionCountsByConversation();
284
+ let hasProcessingConversation = false;
285
+ const footerHtml = (() => {
286
+ if (conversationListPaginationState.isLoading) {
287
+ return '<div class="conv-list-footer">Loading older conversations…</div>';
288
+ }
289
+ if (conversationListPaginationState.hasMore || conversationListPaginationState.isPrefetching) {
290
+ return '<div class="conv-list-footer">Scroll for older conversations</div>';
291
+ }
292
+ return '';
293
+ })();
294
+ if (sorted.length === 0) {
295
+ ensureProcessingDotTimer(false);
296
+ list.innerHTML = '<div style="padding:12px;color:var(--muted);font-size:0.85rem;text-align:center">No conversations yet</div>';
297
+ lastConvListHtml = '';
298
+ return;
299
+ }
300
+ const conversationView = (conversation) => {
301
+ const pendingCount = Number(pendingByConversation[conversation?.id] || 0);
302
+ const sdkSessionId = String(conversation?.sdkSessionId || '').trim();
303
+ const workerState = sdkSessionId ? getSessionWorkerState(sdkSessionId) : null;
304
+ const visualState = resolveConversationUiState({
305
+ conversation,
306
+ workerState,
307
+ hasPendingQuestion: pendingCount > 0,
308
+ });
309
+ const processing = isConversationProcessing(conversation, workerState);
310
+ if (processing) hasProcessingConversation = true;
311
+ return { visualState, processing };
312
+ };
313
+ const listHtml = `${sorted.map((c) => {
314
+ const view = conversationView(c);
315
+ const processingDots = view.processing ? PROCESSING_DOT_FRAMES[processingDotFrame] : '';
316
+ const providerIndicatorLabel = conversationProviderIndicatorLabel(c);
317
+ const providerIndicatorKey = conversationProviderIndicatorKey(c);
318
+ const providerIndicatorHtml = providerIndicatorLabel
319
+ ? `<span class="conv-provider-indicator"${providerIndicatorKey ? ` data-provider="${providerIndicatorKey}"` : ''}>${providerIndicatorLabel}</span>`
320
+ : '';
321
+ return `
322
+ <div class="conv-item worker-ui-${view.visualState}${c.id === currentConvId ? ' active' : ''}" onclick="openConversation('${c.id}')">
323
+ <div class="conv-title">${escHtml(c.title)}${processingDots ? `<span class="conv-processing-dots">${escHtml(` ${processingDots}`)}</span>` : ''}${c.archived ? ' <span style="font-size:0.68rem;color:var(--muted)">(archived)</span>' : ''}${pendingByConversation[c.id] ? ` <span class="conv-open-questions">${pendingByConversation[c.id]} open</span>` : ''}</div>
324
+ <div class="conv-meta"><span class="conv-meta-primary">${fmtDate(c.updatedAt)} · ${c.messageCount} msg${c.messageCount !== 1 ? 's' : ''}</span>${providerIndicatorHtml}</div>
325
+ <button class="conv-delete" onclick="deleteConv(event,'${c.id}')" title="Delete">🗑</button>
326
+ </div>`;
327
+ }).join('')}${footerHtml}`;
328
+ if (listHtml !== lastConvListHtml) {
329
+ list.innerHTML = listHtml;
330
+ lastConvListHtml = listHtml;
331
+ }
332
+ ensureProcessingDotTimer(hasProcessingConversation);
333
+ window.syncChatTitleControls?.();
334
+ scheduleConversationListBoundaryCheck();
335
+ }
336
+
337
+ export function applyLoadedConversationState(id, response, {
338
+ restoreScroll = false,
339
+ savedScrollTop = null,
340
+ followLiveUpdates = !restoreScroll,
341
+ } = {}) {
342
+ if (!response) {
343
+ setRepoBrowserSessionInfo('', '');
344
+ restoreInFlightThinking(null);
345
+ renderMessages([]);
346
+ window.syncChatTitleControls?.();
347
+ return;
348
+ }
349
+ const existingConversation = conversations[id] || {};
350
+ upsertConversationRecord({
351
+ ...existingConversation,
352
+ id: id,
353
+ sdkSessionId: response.sdkSessionId ?? existingConversation.sdkSessionId ?? null,
354
+ title: response.title ?? existingConversation.title ?? id,
355
+ archived: response.archived ?? existingConversation.archived ?? false,
356
+ compactedInto: response.compactedInto ?? existingConversation.compactedInto ?? null,
357
+ compactedFrom: response.compactedFrom ?? existingConversation.compactedFrom ?? null,
358
+ updatedAt: response.updatedAt ?? existingConversation.updatedAt ?? new Date().toISOString(),
359
+ preferredRelayMode: response.preferredRelayMode ?? existingConversation.preferredRelayMode,
360
+ preferredModel: response.preferredModel ?? existingConversation.preferredModel,
361
+ preferredReasoningEffort: response.preferredReasoningEffort ?? existingConversation.preferredReasoningEffort,
362
+ configuredWorkspaceRootPath: response.configuredWorkspaceRootPath ?? existingConversation.configuredWorkspaceRootPath ?? null,
363
+ configuredWorkspaceRootName: response.configuredWorkspaceRootName ?? existingConversation.configuredWorkspaceRootName ?? null,
364
+ runtimeWorkspaceRootPath: response.runtimeWorkspaceRootPath ?? existingConversation.runtimeWorkspaceRootPath ?? null,
365
+ runtimeWorkspaceRootName: response.runtimeWorkspaceRootName ?? existingConversation.runtimeWorkspaceRootName ?? null,
366
+ currentWorkspaceRootPath: response.currentWorkspaceRootPath ?? existingConversation.currentWorkspaceRootPath ?? null,
367
+ currentWorkspaceRootName: response.currentWorkspaceRootName ?? existingConversation.currentWorkspaceRootName ?? null,
368
+ runtimeProviderType: response.runtimeSession?.providerType
369
+ ?? existingConversation.runtimeProviderType
370
+ ?? 'github',
371
+ runtimeProviderModel: response.runtimeSession?.providerModel
372
+ ?? existingConversation.runtimeProviderModel
373
+ ?? null,
374
+ runtimeModel: response.runtimeSession?.model
375
+ ?? existingConversation.runtimeModel
376
+ ?? null,
377
+ sessionUsageSummary: response.sessionUsageSummary ?? existingConversation.sessionUsageSummary ?? null,
378
+ messageCount: Array.isArray(response.messages)
379
+ ? Math.max(existingConversation.messageCount || 0, response.messages.length)
380
+ : (existingConversation.messageCount || 0),
381
+ });
382
+ renderConvList();
383
+ window.applyConversationPreferences?.(id, {
384
+ preferredRelayMode: response.preferredRelayMode,
385
+ preferredModel: response.preferredModel,
386
+ preferredReasoningEffort: response.preferredReasoningEffort,
387
+ });
388
+ setRepoBrowserSessionInfo(response.sessionRootPath || '', response.sessionRootName || response.title || '');
389
+ // The repo tree deliberately does NOT reload here: this runs on the 900ms
390
+ // live poll, and the bare loadRepoBrowserTree path resets loaded folders and
391
+ // deep selections. The end-of-turn message_status handler refreshes the tree
392
+ // through the restoring path instead.
393
+ const didRenderMessages = renderMessages(response.messages, !restoreScroll, response);
394
+ hydrateConversationDraft(id, {
395
+ draftText: response.draftText,
396
+ draftAttachments: response.draftAttachments,
397
+ draftUpdatedAt: response.draftUpdatedAt,
398
+ draftUpdatedByClientId: response.draftUpdatedByClientId,
399
+ });
400
+ restoreInFlightThinking(response.inFlight || null, followLiveUpdates);
401
+ setBackgroundTasksConversation(id);
402
+ setConversationBackgroundTasks(id, response.backgroundTasks || []);
403
+ // Merge, not replace: this payload only carries one conversation's previews.
404
+ mergeConversationPreviews(id, response.previews || []);
405
+ updateSessionPill(conversations[id], response.runtimeSession || null);
406
+ window.syncChatTitleControls?.();
407
+ if (!restoreScroll || !didRenderMessages) return;
408
+ const el = document.getElementById('messages');
409
+ if (!el) return;
410
+ if (Number.isFinite(savedScrollTop)) {
411
+ el.scrollTop = savedScrollTop;
412
+ saveConversationScrollTop(id, el.scrollTop);
413
+ return;
414
+ }
415
+ el.scrollTop = el.scrollHeight;
416
+ saveConversationScrollTop(id, el.scrollTop);
417
+ }
418
+
419
+ export async function openConversation(id, options = {}) {
420
+ const didLeaveStatusView = leaveStatusView();
421
+ document.getElementById('input-area')?.removeAttribute('hidden');
422
+ const previousConversationId = String(currentConvId || '').trim();
423
+ const nextConversationId = String(id || '').trim();
424
+ if (previousConversationId && nextConversationId && previousConversationId !== nextConversationId) {
425
+ await flushConversationDraft(previousConversationId);
426
+ window.clearImageEditTarget?.();
427
+ }
428
+ const capturedVersion = ++openConversationVersion;
429
+ setCurrentConv(id);
430
+ if (repoBrowserState.activeRoot === 'workspace') {
431
+ repoBrowserState.tree = null;
432
+ repoBrowserState.nodeMap = new Map();
433
+ repoBrowserState.currentPath = '';
434
+ repoBrowserState.truncated = false;
435
+ repoBrowserState.nodeCount = 0;
436
+ repoBrowserState.maxNodes = 0;
437
+ repoBrowserState.loadingPath = '';
438
+ repoBrowserState.error = '';
439
+ }
440
+ closeSidebar();
441
+ clearAttachments();
442
+ document.getElementById('chat-title').textContent = conversations[id]?.title || id;
443
+ if (didLeaveStatusView) {
444
+ restoreInFlightThinking(null);
445
+ renderMessages([]);
446
+ }
447
+ window.syncChatTitleControls?.();
448
+ updateSessionPill(conversations[id], null);
449
+ updateCompactButton();
450
+ renderConvList();
451
+ if (repoBrowserState.open && repoBrowserState.activeRoot === 'workspace') {
452
+ void loadRepoBrowserTree();
453
+ }
454
+
455
+ const focusMessageId = String(options.focusMessageId || '').trim();
456
+ const aroundMessageId = String(options.aroundMessageId || focusMessageId || '').trim();
457
+ const forceFreshWindow = !!aroundMessageId;
458
+ const savedScrollTop = loadConversationScrollTop(id);
459
+ const restoreScroll = !forceFreshWindow && Number.isFinite(savedScrollTop);
460
+ const savedLoadedCount = loadConversationLoadedMessageCount(id);
461
+ const requestLimit = forceFreshWindow
462
+ ? 40
463
+ : (Number.isFinite(savedLoadedCount)
464
+ ? Math.max(20, savedLoadedCount)
465
+ : 20);
466
+ const r = await loadConversation(id, {
467
+ limit: requestLimit,
468
+ aroundMessageId: aroundMessageId || undefined,
469
+ });
470
+ if (!shouldApplyConversationLoad({
471
+ requestedConversationId: id,
472
+ activeConversationId: currentConvId,
473
+ capturedVersion,
474
+ currentVersion: openConversationVersion,
475
+ })) {
476
+ return;
477
+ }
478
+ if (r) {
479
+ applyLoadedConversationState(id, r, { restoreScroll, savedScrollTop });
480
+ if (focusMessageId) {
481
+ requestAnimationFrame(() => {
482
+ focusConversationMessageById(focusMessageId, { behavior: 'smooth', block: 'center' });
483
+ });
484
+ }
485
+ } else {
486
+ setRepoBrowserSessionInfo('', '');
487
+ restoreInFlightThinking(null);
488
+ renderMessages([]);
489
+ }
490
+ await loadRelayQuestions(id);
491
+ await loadRelayBoards();
492
+ if (!shouldApplyConversationLoad({
493
+ requestedConversationId: id,
494
+ activeConversationId: currentConvId,
495
+ capturedVersion,
496
+ currentVersion: openConversationVersion,
497
+ })) {
498
+ return;
499
+ }
500
+ applyContextUsageBar(null);
501
+ scheduleContextUsageRefresh(id, 0);
502
+ const composer = document.getElementById('msg-input');
503
+ if (isMobileComposerViewport()) {
504
+ releaseComposerFocusAfterSend(composer);
505
+ } else {
506
+ composer.focus();
507
+ }
508
+ }
509
+
510
+ function normalizeNewConversationProviderType(value = '') {
511
+ const normalized = String(value || '').trim().toLowerCase();
512
+ if (normalized === 'openai' || normalized === 'openai-byok') return 'openai';
513
+ if (normalized === 'openai-image' || normalized === 'openai-image-byok') return 'openai-image';
514
+ if (normalized === 'claude') return 'claude';
515
+ if (normalized === 'cursor') return 'cursor';
516
+ if (normalized === 'grok') return 'grok';
517
+ return 'github';
518
+ }
519
+
520
+ function bootstrapProviderType(providerType = 'github') {
521
+ const normalized = normalizeNewConversationProviderType(providerType);
522
+ return normalized === 'openai-image' ? 'openai' : normalized;
523
+ }
524
+
525
+ function isOpenAIImageModelId(modelId = '') {
526
+ const value = String(modelId || '').trim().toLowerCase().replace(/^openai\//, '');
527
+ return value.startsWith('gpt-image-') || value.startsWith('dall-e-');
528
+ }
529
+
530
+ function isLikelyOpenAIModelId(modelId = '') {
531
+ const value = String(modelId || '').trim().toLowerCase().replace(/^openai\//, '');
532
+ if (!value) return false;
533
+ return /^gpt-/.test(value) || /^o[134](?:[.-]|$)/.test(value) || /^codex(?:[.-]|$)/.test(value);
534
+ }
535
+
536
+ function modelProvidersForCatalogModel(catalog = {}, modelId = '') {
537
+ const key = String(modelId || '').trim().toLowerCase();
538
+ if (!key) return [];
539
+ const providers = catalog?.providersByModel?.[key];
540
+ if (!Array.isArray(providers)) return [];
541
+ return providers.map((provider) => String(provider || '').trim().toLowerCase()).filter(Boolean);
542
+ }
543
+
544
+ function modelMatchesNewConversationProvider(catalog = {}, modelId = '', providerType = 'github') {
545
+ const normalizedModelId = String(modelId || '').trim();
546
+ if (!normalizedModelId) return false;
547
+ const providers = modelProvidersForCatalogModel(catalog, modelId);
548
+ const normalizedProvider = normalizeNewConversationProviderType(providerType);
549
+ const wantsOpenAI = normalizedProvider === 'openai' || normalizedProvider === 'openai-image';
550
+ const hasOpenAIByok = providers.includes('openai-byok');
551
+ const hasClaude = providers.includes('claude');
552
+ const hasCursor = providers.includes('cursor');
553
+ const hasGrok = providers.includes('grok');
554
+ if (wantsOpenAI) {
555
+ if (hasOpenAIByok) return true;
556
+ const settingsModel = String(newConversationOpenAISettingsCache?.model || '').trim();
557
+ const settingsModels = Array.isArray(newConversationOpenAISettingsCache?.models)
558
+ ? newConversationOpenAISettingsCache.models.map((entry) => String(entry || '').trim()).filter(Boolean)
559
+ : [];
560
+ if (normalizedModelId === settingsModel || settingsModels.includes(normalizedModelId)) return true;
561
+ // Fallback when provider metadata is temporarily stale/missing.
562
+ if (providers.length === 0 && isLikelyOpenAIModelId(normalizedModelId)) return true;
563
+ return false;
564
+ }
565
+ if (normalizedProvider === 'claude') {
566
+ if (hasClaude) return true;
567
+ const claudeModel = String(newConversationClaudeSettingsCache?.model || '').trim();
568
+ const claudeModels = Array.isArray(newConversationClaudeSettingsCache?.models)
569
+ ? newConversationClaudeSettingsCache.models.map((entry) => String(entry || '').trim()).filter(Boolean)
570
+ : [];
571
+ return normalizedModelId === claudeModel || claudeModels.includes(normalizedModelId);
572
+ }
573
+ if (normalizedProvider === 'cursor') {
574
+ if (hasCursor) return true;
575
+ const cursorModel = String(newConversationCursorSettingsCache?.model || '').trim();
576
+ const cursorModels = Array.isArray(newConversationCursorSettingsCache?.models)
577
+ ? newConversationCursorSettingsCache.models.map((entry) => String(entry || '').trim()).filter(Boolean)
578
+ : [];
579
+ return normalizedModelId === cursorModel || cursorModels.includes(normalizedModelId);
580
+ }
581
+ if (normalizedProvider === 'grok') {
582
+ if (hasGrok) return true;
583
+ const grokModel = String(newConversationGrokSettingsCache?.model || '').trim();
584
+ const grokModels = Array.isArray(newConversationGrokSettingsCache?.models)
585
+ ? newConversationGrokSettingsCache.models.map((entry) => String(entry || '').trim()).filter(Boolean)
586
+ : [];
587
+ return normalizedModelId === grokModel || grokModels.includes(normalizedModelId);
588
+ }
589
+ const claudeOnly = hasClaude && providers.every((provider) => provider === 'claude');
590
+ if (claudeOnly) return false;
591
+ const cursorOnly = hasCursor && providers.every((provider) => provider === 'cursor');
592
+ if (cursorOnly) return false;
593
+ const grokOnly = hasGrok && providers.every((provider) => provider === 'grok');
594
+ if (grokOnly) return false;
595
+ return providers.some((provider) => (
596
+ provider !== 'openai-byok'
597
+ && provider !== 'claude'
598
+ && provider !== 'cursor'
599
+ && provider !== 'grok'
600
+ )) || !hasOpenAIByok;
601
+ }
602
+
603
+ function openAIImageSizesForModel(modelId = '') {
604
+ const normalizedModel = String(modelId || '').trim().toLowerCase().replace(/^openai\//, '');
605
+ if (normalizedModel.startsWith('dall-e-2')) return ['256x256', '512x512', '1024x1024'];
606
+ if (normalizedModel.startsWith('dall-e-3')) return ['1024x1024', '1792x1024', '1024x1792'];
607
+ return ['auto', '1024x1024', '1536x1024', '1024x1536'];
608
+ }
609
+
610
+ function syncNewConversationReasoningLabel(providerType = 'github') {
611
+ const label = document.querySelector('label[for="new-conversation-reasoning-select"]');
612
+ if (!label) return;
613
+ label.textContent = normalizeNewConversationProviderType(providerType) === 'openai-image'
614
+ ? 'Quality'
615
+ : 'Reasoning effort';
616
+ }
617
+
618
+ function populateNewConversationSizeSelect(providerType = 'github', selectedModel = '') {
619
+ const select = document.getElementById('new-conversation-size-select');
620
+ const row = document.getElementById('new-conversation-size-row');
621
+ const status = document.getElementById('new-conversation-size-status');
622
+ if (!select || !row) return;
623
+ const normalizedProvider = normalizeNewConversationProviderType(providerType);
624
+ if (normalizedProvider !== 'openai-image') {
625
+ row.style.display = 'none';
626
+ select.innerHTML = '';
627
+ return;
628
+ }
629
+ row.style.display = 'block';
630
+ const sizes = openAIImageSizesForModel(selectedModel);
631
+ const preferred = String(localStorage.getItem(OPENAI_IMAGE_SIZE_STORAGE_KEY) || '').trim().toLowerCase();
632
+ select.innerHTML = '';
633
+ for (const size of sizes) {
634
+ const option = document.createElement('option');
635
+ option.value = size;
636
+ option.textContent = size;
637
+ select.appendChild(option);
638
+ }
639
+ select.value = sizes.includes(preferred) ? preferred : sizes[0];
640
+ if (status) status.textContent = 'Image size used for generated outputs in this chat.';
641
+ }
642
+
643
+ async function populateNewConversationReasoningSelect(selectedModel = '') {
644
+ const select = document.getElementById('new-conversation-reasoning-select');
645
+ const status = document.getElementById('new-conversation-reasoning-status');
646
+ if (!select) return;
647
+ const modelId = String(selectedModel || '').trim().toLowerCase();
648
+ if (!modelId) {
649
+ select.innerHTML = '';
650
+ select.disabled = true;
651
+ if (status) status.textContent = 'No model selected.';
652
+ return;
653
+ }
654
+ const provider = normalizeNewConversationProviderType(
655
+ String(document.getElementById('new-conversation-provider-select')?.value || '').trim().toLowerCase(),
656
+ );
657
+ syncNewConversationReasoningLabel(provider);
658
+ const catalog = newConversationCatalogCache || await loadModelCatalog() || {};
659
+ const efforts = reasoningChoicesForProviderModel(catalog || {}, {
660
+ provider: provider === 'openai-image' ? 'openai' : provider,
661
+ modelId,
662
+ });
663
+ select.innerHTML = '';
664
+ if (!efforts.length) {
665
+ const option = document.createElement('option');
666
+ option.value = '';
667
+ option.textContent = 'Unavailable';
668
+ select.appendChild(option);
669
+ select.value = '';
670
+ select.disabled = true;
671
+ if (status) status.textContent = 'Reasoning metadata unavailable for this model.';
672
+ return;
673
+ }
674
+ const reasoningOffUnsupported = isReasoningOffUnsupported(catalog, provider, modelId);
675
+ for (const effort of efforts) {
676
+ const option = document.createElement('option');
677
+ option.value = effort;
678
+ option.textContent = reasoningEffortOptionLabel(effort, { reasoningOffUnsupported });
679
+ const optionTitle = reasoningEffortOptionTitle(effort);
680
+ if (optionTitle) option.title = optionTitle;
681
+ select.appendChild(option);
682
+ }
683
+ const preferred = resolvePreferredReasoningEffort(efforts, [
684
+ localStorage.getItem(REASONING_STORAGE_KEY),
685
+ ]);
686
+ select.value = preferred || efforts[0];
687
+ select.disabled = false;
688
+ if (status) {
689
+ status.textContent = provider === 'openai-image'
690
+ ? 'Choose output quality for generated images.'
691
+ : 'Choose the effort used when this conversation starts.';
692
+ }
693
+ }
694
+
695
+ function storedComposerModel() {
696
+ return String(localStorage.getItem(MODEL_STORAGE_KEY) || '').trim();
697
+ }
698
+
699
+ function updateNewConversationProviderHelp(provider = 'github') {
700
+ const help = document.getElementById('new-conversation-provider-help');
701
+ if (!help) return;
702
+ const normalizedProvider = normalizeNewConversationProviderType(provider);
703
+ if (normalizedProvider === 'openai-image') {
704
+ help.textContent = 'OpenAI image chats call the Images API directly using your BYOK key.';
705
+ return;
706
+ }
707
+ if (normalizedProvider === 'openai') {
708
+ help.textContent = 'OpenAI models use your saved BYOK API key.';
709
+ return;
710
+ }
711
+ if (normalizedProvider === 'claude') {
712
+ help.textContent = "Claude chats run through the Claude Agent SDK with the relay host's Claude login.";
713
+ return;
714
+ }
715
+ if (normalizedProvider === 'cursor') {
716
+ help.textContent = 'Cursor chats run through the Cursor Agent SDK using your saved Cursor API key.';
717
+ return;
718
+ }
719
+ if (normalizedProvider === 'grok') {
720
+ help.textContent = "Grok chats use the host machine's logged-in Grok credentials (grok login or XAI_API_KEY).";
721
+ return;
722
+ }
723
+ help.textContent = 'Copilot models use your GitHub Copilot runtime.';
724
+ }
725
+
726
+ async function populateNewConversationModelSelect(providerType = 'github') {
727
+ const target = document.getElementById('new-conversation-model-select');
728
+ if (!target) return false;
729
+ target.innerHTML = '';
730
+ const source = document.getElementById('model-select');
731
+ const sourceLabelByValue = new Map(
732
+ Array.from(source?.options || []).map((option) => [
733
+ String(option.value || '').trim(),
734
+ String(option.textContent || option.value || '').trim(),
735
+ ]),
736
+ );
737
+ const catalog = newConversationCatalogCache || await loadModelCatalog();
738
+ if (!catalog || !Array.isArray(catalog.models)) return false;
739
+ const normalizedProvider = normalizeNewConversationProviderType(providerType);
740
+ const choices = buildNewConversationModelChoices(
741
+ catalog.models
742
+ .filter((modelId) => modelMatchesNewConversationProvider(catalog, modelId, normalizedProvider))
743
+ .filter((modelId) => (
744
+ normalizedProvider !== 'openai-image' || isOpenAIImageModelId(modelId)
745
+ ))
746
+ .map((modelId) => {
747
+ const value = String(modelId || '').trim();
748
+ return {
749
+ value,
750
+ label: sourceLabelByValue.get(value) || value,
751
+ };
752
+ }),
753
+ );
754
+ for (const choice of choices) {
755
+ const option = document.createElement('option');
756
+ option.value = choice.value;
757
+ option.textContent = choice.label;
758
+ target.appendChild(option);
759
+ }
760
+ if (!target.options.length) return false;
761
+ const storedModel = storedComposerModel();
762
+ if (storedModel && Array.from(target.options).some((option) => option.value === storedModel)) {
763
+ target.value = storedModel;
764
+ } else if (normalizedProvider !== 'openai-image' && Array.from(target.options).some((option) => option.value === 'auto')) {
765
+ target.value = 'auto';
766
+ }
767
+ updateNewConversationProviderHelp(normalizedProvider);
768
+ syncNewConversationReasoningLabel(normalizedProvider);
769
+ populateNewConversationSizeSelect(normalizedProvider, target.value);
770
+ await populateNewConversationReasoningSelect(target.value);
771
+ return true;
772
+ }
773
+
774
+ function resolveNewConversationDefaultCwd() {
775
+ return normalizeKnownCwdPath(defaultSessionWorkspaceRootPath || workspaceRootPath || '');
776
+ }
777
+
778
+ function getNewConversationSelectedCwd() {
779
+ const select = document.getElementById('new-conversation-cwd-select');
780
+ if (!select) return '';
781
+ if (select.value === NEW_CHAT_CUSTOM_CWD_VALUE) {
782
+ return normalizeKnownCwdPath(document.getElementById('new-conversation-cwd-manual')?.value || '');
783
+ }
784
+ return normalizeKnownCwdPath(select.value || '');
785
+ }
786
+
787
+ function syncNewConversationCwdControls() {
788
+ const select = document.getElementById('new-conversation-cwd-select');
789
+ const manual = document.getElementById('new-conversation-cwd-manual');
790
+ const status = document.getElementById('new-conversation-cwd-status');
791
+ if (!select) return;
792
+ const isCustom = select.value === NEW_CHAT_CUSTOM_CWD_VALUE;
793
+ if (manual) manual.hidden = !isCustom;
794
+ if (!status) return;
795
+ if (isCustom) {
796
+ const manualPath = normalizeKnownCwdPath(manual?.value || '');
797
+ status.textContent = manualPath
798
+ ? `This chat starts in ${manualPath}.`
799
+ : 'Enter the full path of the launch directory on the relay host.';
800
+ return;
801
+ }
802
+ const selectedPath = normalizeKnownCwdPath(select.value || '');
803
+ if (selectedPath) {
804
+ status.textContent = `This chat starts in ${selectedPath}.`;
805
+ return;
806
+ }
807
+ const defaultPath = resolveNewConversationDefaultCwd();
808
+ status.textContent = defaultPath
809
+ ? `This chat starts in ${defaultPath} (relay default).`
810
+ : 'This chat starts in the relay default directory.';
811
+ }
812
+
813
+ function populateNewConversationCwdSelect() {
814
+ const select = document.getElementById('new-conversation-cwd-select');
815
+ if (!select) return;
816
+ const options = buildKnownCwdOptions({
817
+ currentSessionCwd: getConversationCurrentWorkspaceRootPath(currentConvId) || '',
818
+ workspaceRootPath,
819
+ browserCwd: getRepoBrowserLaunchCwdPath(),
820
+ recentRoots: getRecentWorkspaceRoots(),
821
+ });
822
+ select.innerHTML = '';
823
+ const defaultPath = resolveNewConversationDefaultCwd();
824
+ const defaultOption = document.createElement('option');
825
+ defaultOption.value = '';
826
+ defaultOption.textContent = defaultPath ? `Default (${defaultPath})` : 'Default';
827
+ select.appendChild(defaultOption);
828
+ for (const option of options) {
829
+ const entry = document.createElement('option');
830
+ entry.value = option.path;
831
+ entry.textContent = option.path;
832
+ entry.title = option.note ? `${option.label} (${option.note})` : option.label;
833
+ select.appendChild(entry);
834
+ }
835
+ const customOption = document.createElement('option');
836
+ customOption.value = NEW_CHAT_CUSTOM_CWD_VALUE;
837
+ customOption.textContent = 'Custom path…';
838
+ select.appendChild(customOption);
839
+ const storedCwd = normalizeKnownCwdPath(localStorage.getItem(NEW_CHAT_CWD_STORAGE_KEY) || '');
840
+ if (storedCwd) {
841
+ const storedKey = storedCwd.toLowerCase();
842
+ const match = Array.from(select.options).find((option) => (
843
+ option.value !== NEW_CHAT_CUSTOM_CWD_VALUE
844
+ && normalizeKnownCwdPath(option.value).toLowerCase() === storedKey
845
+ ));
846
+ if (match) select.value = match.value;
847
+ }
848
+ if (select.dataset.cwdBound !== '1') {
849
+ select.dataset.cwdBound = '1';
850
+ select.addEventListener('change', () => {
851
+ syncNewConversationCwdControls();
852
+ if (select.value === NEW_CHAT_CUSTOM_CWD_VALUE) {
853
+ document.getElementById('new-conversation-cwd-manual')?.focus?.();
854
+ }
855
+ });
856
+ }
857
+ const manual = document.getElementById('new-conversation-cwd-manual');
858
+ if (manual && manual.dataset.cwdBound !== '1') {
859
+ manual.dataset.cwdBound = '1';
860
+ manual.addEventListener('input', syncNewConversationCwdControls);
861
+ }
862
+ const browse = document.getElementById('new-conversation-cwd-browse');
863
+ if (browse && browse.dataset.cwdBound !== '1') {
864
+ browse.dataset.cwdBound = '1';
865
+ browse.addEventListener('click', (event) => {
866
+ event.preventDefault();
867
+ // The picked path lands in the "Custom path…" manual input, so it goes
868
+ // through the exact same submit/validation path as a typed one.
869
+ openRepoBrowserForCwdPick((pickedPath) => {
870
+ const cwdSelect = document.getElementById('new-conversation-cwd-select');
871
+ const manualInput = document.getElementById('new-conversation-cwd-manual');
872
+ if (cwdSelect) cwdSelect.value = NEW_CHAT_CUSTOM_CWD_VALUE;
873
+ if (manualInput) manualInput.value = pickedPath;
874
+ syncNewConversationCwdControls();
875
+ });
876
+ });
877
+ }
878
+ syncNewConversationCwdControls();
879
+ }
880
+
881
+ async function openNewConversationModelModal() {
882
+ const [catalog, settings, claudeSettings, cursorSettings, grokSettings] = await Promise.all([
883
+ loadModelCatalog(),
884
+ loadOpenAISettings(),
885
+ loadClaudeSettings(),
886
+ loadCursorSettings(),
887
+ loadGrokSettings(),
888
+ ]);
889
+ newConversationCatalogCache = catalog || null;
890
+ newConversationOpenAISettingsCache = settings || null;
891
+ newConversationClaudeSettingsCache = claudeSettings || null;
892
+ newConversationCursorSettingsCache = cursorSettings || null;
893
+ newConversationGrokSettingsCache = grokSettings || null;
894
+ const providerSelect = document.getElementById('new-conversation-provider-select');
895
+ if (providerSelect) {
896
+ const options = [{ value: 'github', label: 'Copilot' }];
897
+ if (settings?.enabled === true) {
898
+ options.push({ value: 'openai', label: 'OpenAI (BYOK)' });
899
+ options.push({ value: 'openai-image', label: 'OpenAI Image (BYOK)' });
900
+ }
901
+ if (claudeSettings?.enabled === true) {
902
+ options.push({ value: 'claude', label: 'Claude SDK' });
903
+ }
904
+ if (cursorSettings?.enabled === true) {
905
+ options.push({ value: 'cursor', label: 'Cursor SDK' });
906
+ }
907
+ if (grokSettings?.enabled === true) {
908
+ options.push({ value: 'grok', label: 'Grok' });
909
+ }
910
+ providerSelect.innerHTML = '';
911
+ for (const option of options) {
912
+ const entry = document.createElement('option');
913
+ entry.value = option.value;
914
+ entry.textContent = option.label;
915
+ providerSelect.appendChild(entry);
916
+ }
917
+ // With Copilot alone the provider row is a single dead option — hide it.
918
+ const providerRow = document.getElementById('new-conversation-provider-row');
919
+ if (providerRow) providerRow.hidden = options.length <= 1;
920
+ const preferredProvider = 'github';
921
+ providerSelect.value = options.some((option) => option.value === preferredProvider)
922
+ ? preferredProvider
923
+ : options[0].value;
924
+ if (providerSelect.dataset.modelsBound !== '1') {
925
+ providerSelect.dataset.modelsBound = '1';
926
+ providerSelect.addEventListener('change', () => {
927
+ void populateNewConversationModelSelect(providerSelect.value);
928
+ });
929
+ }
930
+ }
931
+
932
+ if (!(await populateNewConversationModelSelect(providerSelect?.value || 'github'))) {
933
+ showTransientRelayNotice('No model is currently available for a new conversation.', 5000);
934
+ return;
935
+ }
936
+ const modelSelect = document.getElementById('new-conversation-model-select');
937
+ if (modelSelect && modelSelect.dataset.reasoningBound !== '1') {
938
+ modelSelect.dataset.reasoningBound = '1';
939
+ modelSelect.addEventListener('change', () => {
940
+ const provider = String(document.getElementById('new-conversation-provider-select')?.value || '').trim();
941
+ populateNewConversationSizeSelect(provider, modelSelect.value);
942
+ void populateNewConversationReasoningSelect(modelSelect.value);
943
+ });
944
+ }
945
+ populateNewConversationCwdSelect();
946
+ const modal = document.getElementById('new-conversation-model-modal');
947
+ if (!modal) return;
948
+ modal.classList.add('visible');
949
+ modal.setAttribute('aria-hidden', 'false');
950
+ setTimeout(() => document.getElementById('new-conversation-model-select')?.focus(), 0);
951
+ }
952
+
953
+ function hideNewConversationModelModal() {
954
+ const modal = document.getElementById('new-conversation-model-modal');
955
+ modal?.classList.remove('visible');
956
+ modal?.setAttribute('aria-hidden', 'true');
957
+ }
958
+
959
+ export function closeNewConversationModelModal() {
960
+ if (newConversationInFlight) return;
961
+ hideNewConversationModelModal();
962
+ }
963
+
964
+ // The only writer of the shared "last used" keys on this path, and only once
965
+ // the bootstrap has succeeded: an abandoned or rejected New Chat must not
966
+ // change what the open conversation or the next one starts with.
967
+ function rememberBootstrappedSelection(result = null) {
968
+ const bootstrappedModel = String(result?.preferredModel || result?.selectedModel || '')
969
+ .trim()
970
+ // The composer stores base ids and carries the 1M tier separately.
971
+ .replace(CLAUDE_LONG_CONTEXT_PATTERN, '');
972
+ if (bootstrappedModel) localStorage.setItem(MODEL_STORAGE_KEY, bootstrappedModel);
973
+ const bootstrappedEffort = String(result?.preferredReasoningEffort || '').trim().toLowerCase();
974
+ if (bootstrappedEffort) localStorage.setItem(REASONING_STORAGE_KEY, bootstrappedEffort);
975
+ }
976
+
977
+ // The conversation row exists in both the success and the worker-prestart
978
+ // failure case, so both take the same post-create steps.
979
+ async function openBootstrappedConversation({
980
+ conversationId,
981
+ payload,
982
+ selectedCwd = '',
983
+ selectedProvider = '',
984
+ selectedSize = '',
985
+ }) {
986
+ // Only a bootstrap that actually created the conversation makes the CWD
987
+ // choice sticky, so a rejected path can never become the next chat's default.
988
+ try {
989
+ if (selectedCwd) localStorage.setItem(NEW_CHAT_CWD_STORAGE_KEY, selectedCwd);
990
+ else localStorage.removeItem(NEW_CHAT_CWD_STORAGE_KEY);
991
+ } catch {}
992
+ hideNewConversationModelModal();
993
+ // The server echoes what it actually bound and stored; the composer picks
994
+ // those up from the conversation row when it opens, so only the shared
995
+ // "last used" storage is updated here.
996
+ rememberBootstrappedSelection(payload);
997
+ await refreshConversations();
998
+ await openConversation(conversationId);
999
+ if (selectedProvider === 'openai-image') {
1000
+ const contextTierSelect = document.getElementById('context-tier-select');
1001
+ if (contextTierSelect && selectedSize && Array.from(contextTierSelect.options).some((option) => option.value === selectedSize)) {
1002
+ contextTierSelect.value = selectedSize;
1003
+ }
1004
+ }
1005
+ }
1006
+
1007
+ async function createNewConversation(selectedModel, selectedReasoningEffort = '', selectedCwd = '') {
1008
+ if (newConversationInFlight) return;
1009
+ setNewConversationInFlight(true);
1010
+ const confirmButton = document.getElementById('new-conversation-model-confirm');
1011
+ if (confirmButton) confirmButton.disabled = true;
1012
+ const selectedProvider = normalizeNewConversationProviderType(
1013
+ String(document.getElementById('new-conversation-provider-select')?.value || '').trim(),
1014
+ );
1015
+ const selectedSize = String(document.getElementById('new-conversation-size-select')?.value || '').trim().toLowerCase();
1016
+ if (selectedProvider === 'openai-image' && selectedSize) {
1017
+ localStorage.setItem(OPENAI_IMAGE_SIZE_STORAGE_KEY, selectedSize);
1018
+ }
1019
+ try {
1020
+ const result = await bootstrapConversationSession({
1021
+ model: selectedModel || undefined,
1022
+ providerType: bootstrapProviderType(selectedProvider),
1023
+ reasoningEffort: String(selectedReasoningEffort || '').trim().toLowerCase() || undefined,
1024
+ // Bootstrap writes the conversation's preferences, so the current mode has
1025
+ // to travel with it or every new chat would come back as the default one.
1026
+ // The composer's selector wins over storage: storage holds the last
1027
+ // explicit choice, which is not the mode of the conversation on screen.
1028
+ relayMode: String(
1029
+ document.getElementById('mode-select')?.value
1030
+ || localStorage.getItem(MODE_STORAGE_KEY)
1031
+ || '',
1032
+ ).trim() || undefined,
1033
+ workspaceRootPath: selectedCwd || undefined,
1034
+ title: 'New Conversation',
1035
+ });
1036
+ const nextConversationId = String(result?.conversationId || '').trim();
1037
+ if (!nextConversationId) {
1038
+ showTransientRelayNotice('Could not start a new conversation session. Please try again.');
1039
+ return;
1040
+ }
1041
+ await openBootstrappedConversation({
1042
+ conversationId: nextConversationId,
1043
+ payload: result,
1044
+ selectedCwd,
1045
+ selectedProvider,
1046
+ selectedSize,
1047
+ });
1048
+ if (result?.warning) {
1049
+ showTransientRelayNotice(String(result.warning), 6000);
1050
+ }
1051
+ if (result?.workspaceRootWarning) {
1052
+ showTransientRelayNotice(String(result.workspaceRootWarning), 7000);
1053
+ }
1054
+ if (result?.defaultSessionWorkspaceRootWarning) {
1055
+ showTransientRelayNotice(String(result.defaultSessionWorkspaceRootWarning), 7000);
1056
+ }
1057
+ } catch (error) {
1058
+ // A worker that fails to prestart still leaves a committed conversation
1059
+ // bound to the requested provider. Open it so the user can retry from the
1060
+ // chat instead of hunting for an orphaned row in the sidebar.
1061
+ const createdConversationId = error?.payload?.conversationCreated
1062
+ ? String(error.payload.conversationId || '').trim()
1063
+ : '';
1064
+ let recovered = false;
1065
+ if (createdConversationId) {
1066
+ try {
1067
+ await openBootstrappedConversation({
1068
+ conversationId: createdConversationId,
1069
+ payload: error.payload,
1070
+ selectedCwd,
1071
+ selectedProvider,
1072
+ selectedSize,
1073
+ });
1074
+ recovered = true;
1075
+ } catch {
1076
+ // Fall through to the failure notice below with the modal closed; the
1077
+ // conversation exists and the sidebar refresh will show it.
1078
+ }
1079
+ }
1080
+ showTransientRelayNotice(recovered
1081
+ // The chat is usable, but its worker did not start, so say what broke.
1082
+ ? `The chat was created, but its session could not start: ${error?.message || 'unknown error'}.`
1083
+ // Otherwise the modal stays open so the user can fix the CWD/model and retry.
1084
+ : (error?.message || 'Could not start a new conversation session.'));
1085
+ } finally {
1086
+ setNewConversationInFlight(false);
1087
+ if (confirmButton) confirmButton.disabled = false;
1088
+ }
1089
+ }
1090
+
1091
+ export async function confirmNewConversationModel() {
1092
+ if (newConversationInFlight) return;
1093
+ const selectedModel = String(document.getElementById('new-conversation-model-select')?.value || '').trim();
1094
+ const selectedReasoningEffort = String(document.getElementById('new-conversation-reasoning-select')?.value || '').trim().toLowerCase();
1095
+ if (!selectedModel) return;
1096
+ const selectedCwd = getNewConversationSelectedCwd();
1097
+ // The modal stays open until the bootstrap succeeds; createNewConversation
1098
+ // closes it, so a rejected CWD or model keeps the selection editable.
1099
+ await createNewConversation(selectedModel, selectedReasoningEffort, selectedCwd);
1100
+ }
1101
+
1102
+ export async function newConversation() {
1103
+ if (IS_SHARED_VIEW) {
1104
+ showTransientRelayNotice('Shared conversations are read-only.');
1105
+ return;
1106
+ }
1107
+ if (newConversationInFlight) return;
1108
+ await openNewConversationModelModal();
1109
+ }
1110
+
1111
+ export function initConversationListLazyLoading() {
1112
+ const el = getConversationListElement();
1113
+ if (!el || el.dataset.lazyLoadBound === '1') return;
1114
+ el.dataset.lazyLoadBound = '1';
1115
+ el.addEventListener('scroll', () => {
1116
+ conversationListAutoLoadBlockedUntil = 0;
1117
+ void conversationListLoader.handleBoundaryDistance(getConversationListBoundaryDistance());
1118
+ }, { passive: true });
1119
+ scheduleConversationListBoundaryCheck();
1120
+ }
1121
+
1122
+ export async function deleteConv(e, id) {
1123
+ e.stopPropagation();
1124
+ if (!confirm('Delete this conversation?')) return;
1125
+ const result = await deleteConversationApi(id);
1126
+ if (!result) return;
1127
+ delete conversations[id];
1128
+ renderConvList();
1129
+ if (currentConvId === id) {
1130
+ setCurrentConv(null);
1131
+ clearAttachments();
1132
+ setRepoBrowserSessionInfo('', '');
1133
+ restoreInFlightThinking(null);
1134
+ renderMessages([]);
1135
+ document.getElementById('chat-title').textContent = 'Select or start a conversation';
1136
+ window.syncChatTitleControls?.();
1137
+ updateSessionPill(null, null);
1138
+ updateCompactButton();
1139
+ scheduleContextUsageRefresh(null);
1140
+ }
1141
+ }