@oar-sh/oar 0.9.5 → 0.9.7

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 (119) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +12 -32
  2. package/.github/extensions/web-relay/skills/question-bridge.mjs +14 -39
  3. package/CHANGELOG.md +340 -0
  4. package/README.md +196 -26
  5. package/package.json +2 -1
  6. package/server/README.md +174 -7
  7. package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +340 -0
  8. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +1325 -0
  9. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +155 -0
  10. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  11. package/server/claude-worker/claude-sdk-adapter.mjs +18 -2
  12. package/server/claude-worker/claude-session-process.mjs +151 -1
  13. package/server/claude-worker/claude-session-worker.mjs +6 -0
  14. package/server/claude-worker/claude-turn-failure.mjs +92 -0
  15. package/server/claude-worker/claude-turn-publisher.mjs +52 -3
  16. package/server/claude-worker/sdk-message-normalizer.mjs +13 -1
  17. package/server/copilot-worker/copilot-model-switch.mjs +29 -3
  18. package/server/copilot-worker/copilot-question-bridge.mjs +2 -4
  19. package/server/copilot-worker/copilot-runtime-tree.mjs +91 -0
  20. package/server/copilot-worker/copilot-sdk-adapter.mjs +48 -8
  21. package/server/copilot-worker/copilot-sdk-session-process.mjs +897 -166
  22. package/server/cursor-worker/cursor-sdk-adapter.mjs +80 -13
  23. package/server/cursor-worker/cursor-turn-runner.mjs +5 -2
  24. package/server/db-schema.mjs +24 -0
  25. package/server/grok-worker/acp-client.mjs +68 -12
  26. package/server/grok-worker/grok-sdk-adapter.mjs +4 -0
  27. package/server/grok-worker/grok-turn-runner.mjs +6 -2
  28. package/server/migrations/0006-workspace-root-settings.mjs +67 -0
  29. package/server/migrations/0007-claude-cloud.mjs +82 -0
  30. package/server/public/app/agent-sessions-settings-ui.js +197 -0
  31. package/server/public/app/agent-sessions-settings.mjs +134 -0
  32. package/server/public/app/api-client.js +160 -6
  33. package/server/public/app/attachments-view.js +20 -1
  34. package/server/public/app/attribution-control.mjs +46 -0
  35. package/server/public/app/bootstrap.js +347 -31
  36. package/server/public/app/claude-cloud-conversation-ui.js +110 -0
  37. package/server/public/app/claude-cloud-settings-ui.js +374 -0
  38. package/server/public/app/claude-cloud-ui.mjs +611 -0
  39. package/server/public/app/composer-control-state.mjs +15 -3
  40. package/server/public/app/context-menu.mjs +140 -0
  41. package/server/public/app/context-tier-options.mjs +4 -0
  42. package/server/public/app/conversation-origin-ui.js +27 -0
  43. package/server/public/app/conversation-provider-indicator.mjs +22 -4
  44. package/server/public/app/conversation-view.js +178 -8
  45. package/server/public/app/cwd-picker.js +12 -0
  46. package/server/public/app/journal-view.js +704 -28
  47. package/server/public/app/markdown-strikethrough.mjs +31 -0
  48. package/server/public/app/math-dollars.mjs +89 -0
  49. package/server/public/app/model-selector-options.mjs +1 -1
  50. package/server/public/app/plan-usage-view.mjs +36 -8
  51. package/server/public/app/relay-board-inline.mjs +70 -0
  52. package/server/public/app/relay-board-view.js +157 -61
  53. package/server/public/app/relay-error-ctas.mjs +28 -5
  54. package/server/public/app/remote-relay-origin-view.mjs +120 -4
  55. package/server/public/app/router.js +49 -12
  56. package/server/public/app/settings-modal.js +33 -2
  57. package/server/public/app/settings-tabs.js +1 -1
  58. package/server/public/app/socket-handlers.js +60 -0
  59. package/server/public/app/store.js +4 -1
  60. package/server/public/app/subagent-fold.mjs +61 -0
  61. package/server/public/app/usage-limit-ui.js +225 -0
  62. package/server/public/index.html +471 -8
  63. package/server/relay-tools.md +13 -1
  64. package/server/repositories/message-repository.mjs +10 -1
  65. package/server/repositories/question-repository.mjs +1 -0
  66. package/server/repositories/remote-relay-repository.mjs +72 -1
  67. package/server/repositories/session-repository.mjs +47 -1
  68. package/server/routes/claude-cloud-routes.mjs +140 -0
  69. package/server/routes/git-routes.mjs +37 -0
  70. package/server/routes/messages-routes.mjs +202 -26
  71. package/server/routes/relay-board-routes.mjs +56 -2
  72. package/server/routes/remote-relay-routes.mjs +15 -1
  73. package/server/routes/sessions-routes.mjs +538 -72
  74. package/server/server-runtime.mjs +291 -27
  75. package/server/services/claude-account-usage-service.mjs +176 -0
  76. package/server/services/claude-cloud-repo-service.mjs +408 -0
  77. package/server/services/claude-cloud-session-service.mjs +326 -0
  78. package/server/services/claude-cloud-settings-service.mjs +269 -0
  79. package/server/services/git-remote-service.mjs +115 -0
  80. package/server/services/plan-usage-claude-cloud.mjs +211 -0
  81. package/server/services/plan-usage-claude.mjs +202 -40
  82. package/server/services/plan-usage-contract.mjs +39 -3
  83. package/server/services/plan-usage-service.mjs +82 -2
  84. package/server/services/preview-proxy-server.mjs +64 -36
  85. package/server/services/process-list-service.mjs +122 -0
  86. package/server/services/provider-reasoning-effort.mjs +3 -0
  87. package/server/services/relay-console-log.mjs +199 -0
  88. package/server/services/remote-relay-caller-context.mjs +78 -19
  89. package/server/services/remote-relay-client.mjs +9 -0
  90. package/server/services/remote-relay-dispatcher.mjs +451 -39
  91. package/server/services/remote-relay-inbound.mjs +83 -1
  92. package/server/services/remote-relay-loopback.mjs +245 -0
  93. package/server/services/remote-relay-registry-service.mjs +52 -0
  94. package/server/services/session-worker-launch-service.mjs +100 -15
  95. package/server/services/session-worker-process-service.mjs +410 -138
  96. package/server/services/session-worker-registry-service.mjs +1 -1
  97. package/server/services/session-worker-stop-service.mjs +24 -13
  98. package/server/services/session-worker-supervisor-service.mjs +89 -2
  99. package/server/services/usage-limit-pause-service.mjs +443 -0
  100. package/server/services/worker-launch-note-service.mjs +151 -0
  101. package/shared/ask-user-bridge.mjs +14 -39
  102. package/shared/claude-attribution.mjs +117 -0
  103. package/shared/claude-cloud/api-client.mjs +809 -0
  104. package/shared/claude-cloud/base-url.mjs +47 -0
  105. package/shared/claude-cloud/credentials.mjs +221 -0
  106. package/shared/claude-cloud/repo-url.mjs +111 -0
  107. package/shared/claude-usage-limit.mjs +257 -0
  108. package/shared/control-poller.mjs +69 -20
  109. package/shared/failure-note-text.mjs +38 -0
  110. package/shared/provider-routing.mjs +4 -2
  111. package/shared/question-wait.mjs +108 -0
  112. package/shared/remote-relay-contract.mjs +167 -22
  113. package/shared/remote-relay-tool-core.mjs +11 -8
  114. package/shared/steer-settle-failure.mjs +17 -0
  115. package/shared/steer-settle-markers.mjs +8 -0
  116. package/shared/worker-runtime/process-tree.mjs +483 -0
  117. package/shared/worker-runtime/relay-errors.mjs +31 -0
  118. package/shared/worker-runtime/send-and-wait-errors.mjs +4 -4
  119. package/shared/worker-runtime/turn-liveness.mjs +249 -0
