@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,85 @@
1
+ // Pure geometry/selection helpers for the "Change CWD" known-CWD picker.
2
+ // Kept DOM-free so they can run under `node --test` without a browser.
3
+
4
+ const FLIP_THRESHOLD_PX = 180;
5
+
6
+ function clamp(value, min, max) {
7
+ if (max < min) return min;
8
+ return Math.min(Math.max(value, min), max);
9
+ }
10
+
11
+ /**
12
+ * Decide where the fixed-position picker panel should sit relative to its trigger.
13
+ * Used on desktop only; the mobile layout is a bottom sheet driven purely by CSS.
14
+ */
15
+ export function resolveCwdMenuPlacement({
16
+ triggerRect,
17
+ viewportWidth = 0,
18
+ viewportHeight = 0,
19
+ panelHeight = 0,
20
+ gap = 6,
21
+ edge = 8,
22
+ minHeight = 140,
23
+ maxHeight = 300,
24
+ } = {}) {
25
+ const top = Number(triggerRect?.top) || 0;
26
+ const left = Number(triggerRect?.left) || 0;
27
+ const width = Math.max(0, Number(triggerRect?.width) || 0);
28
+ const height = Math.max(0, Number(triggerRect?.height) || 0);
29
+ const bottom = top + height;
30
+
31
+ const spaceBelow = Math.max(0, viewportHeight - bottom - gap - edge);
32
+ const spaceAbove = Math.max(0, top - gap - edge);
33
+ const flip = spaceBelow < FLIP_THRESHOLD_PX && spaceAbove > spaceBelow;
34
+ const available = flip ? spaceAbove : spaceBelow;
35
+
36
+ const desiredHeight = panelHeight > 0 ? Math.min(panelHeight, maxHeight) : maxHeight;
37
+ const resolvedMaxHeight = clamp(Math.min(desiredHeight, available), Math.min(minHeight, available), maxHeight);
38
+
39
+ const resolvedWidth = width > 0 ? width : 260;
40
+ const maxLeft = Math.max(edge, viewportWidth - resolvedWidth - edge);
41
+ const resolvedLeft = clamp(left, edge, maxLeft);
42
+ const resolvedTop = flip
43
+ ? Math.max(edge, top - gap - resolvedMaxHeight)
44
+ : bottom + gap;
45
+
46
+ return {
47
+ placement: flip ? 'above' : 'below',
48
+ top: resolvedTop,
49
+ left: resolvedLeft,
50
+ width: resolvedWidth,
51
+ maxHeight: resolvedMaxHeight,
52
+ };
53
+ }
54
+
55
+ /**
56
+ * Move the active option by `delta`, clamped at both ends (no wrap-around, per the
57
+ * ARIA APG listbox default). Returns -1 when there is nothing to activate.
58
+ */
59
+ export function resolveActiveOptionIndex(currentIndex, delta, count) {
60
+ const total = Number(count) || 0;
61
+ if (total <= 0) return -1;
62
+ const step = Number(delta) || 0;
63
+ const current = Number.isInteger(currentIndex) ? currentIndex : -1;
64
+ if (current < 0) return step < 0 ? total - 1 : 0;
65
+ return clamp(current + step, 0, total - 1);
66
+ }
67
+
68
+ /**
69
+ * Type-ahead: find the next entry whose label or path starts with `buffer`,
70
+ * searching forward from `fromIndex` and wrapping once.
71
+ */
72
+ export function resolveTypeaheadIndex(entries, buffer, fromIndex = -1) {
73
+ const needle = String(buffer || '').trim().toLowerCase();
74
+ if (!needle || !Array.isArray(entries) || !entries.length) return -1;
75
+ const total = entries.length;
76
+ const start = Number.isInteger(fromIndex) && fromIndex >= 0 ? fromIndex : -1;
77
+ for (let offset = 1; offset <= total; offset += 1) {
78
+ const index = (start + offset + total) % total;
79
+ const entry = entries[index] || {};
80
+ const label = String(entry.label || '').toLowerCase();
81
+ const pathValue = String(entry.path || '').toLowerCase();
82
+ if (label.startsWith(needle) || pathValue.startsWith(needle)) return index;
83
+ }
84
+ return -1;
85
+ }
@@ -0,0 +1,605 @@
1
+ import {
2
+ currentConvId,
3
+ conversations,
4
+ workspaceRootPath,
5
+ escHtml,
6
+ getConversationWorkspaceState,
7
+ getConversationCurrentWorkspaceRootPath,
8
+ getRecentWorkspaceRoots,
9
+ openSummaryModal,
10
+ closeSummaryModal,
11
+ setSummaryModalLoading,
12
+ showTransientRelayNotice,
13
+ summaryModalState,
14
+ } from './store.js';
15
+ import { updateWorkspaceRoot, relaunchSessionWorkerWithWorkspaceRoot } from './api-client.js';
16
+ import { getRepoBrowserLaunchCwdPath, openRepoBrowserForCwdPick } from './attachments-view.js';
17
+ import {
18
+ resolveActiveOptionIndex,
19
+ resolveCwdMenuPlacement,
20
+ resolveTypeaheadIndex,
21
+ } from './cwd-menu-placement.mjs';
22
+ import {
23
+ buildKnownCwdOptions as buildKnownCwdOptionsFromInputs,
24
+ normalizeKnownCwdPath,
25
+ } from './known-cwd-options.mjs';
26
+
27
+ export { normalizeKnownCwdPath };
28
+
29
+ const LEGACY_KNOWN_CWD_HISTORY_KEY = 'copilot_known_cwds';
30
+ const MOBILE_PICKER_MEDIA_QUERY = '(max-width: 680px)';
31
+ const TYPEAHEAD_RESET_MS = 700;
32
+
33
+ let changeCwdInFlight = false;
34
+
35
+ // --- Known-CWD picker state -------------------------------------------------
36
+ // Invariant: nothing in this picker changes DOM layout, visibility or state on
37
+ // pointerdown/pointerup. Every state change happens in a click or keydown
38
+ // handler, so the synthetic click that follows a touch has nothing left to
39
+ // trigger and can never land on the action buttons below the panel.
40
+ let menuOpen = false;
41
+ let activeIndex = -1;
42
+ let typeaheadBuffer = '';
43
+ let typeaheadAt = 0;
44
+ let repositionBound = false;
45
+ let modalBodyBound = false;
46
+ let sawPointerDownInBody = false;
47
+
48
+ let deps = {
49
+ applyConversationWorkspaceRootUpdate: () => {},
50
+ refreshSessionWorkerStatus: async () => {},
51
+ };
52
+
53
+ export function initCwdPicker({
54
+ applyConversationWorkspaceRootUpdate,
55
+ refreshSessionWorkerStatus,
56
+ } = {}) {
57
+ if (typeof applyConversationWorkspaceRootUpdate === 'function') {
58
+ deps.applyConversationWorkspaceRootUpdate = applyConversationWorkspaceRootUpdate;
59
+ }
60
+ if (typeof refreshSessionWorkerStatus === 'function') {
61
+ deps.refreshSessionWorkerStatus = refreshSessionWorkerStatus;
62
+ }
63
+ }
64
+
65
+ export function clearLegacyKnownCwdHistoryStorage() {
66
+ try {
67
+ localStorage.removeItem(LEGACY_KNOWN_CWD_HISTORY_KEY);
68
+ } catch {}
69
+ }
70
+
71
+ function buildKnownCwdOptions() {
72
+ return buildKnownCwdOptionsFromInputs({
73
+ currentSessionCwd: getSelectedConversationCurrentCwd(),
74
+ workspaceRootPath,
75
+ browserCwd: getRepoBrowserLaunchCwdPath(),
76
+ recentRoots: getRecentWorkspaceRoots(),
77
+ });
78
+ }
79
+
80
+ function renderKnownCwdMenuItems(options, selectedPath) {
81
+ if (!options.length) {
82
+ return '<div class="change-cwd-menu-empty">No known CWDs available</div>';
83
+ }
84
+ const selectedKey = normalizeKnownCwdPath(selectedPath).toLowerCase();
85
+ // role="option" divs, not buttons: a <button> is an invalid listbox child, and
86
+ // under aria-activedescendant the options must not be focusable.
87
+ return options.map((option, index) => {
88
+ const optionPath = normalizeKnownCwdPath(option.path);
89
+ const selected = optionPath.toLowerCase() === selectedKey;
90
+ return `
91
+ <div class="change-cwd-menu-item${selected ? ' selected' : ''}" role="option" id="change-cwd-option-${index}" aria-selected="${selected ? 'true' : 'false'}" data-path="${escHtml(optionPath)}" data-label="${escHtml(option.label || '')}" data-note="${escHtml(option.note || '')}" title="${escHtml(optionPath)}">
92
+ <span class="change-cwd-menu-item-primary">${escHtml(option.label || 'Known CWD')}</span>
93
+ <span class="change-cwd-menu-item-secondary">${escHtml(optionPath)}</span>
94
+ </div>
95
+ `;
96
+ }).join('');
97
+ }
98
+
99
+ function getSelectedChangeCwdPath() {
100
+ const input = document.getElementById('change-cwd-selected-path');
101
+ return normalizeKnownCwdPath(input?.value || '');
102
+ }
103
+
104
+ function getManualChangeCwdPath() {
105
+ const input = document.getElementById('change-cwd-manual-path');
106
+ return normalizeKnownCwdPath(input?.value || '');
107
+ }
108
+
109
+ function getEffectiveChangeCwdPath() {
110
+ return getManualChangeCwdPath() || getSelectedChangeCwdPath();
111
+ }
112
+
113
+ function getPickerEls() {
114
+ return {
115
+ modalBody: document.getElementById('summary-modal-body'),
116
+ picker: document.getElementById('change-cwd-picker'),
117
+ trigger: document.getElementById('change-cwd-menu-trigger'),
118
+ triggerText: document.getElementById('change-cwd-trigger-text'),
119
+ panel: document.getElementById('change-cwd-menu'),
120
+ backdrop: document.getElementById('change-cwd-menu-backdrop'),
121
+ selectionInput: document.getElementById('change-cwd-selected-path'),
122
+ manualInput: document.getElementById('change-cwd-manual-path'),
123
+ };
124
+ }
125
+
126
+ function getOptionEls() {
127
+ const panel = document.getElementById('change-cwd-menu');
128
+ return Array.from(panel?.querySelectorAll('.change-cwd-menu-item[data-path]') || []);
129
+ }
130
+
131
+ function isMobilePickerViewport() {
132
+ try {
133
+ return !!window.matchMedia?.(MOBILE_PICKER_MEDIA_QUERY)?.matches;
134
+ } catch {
135
+ return false;
136
+ }
137
+ }
138
+
139
+ function positionChangeCwdMenu() {
140
+ const { trigger, panel } = getPickerEls();
141
+ if (!trigger || !panel || panel.hidden) return;
142
+ if (isMobilePickerViewport()) {
143
+ // The bottom-sheet layout comes entirely from the media query. Inline styles
144
+ // set by a previous desktop-width render would otherwise override it.
145
+ panel.style.removeProperty('left');
146
+ panel.style.removeProperty('top');
147
+ panel.style.removeProperty('width');
148
+ panel.style.removeProperty('max-height');
149
+ return;
150
+ }
151
+ const triggerRect = trigger.getBoundingClientRect();
152
+ const viewportWidth = window.innerWidth || 0;
153
+ const viewportHeight = window.innerHeight || 0;
154
+ // Two passes: size the panel first so its natural height can be measured,
155
+ // then anchor it (and possibly flip it above the trigger).
156
+ panel.style.left = `${Math.round(triggerRect.left)}px`;
157
+ panel.style.top = '0px';
158
+ panel.style.width = `${Math.round(triggerRect.width)}px`;
159
+ panel.style.removeProperty('max-height');
160
+ const placement = resolveCwdMenuPlacement({
161
+ triggerRect,
162
+ viewportWidth,
163
+ viewportHeight,
164
+ panelHeight: panel.scrollHeight,
165
+ });
166
+ panel.style.left = `${Math.round(placement.left)}px`;
167
+ panel.style.top = `${Math.round(placement.top)}px`;
168
+ panel.style.width = `${Math.round(placement.width)}px`;
169
+ panel.style.maxHeight = `${Math.round(placement.maxHeight)}px`;
170
+ }
171
+
172
+ function bindRepositionListeners() {
173
+ if (repositionBound) return;
174
+ repositionBound = true;
175
+ // Capture phase so scrolling inside .summary-body (which does not bubble) counts.
176
+ window.addEventListener('scroll', positionChangeCwdMenu, true);
177
+ window.addEventListener('resize', positionChangeCwdMenu);
178
+ window.visualViewport?.addEventListener?.('resize', positionChangeCwdMenu);
179
+ window.visualViewport?.addEventListener?.('scroll', positionChangeCwdMenu);
180
+ }
181
+
182
+ function unbindRepositionListeners() {
183
+ if (!repositionBound) return;
184
+ repositionBound = false;
185
+ window.removeEventListener('scroll', positionChangeCwdMenu, true);
186
+ window.removeEventListener('resize', positionChangeCwdMenu);
187
+ window.visualViewport?.removeEventListener?.('resize', positionChangeCwdMenu);
188
+ window.visualViewport?.removeEventListener?.('scroll', positionChangeCwdMenu);
189
+ }
190
+
191
+ function setActiveOption(index, { scroll = true } = {}) {
192
+ const { trigger } = getPickerEls();
193
+ const items = getOptionEls();
194
+ activeIndex = Number.isInteger(index) && index >= 0 && index < items.length ? index : -1;
195
+ items.forEach((item, itemIndex) => {
196
+ item.classList.toggle('active', itemIndex === activeIndex);
197
+ });
198
+ const activeItem = activeIndex >= 0 ? items[activeIndex] : null;
199
+ if (trigger) {
200
+ if (activeItem?.id) trigger.setAttribute('aria-activedescendant', activeItem.id);
201
+ else trigger.removeAttribute('aria-activedescendant');
202
+ }
203
+ if (activeItem && scroll) activeItem.scrollIntoView({ block: 'nearest' });
204
+ }
205
+
206
+ function getSelectedOptionIndex() {
207
+ const selectedPath = getSelectedChangeCwdPath().toLowerCase();
208
+ if (!selectedPath) return -1;
209
+ return getOptionEls().findIndex((item) => (
210
+ normalizeKnownCwdPath(item.getAttribute('data-path') || '').toLowerCase() === selectedPath
211
+ ));
212
+ }
213
+
214
+ function moveActiveOption(delta) {
215
+ setActiveOption(resolveActiveOptionIndex(activeIndex, delta, getOptionEls().length));
216
+ }
217
+
218
+ function openChangeCwdMenu() {
219
+ const { trigger, panel, backdrop, manualInput } = getPickerEls();
220
+ if (!trigger || !panel || !getOptionEls().length) return;
221
+ menuOpen = true;
222
+ panel.hidden = false;
223
+ if (backdrop) backdrop.hidden = false;
224
+ trigger.setAttribute('aria-expanded', 'true');
225
+ // Drop the virtual keyboard so it cannot resize the viewport under the sheet.
226
+ if (isMobilePickerViewport()) manualInput?.blur?.();
227
+ positionChangeCwdMenu();
228
+ const selectedIndex = getSelectedOptionIndex();
229
+ setActiveOption(selectedIndex >= 0 ? selectedIndex : 0);
230
+ bindRepositionListeners();
231
+ }
232
+
233
+ function closeChangeCwdMenu({ focusTrigger = false } = {}) {
234
+ const { trigger, panel, backdrop } = getPickerEls();
235
+ menuOpen = false;
236
+ typeaheadBuffer = '';
237
+ if (panel) panel.hidden = true;
238
+ if (backdrop) backdrop.hidden = true;
239
+ if (trigger) {
240
+ trigger.setAttribute('aria-expanded', 'false');
241
+ trigger.removeAttribute('aria-activedescendant');
242
+ }
243
+ for (const item of getOptionEls()) item.classList.remove('active');
244
+ activeIndex = -1;
245
+ unbindRepositionListeners();
246
+ if (focusTrigger) trigger?.focus?.({ preventScroll: true });
247
+ }
248
+
249
+ function commitOptionElement(item) {
250
+ const { selectionInput } = getPickerEls();
251
+ if (!item || !selectionInput) return;
252
+ selectionInput.value = normalizeKnownCwdPath(item.getAttribute('data-path') || '');
253
+ syncChangeCwdPickerView();
254
+ closeChangeCwdMenu({ focusTrigger: true });
255
+ }
256
+
257
+ function handleTriggerTypeahead(event) {
258
+ const now = Date.now();
259
+ if (now - typeaheadAt > TYPEAHEAD_RESET_MS) typeaheadBuffer = '';
260
+ typeaheadAt = now;
261
+ typeaheadBuffer += event.key.toLowerCase();
262
+ const entries = getOptionEls().map((item) => ({
263
+ label: item.getAttribute('data-label') || '',
264
+ path: item.getAttribute('data-path') || '',
265
+ }));
266
+ // Re-search from the current option when the buffer grows, so repeated
267
+ // keystrokes refine the match instead of skipping past it.
268
+ const from = typeaheadBuffer.length > 1 ? activeIndex - 1 : activeIndex;
269
+ const match = resolveTypeaheadIndex(entries, typeaheadBuffer, from);
270
+ if (match >= 0) setActiveOption(match);
271
+ }
272
+
273
+ function handleTriggerKeydown(event) {
274
+ const items = getOptionEls();
275
+ if (!items.length) return;
276
+ const key = event.key;
277
+
278
+ if (key === 'Escape') {
279
+ if (!menuOpen) return;
280
+ event.preventDefault();
281
+ event.stopPropagation();
282
+ closeChangeCwdMenu({ focusTrigger: true });
283
+ return;
284
+ }
285
+ if (key === 'Tab') {
286
+ // Let focus move naturally; just collapse without committing.
287
+ if (menuOpen) closeChangeCwdMenu();
288
+ return;
289
+ }
290
+ if (key === 'Enter' || key === ' ' || key === 'Spacebar') {
291
+ // The trigger is a native <button>, so suppress the click it would emit.
292
+ event.preventDefault();
293
+ event.stopPropagation();
294
+ if (!menuOpen) openChangeCwdMenu();
295
+ else if (activeIndex >= 0) commitOptionElement(items[activeIndex]);
296
+ else closeChangeCwdMenu({ focusTrigger: true });
297
+ return;
298
+ }
299
+ if (key === 'ArrowDown' || key === 'ArrowUp') {
300
+ event.preventDefault();
301
+ event.stopPropagation();
302
+ if (key === 'ArrowUp' && event.altKey) {
303
+ if (menuOpen) closeChangeCwdMenu({ focusTrigger: true });
304
+ return;
305
+ }
306
+ if (!menuOpen) {
307
+ openChangeCwdMenu();
308
+ if (getSelectedOptionIndex() < 0) setActiveOption(key === 'ArrowUp' ? items.length - 1 : 0);
309
+ return;
310
+ }
311
+ moveActiveOption(key === 'ArrowDown' ? 1 : -1);
312
+ return;
313
+ }
314
+ if (key === 'Home' || key === 'End') {
315
+ event.preventDefault();
316
+ event.stopPropagation();
317
+ if (!menuOpen) openChangeCwdMenu();
318
+ setActiveOption(key === 'Home' ? 0 : items.length - 1);
319
+ return;
320
+ }
321
+ if (key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
322
+ event.preventDefault();
323
+ event.stopPropagation();
324
+ if (!menuOpen) openChangeCwdMenu();
325
+ handleTriggerTypeahead(event);
326
+ }
327
+ }
328
+
329
+ function syncChangeCwdPickerView() {
330
+ const { triggerText, trigger } = getPickerEls();
331
+ const details = document.getElementById('change-cwd-details');
332
+ const manualPath = getManualChangeCwdPath();
333
+ const manualKey = manualPath.toLowerCase();
334
+ const selectedPath = getSelectedChangeCwdPath();
335
+ const selectedKey = selectedPath.toLowerCase();
336
+ let selectedItem = null;
337
+ let manualMatchItem = null;
338
+ for (const item of getOptionEls()) {
339
+ const itemPath = normalizeKnownCwdPath(item.getAttribute('data-path') || '');
340
+ const itemKey = itemPath.toLowerCase();
341
+ const selected = !!itemKey && itemKey === selectedKey;
342
+ const manualMatch = !!itemKey && !!manualKey && itemKey === manualKey;
343
+ item.classList.toggle('selected', selected || manualMatch);
344
+ item.setAttribute('aria-selected', selected ? 'true' : 'false');
345
+ if (selected) selectedItem = item;
346
+ if (manualMatch) manualMatchItem = item;
347
+ }
348
+ if (triggerText) {
349
+ triggerText.textContent = selectedPath || 'Select a known CWD';
350
+ if (trigger) trigger.title = selectedPath || 'Select a known CWD';
351
+ }
352
+ if (details) {
353
+ if (manualPath) {
354
+ const matchLabel = String(manualMatchItem?.getAttribute('data-label') || '').trim();
355
+ details.textContent = matchLabel
356
+ ? `Manual path: ${manualPath} — same as "${matchLabel}"`
357
+ : `Manual path: ${manualPath}`;
358
+ return;
359
+ }
360
+ if (!selectedPath) {
361
+ details.textContent = 'No known CWDs are available yet.';
362
+ return;
363
+ }
364
+ const label = String(selectedItem?.getAttribute('data-label') || '').trim();
365
+ const note = String(selectedItem?.getAttribute('data-note') || '').trim();
366
+ const labelPrefix = label ? `${label}: ` : '';
367
+ const noteSuffix = note ? ` (${note})` : '';
368
+ details.textContent = `${labelPrefix}${selectedPath}${noteSuffix}`;
369
+ }
370
+ }
371
+
372
+ function bindChangeCwdPicker() {
373
+ const { modalBody, trigger, panel, backdrop, selectionInput, manualInput } = getPickerEls();
374
+ if (!modalBody || !trigger || !panel || !selectionInput) return;
375
+
376
+ // #summary-modal-body outlives every modal, so it is bound exactly once. Its
377
+ // handlers no-op unless the change-cwd picker is the modal currently mounted.
378
+ if (!modalBodyBound) {
379
+ modalBodyBound = true;
380
+ modalBody.addEventListener('pointerdown', () => { sawPointerDownInBody = true; }, true);
381
+ modalBody.addEventListener('pointercancel', () => { sawPointerDownInBody = false; }, true);
382
+ modalBody.addEventListener('click', (event) => {
383
+ if (summaryModalState.kind !== 'change-cwd') return;
384
+ const armed = sawPointerDownInBody;
385
+ sawPointerDownInBody = false;
386
+ // event.detail === 0 means keyboard or programmatic activation.
387
+ if (event.detail === 0 || armed) return;
388
+ // A trusted click with no matching pointerdown in this modal is a ghost:
389
+ // the gesture that produced it started somewhere else (e.g. on the chat
390
+ // menu item that opened this modal). Drop it.
391
+ event.preventDefault();
392
+ event.stopPropagation();
393
+ event.stopImmediatePropagation();
394
+ }, true);
395
+ modalBody.addEventListener('keydown', (event) => {
396
+ if (event.key !== 'Escape' || !menuOpen) return;
397
+ event.preventDefault();
398
+ event.stopPropagation();
399
+ closeChangeCwdMenu({ focusTrigger: true });
400
+ });
401
+ }
402
+
403
+ trigger.addEventListener('click', (event) => {
404
+ event.preventDefault();
405
+ event.stopPropagation();
406
+ if (menuOpen) closeChangeCwdMenu({ focusTrigger: true });
407
+ else openChangeCwdMenu();
408
+ });
409
+ trigger.addEventListener('keydown', handleTriggerKeydown);
410
+
411
+ // Delegated: the only pointer-driven activation path in this picker.
412
+ panel.addEventListener('click', (event) => {
413
+ const item = event.target?.closest?.('.change-cwd-menu-item[data-path]');
414
+ if (!item) return;
415
+ event.preventDefault();
416
+ event.stopPropagation();
417
+ commitOptionElement(item);
418
+ });
419
+ panel.addEventListener('pointermove', (event) => {
420
+ if (event.pointerType !== 'mouse') return;
421
+ const item = event.target?.closest?.('.change-cwd-menu-item[data-path]');
422
+ if (!item) return;
423
+ const index = getOptionEls().indexOf(item);
424
+ if (index >= 0 && index !== activeIndex) setActiveOption(index, { scroll: false });
425
+ });
426
+
427
+ backdrop?.addEventListener('click', (event) => {
428
+ event.preventDefault();
429
+ event.stopPropagation();
430
+ closeChangeCwdMenu();
431
+ });
432
+
433
+ manualInput?.addEventListener('input', () => {
434
+ syncChangeCwdPickerView();
435
+ });
436
+
437
+ document.getElementById('change-cwd-browse-btn')?.addEventListener('click', (event) => {
438
+ event.preventDefault();
439
+ event.stopPropagation();
440
+ if (menuOpen) closeChangeCwdMenu();
441
+ // The picked path lands in the manual input, which already wins over the
442
+ // known-CWD selection in getEffectiveChangeCwdPath.
443
+ openRepoBrowserForCwdPick((pickedPath) => {
444
+ const { manualInput: manual } = getPickerEls();
445
+ if (manual) manual.value = pickedPath;
446
+ syncChangeCwdPickerView();
447
+ });
448
+ });
449
+
450
+ syncChangeCwdPickerView();
451
+ }
452
+
453
+ function getSelectedConversationWorkspaceState() {
454
+ return getConversationWorkspaceState(currentConvId) || null;
455
+ }
456
+
457
+ function getSelectedConversationCurrentCwd() {
458
+ return normalizeKnownCwdPath(getConversationCurrentWorkspaceRootPath(currentConvId) || '');
459
+ }
460
+
461
+ export function syncChatHeaderWorkspaceLabel() {
462
+ const labelEl = document.getElementById('chat-title-cwd');
463
+ if (!labelEl) return;
464
+ const convId = String(currentConvId || '').trim();
465
+ const cwd = getSelectedConversationCurrentCwd();
466
+ if (!convId || !cwd) {
467
+ labelEl.hidden = true;
468
+ labelEl.textContent = '';
469
+ labelEl.removeAttribute('title');
470
+ return;
471
+ }
472
+ labelEl.hidden = false;
473
+ labelEl.textContent = cwd;
474
+ labelEl.title = cwd;
475
+ }
476
+
477
+ function getCurrentLaunchableSessionId() {
478
+ const conversation = conversations?.[currentConvId] || null;
479
+ return String(conversation?.sdkSessionId || conversation?.sdk_session_id || '').trim();
480
+ }
481
+
482
+ function isSelectedSessionRunning() {
483
+ const conversation = conversations?.[currentConvId] || null;
484
+ const status = String(conversation?.runtimeSessionStatus || conversation?.runtime_session_status || '').trim().toLowerCase();
485
+ return ['starting', 'ready', 'processing'].includes(status);
486
+ }
487
+
488
+ export function openChangeCwdModal() {
489
+ const options = buildKnownCwdOptions();
490
+ const workspaceState = getSelectedConversationWorkspaceState();
491
+ const currentCwd = normalizeKnownCwdPath(workspaceState?.currentWorkspaceRootPath || '');
492
+ const nextLaunchCwd = normalizeKnownCwdPath(workspaceState?.configuredWorkspaceRootPath || '');
493
+ const defaultPath = nextLaunchCwd || normalizeKnownCwdPath(getRepoBrowserLaunchCwdPath()) || currentCwd || normalizeKnownCwdPath(workspaceRootPath) || options[0]?.path || '';
494
+ const menuItemsHtml = renderKnownCwdMenuItems(options, defaultPath);
495
+ const launchableSessionId = getCurrentLaunchableSessionId();
496
+ const launchDisabledReason = !launchableSessionId
497
+ ? 'Open a conversation with a bound session before launching.'
498
+ : (isSelectedSessionRunning() ? 'Ready sessions will restart in the selected CWD. Active turns must finish first.' : '');
499
+ openSummaryModal({
500
+ title: 'Change CWD',
501
+ subtitle: 'Select a known launch directory',
502
+ kind: 'change-cwd',
503
+ bodyHtml: `
504
+ <p style="margin-bottom:10px;color:var(--muted);line-height:1.45">
505
+ Pick the selected session's launch directory. An idle CLI restarts in the selected CWD; active turns must finish first.
506
+ </p>
507
+ <div style="display:grid;gap:4px;margin-bottom:10px;font-size:0.78rem;color:var(--muted)">
508
+ <div><strong style="color:var(--text)">Current CWD:</strong> ${escHtml(currentCwd || 'Unknown')}</div>
509
+ <div><strong style="color:var(--text)">Next launch:</strong> ${escHtml(nextLaunchCwd || currentCwd || 'Unknown')}</div>
510
+ </div>
511
+ <div class="change-cwd-picker" style="margin-bottom:10px">
512
+ <label class="change-cwd-picker-label" for="change-cwd-manual-path">Manual path</label>
513
+ <div class="cwd-browse-row">
514
+ <input id="change-cwd-manual-path" class="change-cwd-manual-input" type="text" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" placeholder="Manual path">
515
+ <button id="change-cwd-browse-btn" class="cwd-browse-btn" type="button" title="Pick a folder with the file explorer" aria-label="Pick a folder with the file explorer">📁</button>
516
+ </div>
517
+ </div>
518
+ <div id="change-cwd-picker" class="change-cwd-picker">
519
+ <span id="change-cwd-picker-label" class="change-cwd-picker-label">Known CWDs</span>
520
+ <input id="change-cwd-selected-path" type="hidden" value="${escHtml(defaultPath)}">
521
+ <button id="change-cwd-menu-trigger" class="change-cwd-menu-trigger" type="button" role="combobox" aria-haspopup="listbox" aria-expanded="false" aria-controls="change-cwd-menu" aria-labelledby="change-cwd-picker-label change-cwd-trigger-text"${options.length ? '' : ' disabled'}>
522
+ <span id="change-cwd-trigger-text" class="change-cwd-menu-trigger-text">Select a known CWD</span>
523
+ <span class="change-cwd-menu-trigger-caret" aria-hidden="true">▾</span>
524
+ </button>
525
+ <div id="change-cwd-menu-backdrop" class="change-cwd-menu-backdrop" hidden></div>
526
+ <div id="change-cwd-menu" class="change-cwd-menu-panel" role="listbox" aria-labelledby="change-cwd-picker-label" hidden>
527
+ ${menuItemsHtml}
528
+ </div>
529
+ </div>
530
+ <div id="change-cwd-details" style="margin-top:10px;font-size:0.78rem;color:var(--muted);line-height:1.45;word-break:break-word"></div>
531
+ <div class="summary-modal-actions" id="change-cwd-actions">
532
+ <button class="summary-btn" type="button" onclick="confirmChangeCwd()">🗂️ Save next-launch CWD</button>
533
+ <button class="summary-btn" type="button" ${launchableSessionId ? 'onclick="confirmChangeCwdAndLaunch()"' : 'disabled data-keep-disabled="1"'} title="${escHtml(launchDisabledReason || 'Set the CWD and (re)launch the current session worker')}">🚀 Set new CWD and (re)launch</button>
534
+ <button class="summary-close" type="button" onclick="closeSummaryModal()">Cancel</button>
535
+ </div>
536
+ `,
537
+ });
538
+ // Bind synchronously: the picker must be live from the first frame. Stray
539
+ // clicks are handled structurally (see bindChangeCwdPicker), not by a timer.
540
+ sawPointerDownInBody = false;
541
+ bindChangeCwdPicker();
542
+ }
543
+
544
+ function newChangeCwdRequestId() {
545
+ try {
546
+ if (typeof crypto?.randomUUID === 'function') return crypto.randomUUID();
547
+ } catch {}
548
+ return `cwd-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
549
+ }
550
+
551
+ export async function confirmChangeCwd() {
552
+ await submitChangeCwd(false);
553
+ }
554
+
555
+ export async function confirmChangeCwdAndLaunch() {
556
+ await submitChangeCwd(true);
557
+ }
558
+
559
+ async function submitChangeCwd(launchAfterChange = false) {
560
+ if (changeCwdInFlight) return;
561
+ const targetPath = getEffectiveChangeCwdPath();
562
+ if (!targetPath) {
563
+ alert('Enter a manual path or select a known CWD first.');
564
+ return;
565
+ }
566
+ const launchableSessionId = launchAfterChange ? getCurrentLaunchableSessionId() : '';
567
+ if (launchAfterChange && !launchableSessionId) {
568
+ alert('Open a conversation with a bound session before launching.');
569
+ return;
570
+ }
571
+ changeCwdInFlight = true;
572
+ setSummaryModalLoading(true);
573
+ try {
574
+ const result = launchAfterChange
575
+ ? await relaunchSessionWorkerWithWorkspaceRoot(currentConvId, targetPath, newChangeCwdRequestId())
576
+ : await updateWorkspaceRoot(targetPath, currentConvId);
577
+ if (!result) {
578
+ alert('Failed to update the launch CWD');
579
+ return;
580
+ }
581
+ deps.applyConversationWorkspaceRootUpdate({
582
+ conversationId: currentConvId,
583
+ ...result,
584
+ });
585
+ const updatedPath = result.configuredWorkspaceRootPath || result.currentWorkspaceRootPath || result.workspaceRootPath || targetPath;
586
+ if (launchAfterChange) {
587
+ closeSummaryModal();
588
+ showTransientRelayNotice(result.workspaceRootApplied === false
589
+ ? `CWD saved as ${updatedPath}, but the running CLI kept its current directory. Stop it and launch again to apply.`
590
+ : `CWD set to ${updatedPath} and CLI relaunched.`);
591
+ await deps.refreshSessionWorkerStatus().catch(() => {});
592
+ return;
593
+ }
594
+ closeSummaryModal();
595
+ showTransientRelayNotice(isSelectedSessionRunning()
596
+ ? `Next launch CWD saved as ${updatedPath}. The running CLI keeps its current CWD.`
597
+ : `Next launch CWD saved as ${updatedPath}.`);
598
+ } catch (error) {
599
+ alert(error?.message || 'Failed to update the launch CWD');
600
+ } finally {
601
+ changeCwdInFlight = false;
602
+ setSummaryModalLoading(false);
603
+ }
604
+ }
605
+