@@ -28,16 +28,24 @@ import {
28
28
  loadConversations as loadConversationsApi,
29
29
  loadConversation,
30
30
  deleteConversation as deleteConversationApi,
31
+ archiveConversation as archiveConversationApi,
32
+ unarchiveConversation as unarchiveConversationApi,
31
33
  bootstrapConversationSession,
32
34
  scheduleContextUsageRefresh,
33
35
  loadModelCatalog,
34
36
  loadClaudeSettings,
37
+ loadClaudeCloudSettings,
35
38
  loadCursorSettings,
39
+ loadGitRemote,
40
+ loadClaudeCloudBranches,
41
+ loadClaudeCloudRepos,
36
42
  loadGrokSettings,
37
43
  loadOpenAISettings,
38
44
  } from './api-client.js';
39
- import { renderMessages, restoreInFlightThinking, focusConversationMessageById, flushConversationDraft, hydrateConversationDraft, beginConversationDraftSwitch } from './conversation-view.js';
45
+ import { renderMessages, restoreInFlightThinking, focusConversationMessageById, flushConversationDraft, hydrateConversationDraft, beginConversationDraftSwitch, openStopTurnConfirmationForConversation, conversationHasActiveTurn } from './conversation-view.js';
46
+ import { MENU_SEPARATOR, bindLongPress, closeContextMenu, openContextMenu } from './context-menu.mjs';
40
47
  import { setBackgroundTasksConversation, setConversationBackgroundTasks } from './background-tasks-view.mjs';
48
+ import { setUsageLimitConversation } from './usage-limit-ui.js';
41
49
  import { mergeConversationPreviews } from './preview-cards.mjs';
42
50
  import { loadRelayQuestions, getPendingQuestionCountsByConversation } from './ask-user-view.js';
43
51
  import { loadRelayBoards } from './relay-board-view.js';
@@ -58,6 +66,23 @@ import {
58
66
  conversationProviderIndicatorLabel,
59
67
  } from './conversation-provider-indicator.mjs';
60
68
  import { leaveStatusView } from './status-view.mjs';
69
+ import {
70
+ CLAUDE_CLOUD_PROVIDER,
71
+ buildCloudSourceWarnings,
72
+ claudeCloudDefaultModel,
73
+ claudeCloudModelIds,
74
+ cloudBootstrapErrorModel,
75
+ cloudFolderStatusText,
76
+ cloudRepoAccess,
77
+ cloudRepoSuggestionMeta,
78
+ filterCloudBranchSuggestions,
79
+ filterCloudRepoSuggestions,
80
+ isClaudeCloudConversation,
81
+ newChatRowVisibility,
82
+ normalizeCloudRepoInput,
83
+ resolveCloudSourceAutoFill,
84
+ validateCloudSourceInputs,
85
+ } from './claude-cloud-ui.mjs';
61
86
  import { renderConversationOriginMarkerHtml } from './remote-relay-origin-view.mjs';
62
87
  import {
63
88
  normalizeConversationFilter,
@@ -107,6 +132,30 @@ let newConversationOpenAISettingsCache = null;
107
132
  let newConversationClaudeSettingsCache = null;
108
133
  let newConversationCursorSettingsCache = null;
109
134
  let newConversationGrokSettingsCache = null;
135
+ let newConversationClaudeCloudSettingsCache = null;
136
+ // Claude Cloud fields of the New Chat modal: what the selected folder's git
137
+ // remote said, and what was written into Repository/Branch from it (so a later
138
+ // lookup can tell its own fill from something the user typed).
139
+ const NEW_CHAT_CLOUD_LOOKUP_DEBOUNCE_MS = 350;
140
+ let newConversationCloudRemote = null;
141
+ let newConversationCloudLookupFailed = false;
142
+ let newConversationCloudLookupFolder = null;
143
+ let newConversationCloudAutoFill = null;
144
+ let newConversationCloudLookupSeq = 0;
145
+ let newConversationCloudLookupTimer = null;
146
+ // Suggestions under the two fields: the repositories the Claude GitHub app
147
+ // can reach plus the ones used in earlier cloud chats here (one read per
148
+ // open), and the branches of the repository in the field (read when it
149
+ // changes). `branchAutoFill` is what a folder lookup or a pick wrote into
150
+ // Branch, so a later pick may replace it while a typed branch stays.
151
+ let newConversationCloudRepoList = null;
152
+ let newConversationCloudRepoListPromise = null;
153
+ let newConversationCloudBranchList = null;
154
+ let newConversationCloudBranchSeq = 0;
155
+ let newConversationCloudBranchTimer = null;
156
+ let newConversationCloudBranchAutoFill = '';
157
+ const newConversationCloudSuggestActive = { repo: -1, branch: -1 };
158
+ const NEW_CHAT_CLOUD_SUGGEST_BLUR_MS = 150;
110
159
  let conversationListBoundaryCheckFrame = 0;
111
160
  let conversationListAutoLoadBlockedUntil = 0;
112
161
  let conversationListPaginationState = {
@@ -177,6 +226,7 @@ const conversationListLoader = createInfiniteLoader({
177
226
  limit: CONVERSATION_LIST_PAGE_SIZE,
178
227
  beforeConversationId: String(cursor?.beforeConversationId || '').trim(),
179
228
  beforeUpdatedAt: String(cursor?.beforeUpdatedAt || '').trim(),
229
+ archived: conversationListArchivedView ? 'only' : '',
180
230
  });
181
231
  if (!response) throw new Error('Could not load conversations');
182
232
  return {
@@ -266,7 +316,10 @@ export async function loadConversations() {
266
316
 
267
317
  export async function refreshConversations(options = {}) {
268
318
  const preservePagination = options?.preservePagination !== false;
269
- const r = await loadConversationsApi({ limit: CONVERSATION_LIST_PAGE_SIZE });
319
+ const r = await loadConversationsApi({
320
+ limit: CONVERSATION_LIST_PAGE_SIZE,
321
+ archived: conversationListArchivedView ? 'only' : '',
322
+ });
270
323
  if (!r) return;
271
324
  if (Array.isArray(r.knownConversationIds)) {
272
325
  const knownConversationIds = new Set(
@@ -299,7 +352,9 @@ export function renderConvList() {
299
352
  return String(b?.id || '').trim().localeCompare(String(a?.id || '').trim());
300
353
  });
301
354
  const activeFilter = normalizeConversationFilter(conversationListFilterText);
302
- const visible = activeFilter ? filterConversations(sorted, activeFilter) : sorted;
355
+ // The live list and the Archived view are two views of the same records.
356
+ const inView = sorted.filter((conversation) => (conversation?.archived === true) === conversationListArchivedView);
357
+ const visible = activeFilter ? filterConversations(inView, activeFilter) : inView;
303
358
  const pendingByConversation = getPendingQuestionCountsByConversation();
304
359
  let hasProcessingConversation = false;
305
360
  const footerHtml = (() => {
@@ -321,9 +376,9 @@ export function renderConvList() {
321
376
  })();
322
377
  if (visible.length === 0) {
323
378
  ensureProcessingDotTimer(false);
324
- const emptyText = activeFilter && sorted.length > 0
379
+ const emptyText = activeFilter && inView.length > 0
325
380
  ? 'No conversations match your filter'
326
- : 'No conversations yet';
381
+ : (conversationListArchivedView ? 'No archived conversations' : 'No conversations yet');
327
382
  list.innerHTML = `<div style="padding:12px;color:var(--muted);font-size:0.85rem;text-align:center">${emptyText}</div>${footerHtml}`;
328
383
  lastConvListHtml = '';
329
384
  scheduleConversationListBoundaryCheck();
@@ -351,11 +406,15 @@ export function renderConvList() {
351
406
  ? `<span class="conv-provider-indicator"${providerIndicatorKey ? ` data-provider="${providerIndicatorKey}"` : ''}>${providerIndicatorLabel}</span>`
352
407
  : '';
353
408
  // "↗ win-test": another relay's agent created this conversation.
354
- const originMarkerHtml = renderConversationOriginMarkerHtml(c);
409
+ // "via agent · “title”": an agent in that conversation on this relay did.
410
+ const originMarkerHtml = renderConversationOriginMarkerHtml(c, { conversations });
355
411
  return `
356
- <div class="conv-item worker-ui-${view.visualState}${c.id === currentConvId ? ' active' : ''}" onclick="openConversation('${c.id}')">
357
- <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>
412
+ <div class="conv-item worker-ui-${view.visualState}${c.id === currentConvId ? ' active' : ''}${c.archived ? ' archived' : ''}" data-conversation-id="${c.id}" onclick="openConversation('${c.id}')">
413
+ <div class="conv-title">${escHtml(c.title)}${processingDots ? `<span class="conv-processing-dots">${escHtml(` ${processingDots}`)}</span>` : ''}${pendingByConversation[c.id] ? ` <span class="conv-open-questions">${pendingByConversation[c.id]} open</span>` : ''}</div>
358
414
  <div class="conv-meta"><span class="conv-meta-primary">${fmtDate(c.updatedAt)} · ${c.messageCount} msg${c.messageCount !== 1 ? 's' : ''}</span>${originMarkerHtml}${providerIndicatorHtml}</div>
415
+ ${c.archived
416
+ ? `<button class="conv-archive" onclick="unarchiveConv(event,'${c.id}')" title="Unarchive" aria-label="Unarchive">📂</button>`
417
+ : `<button class="conv-archive" onclick="archiveConv(event,'${c.id}')" title="Archive" aria-label="Archive">🗄</button>`}
359
418
  <button class="conv-delete" onclick="deleteConv(event,'${c.id}')" title="Delete">🗑</button>
360
419
  </div>`;
361
420
  }).join('')}${footerHtml}`;
@@ -363,6 +422,7 @@ export function renderConvList() {
363
422
  list.innerHTML = listHtml;
364
423
  lastConvListHtml = listHtml;
365
424
  }
425
+ bindConversationContextMenu(list);
366
426
  ensureProcessingDotTimer(hasProcessingConversation);
367
427
  window.syncChatTitleControls?.();
368
428
  scheduleConversationListBoundaryCheck();
@@ -409,6 +469,12 @@ export function applyLoadedConversationState(id, response, {
409
469
  ?? existingConversation.runtimeModel
410
470
  ?? null,
411
471
  sessionUsageSummary: response.sessionUsageSummary ?? existingConversation.sessionUsageSummary ?? null,
472
+ // Claude Cloud only: repository, branch, session link, pushed branches.
473
+ // An explicit null is a real answer ("not a cloud chat"); only a payload
474
+ // without the field (an older relay) keeps what the list said.
475
+ cloud: 'cloud' in response ? (response.cloud ?? null) : (existingConversation.cloud ?? null),
476
+ // Who created the conversation, when an agent did (the "via agent" marker).
477
+ origin: 'origin' in response ? (response.origin ?? null) : (existingConversation.origin ?? null),
412
478
  messageCount: Array.isArray(response.messages)
413
479
  ? Math.max(existingConversation.messageCount || 0, response.messages.length)
414
480
  : (existingConversation.messageCount || 0),
@@ -434,6 +500,11 @@ export function applyLoadedConversationState(id, response, {
434
500
  restoreInFlightThinking(response.inFlight || null, followLiveUpdates);
435
501
  setBackgroundTasksConversation(id);
436
502
  setConversationBackgroundTasks(id, response.backgroundTasks || []);
503
+ setUsageLimitConversation(id, {
504
+ // Absent from an older relay's payload: keep what the socket said.
505
+ pause: 'usageLimitPause' in response ? response.usageLimitPause : undefined,
506
+ providerType: response.runtimeSession?.providerType,
507
+ });
437
508
  // Merge, not replace: this payload only carries one conversation's previews.
438
509
  mergeConversationPreviews(id, response.previews || []);
439
510
  updateSessionPill(conversations[id], response.runtimeSession || null);
@@ -450,6 +521,23 @@ export function applyLoadedConversationState(id, response, {
450
521
  saveConversationScrollTop(id, el.scrollTop);
451
522
  }
452
523
 
524
+ // The "via agent" marker (sidebar row, header, message badge): opens the
525
+ // conversation whose agent started this one. The marker sits inside a sidebar
526
+ // row that opens on click itself, so the click stops here.
527
+ export function openOriginConversation(event, element) {
528
+ event?.stopPropagation?.();
529
+ event?.preventDefault?.();
530
+ const id = String(element?.dataset?.originConversationId || '').trim();
531
+ if (!id) return null;
532
+ if (!conversations[id]) {
533
+ // Deleted since the marker was drawn; the next render makes it plain text.
534
+ showTransientRelayNotice('That conversation is no longer in the list.');
535
+ renderConvList();
536
+ return null;
537
+ }
538
+ return openConversation(id);
539
+ }
540
+
453
541
  export async function openConversation(id, options = {}) {
454
542
  const didLeaveStatusView = leaveStatusView();
455
543
  document.getElementById('input-area')?.removeAttribute('hidden');
@@ -553,6 +641,7 @@ function normalizeNewConversationProviderType(value = '') {
553
641
  if (normalized === 'openai' || normalized === 'openai-byok') return 'openai';
554
642
  if (normalized === 'openai-image' || normalized === 'openai-image-byok') return 'openai-image';
555
643
  if (normalized === 'claude') return 'claude';
644
+ if (normalized === CLAUDE_CLOUD_PROVIDER) return CLAUDE_CLOUD_PROVIDER;
556
645
  if (normalized === 'cursor') return 'cursor';
557
646
  if (normalized === 'grok') return 'grok';
558
647
  return 'github';
@@ -633,11 +722,18 @@ function modelMatchesNewConversationProvider(catalog = {}, modelId = '', provide
633
722
  if (cursorOnly) return false;
634
723
  const grokOnly = hasGrok && providers.every((provider) => provider === 'grok');
635
724
  if (grokOnly) return false;
725
+ // Should a relay ever tag catalog rows for Claude Cloud, they are no more
726
+ // Copilot's than the other runtimes' rows are.
727
+ const exclusiveOnly = providers.length > 0 && providers.every((provider) => (
728
+ provider === 'claude' || provider === 'cursor' || provider === 'grok' || provider === CLAUDE_CLOUD_PROVIDER
729
+ ));
730
+ if (exclusiveOnly) return false;
636
731
  return providers.some((provider) => (
637
732
  provider !== 'openai-byok'
638
733
  && provider !== 'claude'
639
734
  && provider !== 'cursor'
640
735
  && provider !== 'grok'
736
+ && provider !== CLAUDE_CLOUD_PROVIDER
641
737
  )) || !hasOpenAIByok;
642
738
  }
643
739
 
@@ -721,6 +817,13 @@ async function populateNewConversationReasoningSelect(selectedModel = '') {
721
817
  const provider = normalizeNewConversationProviderType(
722
818
  String(document.getElementById('new-conversation-provider-select')?.value || '').trim().toLowerCase(),
723
819
  );
820
+ // A cloud session has no effort choice; its row is hidden, and an empty
821
+ // select is what the Start button reads as "send none".
822
+ if (!newChatRowVisibility(provider).reasoning) {
823
+ select.innerHTML = '';
824
+ select.disabled = true;
825
+ return;
826
+ }
724
827
  syncNewConversationReasoningLabel(provider);
725
828
  const catalog = newConversationCatalogCache || await loadModelCatalog() || {};
726
829
  const efforts = reasoningChoicesForProviderModel(catalog || {}, {
@@ -779,6 +882,10 @@ function updateNewConversationProviderHelp(provider = 'github') {
779
882
  help.textContent = "Claude chats run through the Claude Agent SDK with the relay host's Claude login.";
780
883
  return;
781
884
  }
885
+ if (normalizedProvider === CLAUDE_CLOUD_PROVIDER) {
886
+ help.textContent = "Claude Cloud chats run in a sandbox at Anthropic on a clone of a GitHub repository, billed to the relay host's Claude account.";
887
+ return;
888
+ }
782
889
  if (normalizedProvider === 'cursor') {
783
890
  help.textContent = 'Cursor chats run through the Cursor Agent SDK using your saved Cursor API key.';
784
891
  return;
@@ -797,12 +904,16 @@ async function populateNewConversationModelSelect(providerType = 'github') {
797
904
  const catalog = newConversationCatalogCache || await loadModelCatalog();
798
905
  if (!catalog || !Array.isArray(catalog.models)) return false;
799
906
  const normalizedProvider = normalizeNewConversationProviderType(providerType);
800
- const eligibleModels = catalog.models
801
- .map((modelId) => String(modelId || '').trim())
802
- .filter((modelId) => modelMatchesNewConversationProvider(catalog, modelId, normalizedProvider))
803
- .filter((modelId) => (
804
- normalizedProvider !== 'openai-image' || isOpenAIImageModelId(modelId)
805
- ));
907
+ const isCloud = normalizedProvider === CLAUDE_CLOUD_PROVIDER;
908
+ // Claude Cloud brings its own list (the tab's models), not catalog rows.
909
+ const eligibleModels = isCloud
910
+ ? claudeCloudModelIds(newConversationClaudeCloudSettingsCache)
911
+ : catalog.models
912
+ .map((modelId) => String(modelId || '').trim())
913
+ .filter((modelId) => modelMatchesNewConversationProvider(catalog, modelId, normalizedProvider))
914
+ .filter((modelId) => (
915
+ normalizedProvider !== 'openai-image' || isOpenAIImageModelId(modelId)
916
+ ));
806
917
  // The same builder the composer's #model-select uses, on the same catalog,
807
918
  // so the two pickers agree on order and wording by construction. The modal
808
919
  // used to borrow labels from the composer, which is scoped to the OPEN
@@ -811,7 +922,8 @@ async function populateNewConversationModelSelect(providerType = 'github') {
811
922
  const choices = buildNewConversationModelChoices(
812
923
  buildCatalogModelOptions(eligibleModels, {
813
924
  metadataByModel: catalog.modelMetadataByModel || {},
814
- annotateContextWindow: normalizedProvider !== 'claude',
925
+ // The merged metadata knows Copilot's windows, not Claude's or the cloud's.
926
+ annotateContextWindow: normalizedProvider !== 'claude' && !isCloud,
815
927
  }).filter((option) => hasAuto || option.value.toLowerCase() !== 'auto'),
816
928
  );
817
929
  for (const choice of choices) {
@@ -820,9 +932,16 @@ async function populateNewConversationModelSelect(providerType = 'github') {
820
932
  option.textContent = choice.label;
821
933
  target.appendChild(option);
822
934
  }
935
+ // The rows follow the provider even when it has no model to offer, so a
936
+ // provider switch never leaves another provider's fields on screen.
937
+ syncNewConversationProviderRows(normalizedProvider);
823
938
  if (!target.options.length) return false;
824
939
  const storedModel = storedComposerModel();
825
- if (storedModel && Array.from(target.options).some((option) => option.value === storedModel)) {
940
+ if (isCloud) {
941
+ // The tab's default, not the last model another provider's chat used.
942
+ const cloudDefault = claudeCloudDefaultModel(newConversationClaudeCloudSettingsCache, eligibleModels);
943
+ if (cloudDefault) target.value = cloudDefault;
944
+ } else if (storedModel && Array.from(target.options).some((option) => option.value === storedModel)) {
826
945
  target.value = storedModel;
827
946
  } else if (normalizedProvider !== 'openai-image' && Array.from(target.options).some((option) => option.value === 'auto')) {
828
947
  target.value = 'auto';
@@ -835,6 +954,414 @@ async function populateNewConversationModelSelect(providerType = 'github') {
835
954
  return true;
836
955
  }
837
956
 
957
+ function selectedNewConversationProvider() {
958
+ return normalizeNewConversationProviderType(
959
+ String(document.getElementById('new-conversation-provider-select')?.value || '').trim(),
960
+ );
961
+ }
962
+
963
+ function isNewConversationCloudSelected() {
964
+ return selectedNewConversationProvider() === CLAUDE_CLOUD_PROVIDER;
965
+ }
966
+
967
+ function syncNewConversationProviderRows(providerType = 'github') {
968
+ const rows = newChatRowVisibility(providerType);
969
+ const cloudRow = document.getElementById('new-conversation-cloud-row');
970
+ if (cloudRow) cloudRow.hidden = !rows.cloudSource;
971
+ if (rows.cloudSource) void ensureNewConversationCloudRepoList();
972
+ const reasoningRow = document.getElementById('new-conversation-reasoning-row');
973
+ if (reasoningRow) reasoningRow.hidden = !rows.reasoning;
974
+ // The folder line reads differently for a chat that does not run in the folder.
975
+ syncNewConversationCwdControls();
976
+ }
977
+
978
+ function newConversationCloudInputs() {
979
+ return {
980
+ repo: document.getElementById('new-conversation-cloud-repo'),
981
+ branch: document.getElementById('new-conversation-cloud-branch'),
982
+ };
983
+ }
984
+
985
+ function renderNewConversationCloudWarnings() {
986
+ const container = document.getElementById('new-conversation-cloud-warnings');
987
+ if (!container) return;
988
+ const { repo, branch } = newConversationCloudInputs();
989
+ const warnings = isNewConversationCloudSelected()
990
+ ? buildCloudSourceWarnings({
991
+ remote: newConversationCloudRemote,
992
+ lookupFailed: newConversationCloudLookupFailed,
993
+ repo: repo?.value || '',
994
+ branch: branch?.value || '',
995
+ environmentId: newConversationClaudeCloudSettingsCache?.environmentId || '',
996
+ repoAccess: cloudRepoAccess(newConversationCloudRepoList, repo?.value || ''),
997
+ })
998
+ : [];
999
+ container.replaceChildren(...warnings.map((warning) => {
1000
+ const line = document.createElement('div');
1001
+ line.className = 'new-conversation-cloud-warning';
1002
+ line.dataset.kind = warning.kind;
1003
+ line.textContent = warning.text;
1004
+ return line;
1005
+ }));
1006
+ }
1007
+
1008
+ // `fields` marks the inputs the message is about; none means a general error.
1009
+ function showNewConversationCloudError(text = '', fields = []) {
1010
+ const errorEl = document.getElementById('new-conversation-cloud-error');
1011
+ if (errorEl) {
1012
+ errorEl.textContent = text;
1013
+ errorEl.hidden = !text;
1014
+ }
1015
+ const inputs = newConversationCloudInputs();
1016
+ for (const [name, input] of Object.entries(inputs)) {
1017
+ if (!input) continue;
1018
+ if (fields.includes(name)) input.setAttribute('aria-invalid', 'true');
1019
+ else input.removeAttribute('aria-invalid');
1020
+ }
1021
+ const firstInvalid = fields.map((name) => inputs[name]).find(Boolean);
1022
+ if (text && firstInvalid) firstInvalid.focus?.();
1023
+ }
1024
+
1025
+ function resetNewConversationCloudState() {
1026
+ if (newConversationCloudLookupTimer) clearTimeout(newConversationCloudLookupTimer);
1027
+ newConversationCloudLookupTimer = null;
1028
+ newConversationCloudLookupSeq += 1;
1029
+ newConversationCloudRemote = null;
1030
+ newConversationCloudLookupFailed = false;
1031
+ newConversationCloudLookupFolder = null;
1032
+ newConversationCloudAutoFill = null;
1033
+ if (newConversationCloudBranchTimer) clearTimeout(newConversationCloudBranchTimer);
1034
+ newConversationCloudBranchTimer = null;
1035
+ newConversationCloudBranchSeq += 1;
1036
+ newConversationCloudRepoList = null;
1037
+ newConversationCloudRepoListPromise = null;
1038
+ newConversationCloudBranchList = null;
1039
+ newConversationCloudBranchAutoFill = '';
1040
+ const { repo, branch } = newConversationCloudInputs();
1041
+ if (repo) repo.value = '';
1042
+ if (branch) branch.value = '';
1043
+ hideNewConversationCloudSuggestions('repo');
1044
+ hideNewConversationCloudSuggestions('branch');
1045
+ showNewConversationCloudError('');
1046
+ renderNewConversationCloudWarnings();
1047
+ }
1048
+
1049
+ // ── Repository and branch suggestions ──
1050
+
1051
+ /** Read the repository list once per open; the warnings and an open list follow. */
1052
+ function ensureNewConversationCloudRepoList() {
1053
+ if (newConversationCloudRepoList || newConversationCloudRepoListPromise) return newConversationCloudRepoListPromise;
1054
+ const seq = newConversationCloudLookupSeq;
1055
+ newConversationCloudRepoListPromise = loadClaudeCloudRepos().then((payload) => {
1056
+ // A reset in the meantime (the modal closed) owns the fields now.
1057
+ if (seq !== newConversationCloudLookupSeq) return;
1058
+ newConversationCloudRepoList = payload && typeof payload === 'object' ? payload : { ok: false, repos: [] };
1059
+ newConversationCloudRepoListPromise = null;
1060
+ renderNewConversationCloudWarnings();
1061
+ renderNewConversationCloudSuggestions('repo');
1062
+ }).catch(() => {
1063
+ newConversationCloudRepoListPromise = null;
1064
+ });
1065
+ return newConversationCloudRepoListPromise;
1066
+ }
1067
+
1068
+ /** The ↻ next to Repository: read the list afresh from the relay (and Anthropic). */
1069
+ export async function refreshNewConversationCloudRepoList() {
1070
+ const button = document.getElementById('new-conversation-cloud-repo-refresh');
1071
+ if (button?.getAttribute('aria-busy') === 'true') return;
1072
+ button?.setAttribute('aria-busy', 'true');
1073
+ const seq = newConversationCloudLookupSeq;
1074
+ try {
1075
+ const payload = await loadClaudeCloudRepos({ refresh: true });
1076
+ if (seq !== newConversationCloudLookupSeq) return;
1077
+ newConversationCloudRepoList = payload && typeof payload === 'object' ? payload : { ok: false, repos: [] };
1078
+ renderNewConversationCloudWarnings();
1079
+ const { repo } = newConversationCloudInputs();
1080
+ if (repo && document.activeElement !== repo) repo.focus?.();
1081
+ renderNewConversationCloudSuggestions('repo');
1082
+ } finally {
1083
+ button?.removeAttribute('aria-busy');
1084
+ }
1085
+ }
1086
+
1087
+ function newConversationCloudSuggestList(field) {
1088
+ return document.getElementById(`new-conversation-cloud-${field}-list`);
1089
+ }
1090
+
1091
+ /** The entries to offer for what the field holds: `{ value, label, meta }`. */
1092
+ function newConversationCloudSuggestions(field) {
1093
+ const { repo, branch } = newConversationCloudInputs();
1094
+ if (field === 'repo') {
1095
+ const entries = Array.isArray(newConversationCloudRepoList?.repos) ? newConversationCloudRepoList.repos : [];
1096
+ return filterCloudRepoSuggestions(entries, repo?.value || '')
1097
+ .map((entry) => ({ value: entry.slug, label: entry.slug, meta: cloudRepoSuggestionMeta(entry) }));
1098
+ }
1099
+ const list = newConversationCloudBranchList;
1100
+ const slug = normalizeCloudRepoInput(repo?.value || '')?.slug || '';
1101
+ if (!list || !slug || list.slug !== slug) return [];
1102
+ // A default branch the pick wrote in is not a filter: the whole list shows.
1103
+ const typed = branch?.value || '';
1104
+ const query = typed && typed === newConversationCloudBranchAutoFill ? '' : typed;
1105
+ return filterCloudBranchSuggestions(list.branches, query, { defaultBranch: list.defaultBranch })
1106
+ .map((name) => ({ value: name, label: name, meta: name === list.defaultBranch ? 'default' : '' }));
1107
+ }
1108
+
1109
+ function hideNewConversationCloudSuggestions(field) {
1110
+ const list = newConversationCloudSuggestList(field);
1111
+ if (list) {
1112
+ list.hidden = true;
1113
+ list.replaceChildren();
1114
+ }
1115
+ newConversationCloudInputs()[field]?.setAttribute('aria-expanded', 'false');
1116
+ newConversationCloudSuggestActive[field] = -1;
1117
+ }
1118
+
1119
+ /**
1120
+ * Show the suggestions for a focused field. The list lives in the modal's
1121
+ * flow under the field (a popover would be clipped by the scrolling modal on
1122
+ * a phone). Nothing to offer, or the field not focused: no list.
1123
+ */
1124
+ function renderNewConversationCloudSuggestions(field) {
1125
+ const input = newConversationCloudInputs()[field];
1126
+ const list = newConversationCloudSuggestList(field);
1127
+ if (!input || !list) return;
1128
+ if (!isNewConversationCloudSelected() || document.activeElement !== input) {
1129
+ hideNewConversationCloudSuggestions(field);
1130
+ return;
1131
+ }
1132
+ const suggestions = newConversationCloudSuggestions(field);
1133
+ // The list could not be read from Claude Cloud: the recent repositories
1134
+ // are still offered, with the reason under them.
1135
+ const listError = field === 'repo' && newConversationCloudRepoList && newConversationCloudRepoList.ok === false
1136
+ ? String(newConversationCloudRepoList.error?.message || 'The repositories could not be read from Claude Cloud.').trim()
1137
+ : '';
1138
+ if (!suggestions.length && !listError) {
1139
+ hideNewConversationCloudSuggestions(field);
1140
+ return;
1141
+ }
1142
+ const active = Math.min(newConversationCloudSuggestActive[field], suggestions.length - 1);
1143
+ newConversationCloudSuggestActive[field] = active;
1144
+ list.replaceChildren(...suggestions.map((suggestion, index) => {
1145
+ const item = document.createElement('button');
1146
+ item.type = 'button';
1147
+ item.className = 'new-conversation-cloud-suggest-item';
1148
+ item.setAttribute('role', 'option');
1149
+ item.setAttribute('aria-selected', index === active ? 'true' : 'false');
1150
+ item.dataset.value = suggestion.value;
1151
+ const label = document.createElement('span');
1152
+ label.textContent = suggestion.label;
1153
+ item.append(label);
1154
+ if (suggestion.meta) {
1155
+ const meta = document.createElement('span');
1156
+ meta.className = 'new-conversation-cloud-suggest-meta';
1157
+ meta.textContent = suggestion.meta;
1158
+ item.append(meta);
1159
+ }
1160
+ return item;
1161
+ }));
1162
+ if (listError) {
1163
+ const note = document.createElement('div');
1164
+ note.className = 'new-conversation-cloud-suggest-note';
1165
+ note.textContent = listError;
1166
+ list.append(note);
1167
+ }
1168
+ list.hidden = false;
1169
+ input.setAttribute('aria-expanded', 'true');
1170
+ }
1171
+
1172
+ /** A suggestion taken: the field holds it, and a repository brings its default branch along. */
1173
+ function pickNewConversationCloudSuggestion(field, value) {
1174
+ const { repo, branch } = newConversationCloudInputs();
1175
+ const input = field === 'repo' ? repo : branch;
1176
+ if (!input) return;
1177
+ input.value = value;
1178
+ hideNewConversationCloudSuggestions(field);
1179
+ if (field === 'repo') {
1180
+ const entries = Array.isArray(newConversationCloudRepoList?.repos) ? newConversationCloudRepoList.repos : [];
1181
+ const entry = entries.find((candidate) => candidate?.slug === value) || null;
1182
+ const defaultBranch = String(entry?.defaultBranch || '').trim();
1183
+ // The default branch is preselected; a branch the user typed is kept.
1184
+ if (branch && (!branch.value.trim() || branch.value === newConversationCloudBranchAutoFill)) {
1185
+ branch.value = defaultBranch;
1186
+ newConversationCloudBranchAutoFill = defaultBranch;
1187
+ }
1188
+ newConversationCloudBranchList = null;
1189
+ scheduleNewConversationCloudBranchLookup(0);
1190
+ }
1191
+ showNewConversationCloudError('');
1192
+ renderNewConversationCloudWarnings();
1193
+ input.focus?.();
1194
+ }
1195
+
1196
+ /** Read the branches of the repository in the field, unless they are known. */
1197
+ async function refreshNewConversationCloudBranches() {
1198
+ const { repo, branch } = newConversationCloudInputs();
1199
+ const slug = normalizeCloudRepoInput(repo?.value || '')?.slug || '';
1200
+ if (!slug) {
1201
+ newConversationCloudBranchList = null;
1202
+ renderNewConversationCloudSuggestions('branch');
1203
+ return;
1204
+ }
1205
+ if (newConversationCloudBranchList?.slug === slug) return;
1206
+ const seq = ++newConversationCloudBranchSeq;
1207
+ const payload = await loadClaudeCloudBranches(slug);
1208
+ if (seq !== newConversationCloudBranchSeq) return;
1209
+ // The host could not read them: the field stays a text field, and the
1210
+ // default branch from the repository list is still offered.
1211
+ const known = (Array.isArray(newConversationCloudRepoList?.repos) ? newConversationCloudRepoList.repos : [])
1212
+ .find((entry) => entry?.slug === slug);
1213
+ const branches = payload?.ok === true && Array.isArray(payload.branches) ? payload.branches : [];
1214
+ const defaultBranch = String((payload?.ok === true && payload.defaultBranch) || known?.defaultBranch || '').trim();
1215
+ newConversationCloudBranchList = { slug, branches, defaultBranch };
1216
+ // Only an empty field takes the default here: the folder's branch belongs
1217
+ // to the folder's repository and stays.
1218
+ if (branch && defaultBranch && !branch.value.trim()) {
1219
+ branch.value = defaultBranch;
1220
+ newConversationCloudBranchAutoFill = defaultBranch;
1221
+ renderNewConversationCloudWarnings();
1222
+ }
1223
+ renderNewConversationCloudSuggestions('branch');
1224
+ }
1225
+
1226
+ function scheduleNewConversationCloudBranchLookup(delayMs = NEW_CHAT_CLOUD_LOOKUP_DEBOUNCE_MS) {
1227
+ if (newConversationCloudBranchTimer) clearTimeout(newConversationCloudBranchTimer);
1228
+ newConversationCloudBranchTimer = null;
1229
+ if (!isNewConversationCloudSelected()) return;
1230
+ newConversationCloudBranchTimer = setTimeout(() => {
1231
+ newConversationCloudBranchTimer = null;
1232
+ void refreshNewConversationCloudBranches();
1233
+ }, delayMs);
1234
+ }
1235
+
1236
+ /** Arrow keys walk the open list, Enter takes the marked entry, Escape closes it. */
1237
+ function handleNewConversationCloudSuggestKey(field, event) {
1238
+ const list = newConversationCloudSuggestList(field);
1239
+ const open = list && !list.hidden;
1240
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
1241
+ if (!open) {
1242
+ renderNewConversationCloudSuggestions(field);
1243
+ if (!list || list.hidden) return;
1244
+ }
1245
+ const items = [...list.querySelectorAll('.new-conversation-cloud-suggest-item')];
1246
+ if (!items.length) return;
1247
+ const current = newConversationCloudSuggestActive[field];
1248
+ const next = event.key === 'ArrowDown'
1249
+ ? (current + 1) % items.length
1250
+ : (current <= 0 ? items.length - 1 : current - 1);
1251
+ newConversationCloudSuggestActive[field] = next;
1252
+ items.forEach((item, index) => item.setAttribute('aria-selected', index === next ? 'true' : 'false'));
1253
+ items[next]?.scrollIntoView?.({ block: 'nearest' });
1254
+ event.preventDefault();
1255
+ return;
1256
+ }
1257
+ if (!open) return;
1258
+ if (event.key === 'Enter') {
1259
+ const active = list.querySelectorAll('.new-conversation-cloud-suggest-item')[newConversationCloudSuggestActive[field]];
1260
+ if (active) {
1261
+ event.preventDefault();
1262
+ pickNewConversationCloudSuggestion(field, active.dataset.value || '');
1263
+ }
1264
+ return;
1265
+ }
1266
+ if (event.key === 'Escape') {
1267
+ event.preventDefault();
1268
+ event.stopPropagation();
1269
+ hideNewConversationCloudSuggestions(field);
1270
+ }
1271
+ }
1272
+
1273
+ function bindNewConversationCloudSuggestions() {
1274
+ const inputs = newConversationCloudInputs();
1275
+ for (const [field, input] of Object.entries(inputs)) {
1276
+ const list = newConversationCloudSuggestList(field);
1277
+ if (!input || !list || list.dataset.cloudBound === '1') continue;
1278
+ list.dataset.cloudBound = '1';
1279
+ input.addEventListener('focus', () => renderNewConversationCloudSuggestions(field));
1280
+ input.addEventListener('blur', () => {
1281
+ // After the click on an entry, which takes the focus for a moment.
1282
+ setTimeout(() => {
1283
+ if (document.activeElement !== input) hideNewConversationCloudSuggestions(field);
1284
+ }, NEW_CHAT_CLOUD_SUGGEST_BLUR_MS);
1285
+ });
1286
+ input.addEventListener('keydown', (event) => handleNewConversationCloudSuggestKey(field, event));
1287
+ // The entries are buttons: a mousedown would move the focus off the
1288
+ // field and close the list before the click lands.
1289
+ list.addEventListener('mousedown', (event) => event.preventDefault());
1290
+ list.addEventListener('click', (event) => {
1291
+ const item = event.target?.closest?.('.new-conversation-cloud-suggest-item');
1292
+ if (item) pickNewConversationCloudSuggestion(field, item.dataset.value || '');
1293
+ });
1294
+ }
1295
+ }
1296
+
1297
+ // Reads origin + branch of the selected folder and fills the two fields.
1298
+ async function refreshNewConversationCloudSource() {
1299
+ if (!isNewConversationCloudSelected()) return;
1300
+ const pickedFolder = getNewConversationSelectedCwd();
1301
+ const folder = pickedFolder || resolveNewConversationDefaultCwd();
1302
+ if (folder === newConversationCloudLookupFolder) {
1303
+ renderNewConversationCloudWarnings();
1304
+ return;
1305
+ }
1306
+ const seq = ++newConversationCloudLookupSeq;
1307
+ newConversationCloudLookupFolder = folder;
1308
+ let remote = folder ? await loadGitRemote(folder) : null;
1309
+ // A newer folder choice (or a closed modal) owns the fields by now.
1310
+ if (seq !== newConversationCloudLookupSeq) return;
1311
+ let lookupFailed = !!folder && !remote;
1312
+ // Nobody picked the relay's default folder: when it has no repository to
1313
+ // offer there is nothing to warn about, the fields are simply typed.
1314
+ if (!pickedFolder && !String(remote?.repoUrl || '').trim()) {
1315
+ remote = null;
1316
+ lookupFailed = false;
1317
+ }
1318
+ newConversationCloudRemote = remote;
1319
+ newConversationCloudLookupFailed = lookupFailed;
1320
+ const { repo, branch } = newConversationCloudInputs();
1321
+ const next = resolveCloudSourceAutoFill({
1322
+ current: { repo: repo?.value || '', branch: branch?.value || '' },
1323
+ lastAutoFill: newConversationCloudAutoFill,
1324
+ remote,
1325
+ });
1326
+ if (repo) repo.value = next.repo;
1327
+ if (branch) branch.value = next.branch;
1328
+ newConversationCloudAutoFill = next.autoFill;
1329
+ newConversationCloudBranchAutoFill = next.branch;
1330
+ showNewConversationCloudError('');
1331
+ renderNewConversationCloudWarnings();
1332
+ scheduleNewConversationCloudBranchLookup(0);
1333
+ }
1334
+
1335
+ // Debounced: the manual path fires on every keystroke.
1336
+ function scheduleNewConversationCloudLookup() {
1337
+ if (newConversationCloudLookupTimer) clearTimeout(newConversationCloudLookupTimer);
1338
+ newConversationCloudLookupTimer = null;
1339
+ if (!isNewConversationCloudSelected()) return;
1340
+ newConversationCloudLookupTimer = setTimeout(() => {
1341
+ newConversationCloudLookupTimer = null;
1342
+ void refreshNewConversationCloudSource();
1343
+ }, NEW_CHAT_CLOUD_LOOKUP_DEBOUNCE_MS);
1344
+ }
1345
+
1346
+ function bindNewConversationCloudInputs() {
1347
+ for (const [field, input] of Object.entries(newConversationCloudInputs())) {
1348
+ if (!input || input.dataset.cloudBound === '1') continue;
1349
+ input.dataset.cloudBound = '1';
1350
+ input.addEventListener('input', () => {
1351
+ // The warnings depend on whether the fields still name the folder's
1352
+ // repository and branch; an edit also answers the last error.
1353
+ showNewConversationCloudError('');
1354
+ renderNewConversationCloudWarnings();
1355
+ newConversationCloudSuggestActive[field] = -1;
1356
+ renderNewConversationCloudSuggestions(field);
1357
+ // A typed branch is the user's own from here on.
1358
+ if (field === 'branch') newConversationCloudBranchAutoFill = '';
1359
+ else scheduleNewConversationCloudBranchLookup();
1360
+ });
1361
+ }
1362
+ bindNewConversationCloudSuggestions();
1363
+ }
1364
+
838
1365
  function resolveNewConversationDefaultCwd() {
839
1366
  return normalizeKnownCwdPath(defaultSessionWorkspaceRootPath || workspaceRootPath || '');
840
1367
  }
@@ -855,6 +1382,12 @@ function syncNewConversationCwdControls() {
855
1382
  if (!select) return;
856
1383
  const isCustom = select.value === NEW_CHAT_CUSTOM_CWD_VALUE;
857
1384
  if (manual) manual.hidden = !isCustom;
1385
+ if (isNewConversationCloudSelected()) {
1386
+ // The folder only supplies the repository and branch for a cloud chat.
1387
+ scheduleNewConversationCloudLookup();
1388
+ if (status) status.textContent = cloudFolderStatusText(getNewConversationSelectedCwd());
1389
+ return;
1390
+ }
858
1391
  if (!status) return;
859
1392
  if (isCustom) {
860
1393
  const manualPath = normalizeKnownCwdPath(manual?.value || '');
@@ -943,18 +1476,24 @@ function populateNewConversationCwdSelect() {
943
1476
  }
944
1477
 
945
1478
  async function openNewConversationModelModal() {
946
- const [catalog, settings, claudeSettings, cursorSettings, grokSettings] = await Promise.all([
1479
+ const [catalog, settings, claudeSettings, cursorSettings, grokSettings, claudeCloudSettings] = await Promise.all([
947
1480
  loadModelCatalog(),
948
1481
  loadOpenAISettings(),
949
1482
  loadClaudeSettings(),
950
1483
  loadCursorSettings(),
951
1484
  loadGrokSettings(),
1485
+ // null on a relay without Claude Cloud: the option is simply not offered.
1486
+ loadClaudeCloudSettings(),
952
1487
  ]);
953
1488
  newConversationCatalogCache = catalog || null;
954
1489
  newConversationOpenAISettingsCache = settings || null;
955
1490
  newConversationClaudeSettingsCache = claudeSettings || null;
956
1491
  newConversationCursorSettingsCache = cursorSettings || null;
957
1492
  newConversationGrokSettingsCache = grokSettings || null;
1493
+ newConversationClaudeCloudSettingsCache = claudeCloudSettings || null;
1494
+ // Every open starts from empty cloud fields; the folder lookup refills them.
1495
+ resetNewConversationCloudState();
1496
+ bindNewConversationCloudInputs();
958
1497
  const providerSelect = document.getElementById('new-conversation-provider-select');
959
1498
  if (providerSelect) {
960
1499
  const options = [{ value: 'github', label: 'Copilot' }];
@@ -965,6 +1504,9 @@ async function openNewConversationModelModal() {
965
1504
  if (claudeSettings?.enabled === true) {
966
1505
  options.push({ value: 'claude', label: 'Claude SDK' });
967
1506
  }
1507
+ if (claudeCloudSettings?.enabled === true) {
1508
+ options.push({ value: CLAUDE_CLOUD_PROVIDER, label: 'Claude Cloud' });
1509
+ }
968
1510
  if (cursorSettings?.enabled === true) {
969
1511
  options.push({ value: 'cursor', label: 'Cursor SDK' });
970
1512
  }
@@ -1057,8 +1599,10 @@ async function openBootstrappedConversation({
1057
1599
  hideNewConversationModelModal();
1058
1600
  // The server echoes what it actually bound and stored; the composer picks
1059
1601
  // those up from the conversation row when it opens, so only the shared
1060
- // "last used" storage is updated here.
1061
- rememberBootstrappedSelection(payload);
1602
+ // "last used" storage is updated here. A cloud chat starts from its tab's
1603
+ // default model and has no effort, so it must not become what the next
1604
+ // local chat starts with.
1605
+ if (selectedProvider !== CLAUDE_CLOUD_PROVIDER) rememberBootstrappedSelection(payload);
1062
1606
  await refreshConversations();
1063
1607
  await openConversation(conversationId);
1064
1608
  if (selectedProvider === 'openai-image') {
@@ -1071,12 +1615,26 @@ async function openBootstrappedConversation({
1071
1615
 
1072
1616
  async function createNewConversation(selectedModel, selectedReasoningEffort = '', selectedCwd = '') {
1073
1617
  if (newConversationInFlight) return;
1618
+ const selectedProvider = selectedNewConversationProvider();
1619
+ const isCloud = selectedProvider === CLAUDE_CLOUD_PROVIDER;
1620
+ let cloudSource;
1621
+ if (isCloud) {
1622
+ // Checked before anything is sent: both fields can be fixed in place.
1623
+ const { repo, branch } = newConversationCloudInputs();
1624
+ const validation = validateCloudSourceInputs({ repo: repo?.value || '', branch: branch?.value || '' });
1625
+ if (!validation.ok) {
1626
+ showNewConversationCloudError(
1627
+ [validation.errors.repo, validation.errors.branch].filter(Boolean).join(' '),
1628
+ Object.keys(validation.errors),
1629
+ );
1630
+ return;
1631
+ }
1632
+ showNewConversationCloudError('');
1633
+ cloudSource = validation.cloudSource;
1634
+ }
1074
1635
  setNewConversationInFlight(true);
1075
1636
  const confirmButton = document.getElementById('new-conversation-model-confirm');
1076
1637
  if (confirmButton) confirmButton.disabled = true;
1077
- const selectedProvider = normalizeNewConversationProviderType(
1078
- String(document.getElementById('new-conversation-provider-select')?.value || '').trim(),
1079
- );
1080
1638
  const selectedSize = String(document.getElementById('new-conversation-size-select')?.value || '').trim().toLowerCase();
1081
1639
  if (selectedProvider === 'openai-image' && selectedSize) {
1082
1640
  localStorage.setItem(OPENAI_IMAGE_SIZE_STORAGE_KEY, selectedSize);
@@ -1085,17 +1643,26 @@ async function createNewConversation(selectedModel, selectedReasoningEffort = ''
1085
1643
  const result = await bootstrapConversationSession({
1086
1644
  model: selectedModel || undefined,
1087
1645
  providerType: bootstrapProviderType(selectedProvider),
1088
- reasoningEffort: String(selectedReasoningEffort || '').trim().toLowerCase() || undefined,
1646
+ reasoningEffort: isCloud
1647
+ ? undefined
1648
+ : (String(selectedReasoningEffort || '').trim().toLowerCase() || undefined),
1089
1649
  // Bootstrap writes the conversation's preferences, so the current mode has
1090
1650
  // to travel with it or every new chat would come back as the default one.
1091
1651
  // The composer's selector wins over storage: storage holds the last
1092
1652
  // explicit choice, which is not the mode of the conversation on screen.
1093
- relayMode: String(
1094
- document.getElementById('mode-select')?.value
1095
- || localStorage.getItem(MODE_STORAGE_KEY)
1096
- || '',
1097
- ).trim() || undefined,
1653
+ // A cloud session has no relay modes, so it never inherits Plan or Ask.
1654
+ relayMode: isCloud
1655
+ ? 'agent'
1656
+ : (String(
1657
+ // With a cloud chat on screen the selector is hidden and pinned to
1658
+ // "agent": that is not a choice, so the last explicit one applies.
1659
+ (isClaudeCloudConversation(conversations[currentConvId]) ? '' : document.getElementById('mode-select')?.value)
1660
+ || localStorage.getItem(MODE_STORAGE_KEY)
1661
+ || '',
1662
+ ).trim() || undefined),
1098
1663
  workspaceRootPath: selectedCwd || undefined,
1664
+ // Claude Cloud only: the repository (and branch) the session clones.
1665
+ cloudSource,
1099
1666
  title: 'New Conversation',
1100
1667
  });
1101
1668
  const nextConversationId = String(result?.conversationId || '').trim();
@@ -1142,6 +1709,15 @@ async function createNewConversation(selectedModel, selectedReasoningEffort = ''
1142
1709
  // conversation exists and the sidebar refresh will show it.
1143
1710
  }
1144
1711
  }
1712
+ // A Claude Cloud rejection (bad repository or branch, provider switched
1713
+ // off, no environment) belongs next to the fields it is about.
1714
+ const cloudError = isCloud && !recovered
1715
+ ? cloudBootstrapErrorModel({ code: error?.payload?.code, message: error?.payload?.error })
1716
+ : null;
1717
+ if (cloudError) {
1718
+ showNewConversationCloudError(cloudError.text, cloudError.field ? [cloudError.field] : []);
1719
+ return;
1720
+ }
1145
1721
  showTransientRelayNotice(recovered
1146
1722
  // The chat is usable, but its worker did not start, so say what broke.
1147
1723
  ? `The chat was created, but its session could not start: ${error?.message || 'unknown error'}.`
@@ -1284,6 +1860,106 @@ export function initConversationFilter() {
1284
1860
  syncClearButton();
1285
1861
  }
1286
1862
 
1863
+ // ── Archive, and the row's context menu ──
1864
+
1865
+ // Which of the two views the sidebar shows: the live conversations, or the
1866
+ // archived ones (the 🗄 toggle in the filter row).
1867
+ let conversationListArchivedView = false;
1868
+
1869
+ export function isArchivedConversationsView() {
1870
+ return conversationListArchivedView;
1871
+ }
1872
+
1873
+ export async function toggleArchivedConversations(next = !conversationListArchivedView) {
1874
+ conversationListArchivedView = next === true;
1875
+ const toggle = document.getElementById('conv-archived-toggle');
1876
+ if (toggle) {
1877
+ toggle.setAttribute('aria-pressed', conversationListArchivedView ? 'true' : 'false');
1878
+ toggle.title = conversationListArchivedView ? 'Back to the conversations' : 'Show archived conversations';
1879
+ toggle.setAttribute('aria-label', toggle.title);
1880
+ }
1881
+ const banner = document.getElementById('conv-archived-banner');
1882
+ if (banner) banner.hidden = !conversationListArchivedView;
1883
+ renderConvList();
1884
+ // The other view's pages come from the relay; the records already known
1885
+ // (an archived chat that was open, say) show at once.
1886
+ await refreshConversations({ preservePagination: false });
1887
+ }
1888
+
1889
+ async function setConversationArchived(id, archived) {
1890
+ const result = await (archived ? archiveConversationApi(id) : unarchiveConversationApi(id));
1891
+ if (!result) return false;
1892
+ if (result.ok === false) {
1893
+ showTransientRelayNotice(
1894
+ String(result.message || '').trim() || `This conversation could not be ${archived ? 'archived' : 'unarchived'}.`,
1895
+ 6000,
1896
+ );
1897
+ return false;
1898
+ }
1899
+ if (conversations[id]) conversations[id].archived = archived;
1900
+ renderConvList();
1901
+ window.syncChatTitleControls?.();
1902
+ window.syncComposerButtonState?.();
1903
+ return true;
1904
+ }
1905
+
1906
+ export async function archiveConv(e, id) {
1907
+ e?.stopPropagation?.();
1908
+ closeContextMenu();
1909
+ await setConversationArchived(id, true);
1910
+ }
1911
+
1912
+ export async function unarchiveConv(e, id) {
1913
+ e?.stopPropagation?.();
1914
+ closeContextMenu();
1915
+ await setConversationArchived(id, false);
1916
+ }
1917
+
1918
+ /** The entries of a conversation row's menu. */
1919
+ function conversationMenuItems(id) {
1920
+ const conversation = conversations[id];
1921
+ if (!conversation) return [];
1922
+ const isCurrent = currentConvId === id;
1923
+ const sdkSessionId = String(conversation.sdkSessionId || '').trim();
1924
+ const workerState = sdkSessionId ? getSessionWorkerState(sdkSessionId) : null;
1925
+ const running = conversationHasActiveTurn(id) || isConversationProcessing(conversation, workerState);
1926
+ const openFirst = async (then) => {
1927
+ if (!isCurrent) await openConversation(id);
1928
+ then();
1929
+ };
1930
+ return [
1931
+ { id: 'open', label: 'Open', onSelect: () => { void openConversation(id); } },
1932
+ { id: 'edit-title', label: 'Edit title', onSelect: () => { void openFirst(() => window.openChatTitleEditor?.()); } },
1933
+ MENU_SEPARATOR,
1934
+ { id: 'stop-turn', label: 'Stop turn', disabled: !running, onSelect: () => { void openFirst(() => openStopTurnConfirmationForConversation(id)); } },
1935
+ { id: 'kill-session', label: 'Kill session', disabled: !workerState && !sdkSessionId, onSelect: () => { void openFirst(() => window.openKillSessionConfirmation?.()); } },
1936
+ MENU_SEPARATOR,
1937
+ conversation.archived === true
1938
+ ? { id: 'unarchive', label: 'Unarchive', onSelect: () => { void unarchiveConv(null, id); } }
1939
+ : { id: 'archive', label: 'Archive', onSelect: () => { void archiveConv(null, id); } },
1940
+ { id: 'delete', label: 'Delete', danger: true, onSelect: () => { void deleteConv({ stopPropagation() {} }, id); } },
1941
+ ];
1942
+ }
1943
+
1944
+ /** Right-click (and long press on touch) on a row opens its menu. Bound once; the list's rows are re-rendered freely. */
1945
+ function bindConversationContextMenu(list) {
1946
+ if (!list || list.dataset.contextMenuBound === '1') return;
1947
+ list.dataset.contextMenuBound = '1';
1948
+ const openFor = (row, x, y) => {
1949
+ const id = String(row?.dataset?.conversationId || '').trim();
1950
+ if (!id) return;
1951
+ const items = conversationMenuItems(id);
1952
+ if (items.length) openContextMenu(x, y, items);
1953
+ };
1954
+ list.addEventListener('contextmenu', (event) => {
1955
+ const row = event.target?.closest?.('.conv-item');
1956
+ if (!row || !list.contains(row)) return;
1957
+ event.preventDefault();
1958
+ openFor(row, event.clientX, event.clientY);
1959
+ });
1960
+ bindLongPress(list, '.conv-item', (row, x, y) => openFor(row, x, y));
1961
+ }
1962
+
1287
1963
  export async function deleteConv(e, id) {
1288
1964
  e.stopPropagation();
1289
1965
  if (!confirm('Delete this conversation?')) return;