@oar-sh/oar 0.9.6 → 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 (81) hide show
  1. package/CHANGELOG.md +172 -0
  2. package/README.md +176 -22
  3. package/package.json +1 -1
  4. package/server/README.md +174 -7
  5. package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +340 -0
  6. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +1325 -0
  7. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +155 -0
  8. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  9. package/server/claude-worker/claude-sdk-adapter.mjs +18 -2
  10. package/server/claude-worker/claude-session-process.mjs +33 -0
  11. package/server/claude-worker/claude-session-worker.mjs +6 -0
  12. package/server/db-schema.mjs +17 -0
  13. package/server/migrations/0006-workspace-root-settings.mjs +67 -0
  14. package/server/migrations/0007-claude-cloud.mjs +82 -0
  15. package/server/public/app/agent-sessions-settings-ui.js +197 -0
  16. package/server/public/app/agent-sessions-settings.mjs +134 -0
  17. package/server/public/app/api-client.js +143 -4
  18. package/server/public/app/attachments-view.js +20 -1
  19. package/server/public/app/attribution-control.mjs +46 -0
  20. package/server/public/app/bootstrap.js +344 -31
  21. package/server/public/app/claude-cloud-conversation-ui.js +110 -0
  22. package/server/public/app/claude-cloud-settings-ui.js +374 -0
  23. package/server/public/app/claude-cloud-ui.mjs +611 -0
  24. package/server/public/app/composer-control-state.mjs +11 -0
  25. package/server/public/app/context-menu.mjs +140 -0
  26. package/server/public/app/context-tier-options.mjs +4 -0
  27. package/server/public/app/conversation-origin-ui.js +27 -0
  28. package/server/public/app/conversation-provider-indicator.mjs +22 -4
  29. package/server/public/app/conversation-view.js +83 -4
  30. package/server/public/app/cwd-picker.js +12 -0
  31. package/server/public/app/journal-view.js +698 -28
  32. package/server/public/app/math-dollars.mjs +89 -0
  33. package/server/public/app/model-selector-options.mjs +1 -1
  34. package/server/public/app/plan-usage-view.mjs +36 -8
  35. package/server/public/app/relay-error-ctas.mjs +16 -2
  36. package/server/public/app/remote-relay-origin-view.mjs +120 -4
  37. package/server/public/app/router.js +49 -12
  38. package/server/public/app/settings-modal.js +33 -2
  39. package/server/public/app/settings-tabs.js +1 -1
  40. package/server/public/app/socket-handlers.js +51 -0
  41. package/server/public/index.html +381 -7
  42. package/server/relay-tools.md +13 -1
  43. package/server/repositories/remote-relay-repository.mjs +42 -1
  44. package/server/repositories/session-repository.mjs +47 -1
  45. package/server/routes/claude-cloud-routes.mjs +140 -0
  46. package/server/routes/git-routes.mjs +37 -0
  47. package/server/routes/messages-routes.mjs +120 -15
  48. package/server/routes/remote-relay-routes.mjs +15 -1
  49. package/server/routes/sessions-routes.mjs +502 -72
  50. package/server/server-runtime.mjs +258 -25
  51. package/server/services/claude-account-usage-service.mjs +176 -0
  52. package/server/services/claude-cloud-repo-service.mjs +408 -0
  53. package/server/services/claude-cloud-session-service.mjs +326 -0
  54. package/server/services/claude-cloud-settings-service.mjs +269 -0
  55. package/server/services/git-remote-service.mjs +115 -0
  56. package/server/services/plan-usage-claude-cloud.mjs +211 -0
  57. package/server/services/plan-usage-claude.mjs +202 -40
  58. package/server/services/plan-usage-contract.mjs +39 -3
  59. package/server/services/plan-usage-service.mjs +82 -2
  60. package/server/services/provider-reasoning-effort.mjs +3 -0
  61. package/server/services/remote-relay-caller-context.mjs +49 -10
  62. package/server/services/remote-relay-client.mjs +9 -0
  63. package/server/services/remote-relay-dispatcher.mjs +383 -35
  64. package/server/services/remote-relay-inbound.mjs +49 -2
  65. package/server/services/remote-relay-loopback.mjs +245 -0
  66. package/server/services/remote-relay-registry-service.mjs +52 -0
  67. package/server/services/session-worker-launch-service.mjs +100 -15
  68. package/server/services/session-worker-process-service.mjs +348 -129
  69. package/server/services/session-worker-stop-service.mjs +24 -13
  70. package/server/services/session-worker-supervisor-service.mjs +56 -1
  71. package/server/services/usage-limit-pause-service.mjs +3 -1
  72. package/server/services/worker-launch-note-service.mjs +151 -0
  73. package/shared/claude-attribution.mjs +117 -0
  74. package/shared/claude-cloud/api-client.mjs +809 -0
  75. package/shared/claude-cloud/base-url.mjs +47 -0
  76. package/shared/claude-cloud/credentials.mjs +221 -0
  77. package/shared/claude-cloud/repo-url.mjs +111 -0
  78. package/shared/provider-routing.mjs +4 -2
  79. package/shared/remote-relay-contract.mjs +153 -22
  80. package/shared/remote-relay-tool-core.mjs +11 -8
  81. package/shared/steer-settle-failure.mjs +1 -0
@@ -28,15 +28,22 @@ 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';
41
48
  import { setUsageLimitConversation } from './usage-limit-ui.js';
42
49
  import { mergeConversationPreviews } from './preview-cards.mjs';
@@ -59,6 +66,23 @@ import {
59
66
  conversationProviderIndicatorLabel,
60
67
  } from './conversation-provider-indicator.mjs';
61
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';
62
86
  import { renderConversationOriginMarkerHtml } from './remote-relay-origin-view.mjs';
63
87
  import {
64
88
  normalizeConversationFilter,
@@ -108,6 +132,30 @@ let newConversationOpenAISettingsCache = null;
108
132
  let newConversationClaudeSettingsCache = null;
109
133
  let newConversationCursorSettingsCache = null;
110
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;
111
159
  let conversationListBoundaryCheckFrame = 0;
112
160
  let conversationListAutoLoadBlockedUntil = 0;
113
161
  let conversationListPaginationState = {
@@ -178,6 +226,7 @@ const conversationListLoader = createInfiniteLoader({
178
226
  limit: CONVERSATION_LIST_PAGE_SIZE,
179
227
  beforeConversationId: String(cursor?.beforeConversationId || '').trim(),
180
228
  beforeUpdatedAt: String(cursor?.beforeUpdatedAt || '').trim(),
229
+ archived: conversationListArchivedView ? 'only' : '',
181
230
  });
182
231
  if (!response) throw new Error('Could not load conversations');
183
232
  return {
@@ -267,7 +316,10 @@ export async function loadConversations() {
267
316
 
268
317
  export async function refreshConversations(options = {}) {
269
318
  const preservePagination = options?.preservePagination !== false;
270
- 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
+ });
271
323
  if (!r) return;
272
324
  if (Array.isArray(r.knownConversationIds)) {
273
325
  const knownConversationIds = new Set(
@@ -300,7 +352,9 @@ export function renderConvList() {
300
352
  return String(b?.id || '').trim().localeCompare(String(a?.id || '').trim());
301
353
  });
302
354
  const activeFilter = normalizeConversationFilter(conversationListFilterText);
303
- 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;
304
358
  const pendingByConversation = getPendingQuestionCountsByConversation();
305
359
  let hasProcessingConversation = false;
306
360
  const footerHtml = (() => {
@@ -322,9 +376,9 @@ export function renderConvList() {
322
376
  })();
323
377
  if (visible.length === 0) {
324
378
  ensureProcessingDotTimer(false);
325
- const emptyText = activeFilter && sorted.length > 0
379
+ const emptyText = activeFilter && inView.length > 0
326
380
  ? 'No conversations match your filter'
327
- : 'No conversations yet';
381
+ : (conversationListArchivedView ? 'No archived conversations' : 'No conversations yet');
328
382
  list.innerHTML = `<div style="padding:12px;color:var(--muted);font-size:0.85rem;text-align:center">${emptyText}</div>${footerHtml}`;
329
383
  lastConvListHtml = '';
330
384
  scheduleConversationListBoundaryCheck();
@@ -352,11 +406,15 @@ export function renderConvList() {
352
406
  ? `<span class="conv-provider-indicator"${providerIndicatorKey ? ` data-provider="${providerIndicatorKey}"` : ''}>${providerIndicatorLabel}</span>`
353
407
  : '';
354
408
  // "↗ win-test": another relay's agent created this conversation.
355
- const originMarkerHtml = renderConversationOriginMarkerHtml(c);
409
+ // "via agent · “title”": an agent in that conversation on this relay did.
410
+ const originMarkerHtml = renderConversationOriginMarkerHtml(c, { conversations });
356
411
  return `
357
- <div class="conv-item worker-ui-${view.visualState}${c.id === currentConvId ? ' active' : ''}" onclick="openConversation('${c.id}')">
358
- <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>
359
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>`}
360
418
  <button class="conv-delete" onclick="deleteConv(event,'${c.id}')" title="Delete">🗑</button>
361
419
  </div>`;
362
420
  }).join('')}${footerHtml}`;
@@ -364,6 +422,7 @@ export function renderConvList() {
364
422
  list.innerHTML = listHtml;
365
423
  lastConvListHtml = listHtml;
366
424
  }
425
+ bindConversationContextMenu(list);
367
426
  ensureProcessingDotTimer(hasProcessingConversation);
368
427
  window.syncChatTitleControls?.();
369
428
  scheduleConversationListBoundaryCheck();
@@ -410,6 +469,12 @@ export function applyLoadedConversationState(id, response, {
410
469
  ?? existingConversation.runtimeModel
411
470
  ?? null,
412
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),
413
478
  messageCount: Array.isArray(response.messages)
414
479
  ? Math.max(existingConversation.messageCount || 0, response.messages.length)
415
480
  : (existingConversation.messageCount || 0),
@@ -456,6 +521,23 @@ export function applyLoadedConversationState(id, response, {
456
521
  saveConversationScrollTop(id, el.scrollTop);
457
522
  }
458
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
+
459
541
  export async function openConversation(id, options = {}) {
460
542
  const didLeaveStatusView = leaveStatusView();
461
543
  document.getElementById('input-area')?.removeAttribute('hidden');
@@ -559,6 +641,7 @@ function normalizeNewConversationProviderType(value = '') {
559
641
  if (normalized === 'openai' || normalized === 'openai-byok') return 'openai';
560
642
  if (normalized === 'openai-image' || normalized === 'openai-image-byok') return 'openai-image';
561
643
  if (normalized === 'claude') return 'claude';
644
+ if (normalized === CLAUDE_CLOUD_PROVIDER) return CLAUDE_CLOUD_PROVIDER;
562
645
  if (normalized === 'cursor') return 'cursor';
563
646
  if (normalized === 'grok') return 'grok';
564
647
  return 'github';
@@ -639,11 +722,18 @@ function modelMatchesNewConversationProvider(catalog = {}, modelId = '', provide
639
722
  if (cursorOnly) return false;
640
723
  const grokOnly = hasGrok && providers.every((provider) => provider === 'grok');
641
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;
642
731
  return providers.some((provider) => (
643
732
  provider !== 'openai-byok'
644
733
  && provider !== 'claude'
645
734
  && provider !== 'cursor'
646
735
  && provider !== 'grok'
736
+ && provider !== CLAUDE_CLOUD_PROVIDER
647
737
  )) || !hasOpenAIByok;
648
738
  }
649
739
 
@@ -727,6 +817,13 @@ async function populateNewConversationReasoningSelect(selectedModel = '') {
727
817
  const provider = normalizeNewConversationProviderType(
728
818
  String(document.getElementById('new-conversation-provider-select')?.value || '').trim().toLowerCase(),
729
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
+ }
730
827
  syncNewConversationReasoningLabel(provider);
731
828
  const catalog = newConversationCatalogCache || await loadModelCatalog() || {};
732
829
  const efforts = reasoningChoicesForProviderModel(catalog || {}, {
@@ -785,6 +882,10 @@ function updateNewConversationProviderHelp(provider = 'github') {
785
882
  help.textContent = "Claude chats run through the Claude Agent SDK with the relay host's Claude login.";
786
883
  return;
787
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
+ }
788
889
  if (normalizedProvider === 'cursor') {
789
890
  help.textContent = 'Cursor chats run through the Cursor Agent SDK using your saved Cursor API key.';
790
891
  return;
@@ -803,12 +904,16 @@ async function populateNewConversationModelSelect(providerType = 'github') {
803
904
  const catalog = newConversationCatalogCache || await loadModelCatalog();
804
905
  if (!catalog || !Array.isArray(catalog.models)) return false;
805
906
  const normalizedProvider = normalizeNewConversationProviderType(providerType);
806
- const eligibleModels = catalog.models
807
- .map((modelId) => String(modelId || '').trim())
808
- .filter((modelId) => modelMatchesNewConversationProvider(catalog, modelId, normalizedProvider))
809
- .filter((modelId) => (
810
- normalizedProvider !== 'openai-image' || isOpenAIImageModelId(modelId)
811
- ));
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
+ ));
812
917
  // The same builder the composer's #model-select uses, on the same catalog,
813
918
  // so the two pickers agree on order and wording by construction. The modal
814
919
  // used to borrow labels from the composer, which is scoped to the OPEN
@@ -817,7 +922,8 @@ async function populateNewConversationModelSelect(providerType = 'github') {
817
922
  const choices = buildNewConversationModelChoices(
818
923
  buildCatalogModelOptions(eligibleModels, {
819
924
  metadataByModel: catalog.modelMetadataByModel || {},
820
- annotateContextWindow: normalizedProvider !== 'claude',
925
+ // The merged metadata knows Copilot's windows, not Claude's or the cloud's.
926
+ annotateContextWindow: normalizedProvider !== 'claude' && !isCloud,
821
927
  }).filter((option) => hasAuto || option.value.toLowerCase() !== 'auto'),
822
928
  );
823
929
  for (const choice of choices) {
@@ -826,9 +932,16 @@ async function populateNewConversationModelSelect(providerType = 'github') {
826
932
  option.textContent = choice.label;
827
933
  target.appendChild(option);
828
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);
829
938
  if (!target.options.length) return false;
830
939
  const storedModel = storedComposerModel();
831
- 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)) {
832
945
  target.value = storedModel;
833
946
  } else if (normalizedProvider !== 'openai-image' && Array.from(target.options).some((option) => option.value === 'auto')) {
834
947
  target.value = 'auto';
@@ -841,6 +954,414 @@ async function populateNewConversationModelSelect(providerType = 'github') {
841
954
  return true;
842
955
  }
843
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
+
844
1365
  function resolveNewConversationDefaultCwd() {
845
1366
  return normalizeKnownCwdPath(defaultSessionWorkspaceRootPath || workspaceRootPath || '');
846
1367
  }
@@ -861,6 +1382,12 @@ function syncNewConversationCwdControls() {
861
1382
  if (!select) return;
862
1383
  const isCustom = select.value === NEW_CHAT_CUSTOM_CWD_VALUE;
863
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
+ }
864
1391
  if (!status) return;
865
1392
  if (isCustom) {
866
1393
  const manualPath = normalizeKnownCwdPath(manual?.value || '');
@@ -949,18 +1476,24 @@ function populateNewConversationCwdSelect() {
949
1476
  }
950
1477
 
951
1478
  async function openNewConversationModelModal() {
952
- const [catalog, settings, claudeSettings, cursorSettings, grokSettings] = await Promise.all([
1479
+ const [catalog, settings, claudeSettings, cursorSettings, grokSettings, claudeCloudSettings] = await Promise.all([
953
1480
  loadModelCatalog(),
954
1481
  loadOpenAISettings(),
955
1482
  loadClaudeSettings(),
956
1483
  loadCursorSettings(),
957
1484
  loadGrokSettings(),
1485
+ // null on a relay without Claude Cloud: the option is simply not offered.
1486
+ loadClaudeCloudSettings(),
958
1487
  ]);
959
1488
  newConversationCatalogCache = catalog || null;
960
1489
  newConversationOpenAISettingsCache = settings || null;
961
1490
  newConversationClaudeSettingsCache = claudeSettings || null;
962
1491
  newConversationCursorSettingsCache = cursorSettings || null;
963
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();
964
1497
  const providerSelect = document.getElementById('new-conversation-provider-select');
965
1498
  if (providerSelect) {
966
1499
  const options = [{ value: 'github', label: 'Copilot' }];
@@ -971,6 +1504,9 @@ async function openNewConversationModelModal() {
971
1504
  if (claudeSettings?.enabled === true) {
972
1505
  options.push({ value: 'claude', label: 'Claude SDK' });
973
1506
  }
1507
+ if (claudeCloudSettings?.enabled === true) {
1508
+ options.push({ value: CLAUDE_CLOUD_PROVIDER, label: 'Claude Cloud' });
1509
+ }
974
1510
  if (cursorSettings?.enabled === true) {
975
1511
  options.push({ value: 'cursor', label: 'Cursor SDK' });
976
1512
  }
@@ -1063,8 +1599,10 @@ async function openBootstrappedConversation({
1063
1599
  hideNewConversationModelModal();
1064
1600
  // The server echoes what it actually bound and stored; the composer picks
1065
1601
  // those up from the conversation row when it opens, so only the shared
1066
- // "last used" storage is updated here.
1067
- 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);
1068
1606
  await refreshConversations();
1069
1607
  await openConversation(conversationId);
1070
1608
  if (selectedProvider === 'openai-image') {
@@ -1077,12 +1615,26 @@ async function openBootstrappedConversation({
1077
1615
 
1078
1616
  async function createNewConversation(selectedModel, selectedReasoningEffort = '', selectedCwd = '') {
1079
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
+ }
1080
1635
  setNewConversationInFlight(true);
1081
1636
  const confirmButton = document.getElementById('new-conversation-model-confirm');
1082
1637
  if (confirmButton) confirmButton.disabled = true;
1083
- const selectedProvider = normalizeNewConversationProviderType(
1084
- String(document.getElementById('new-conversation-provider-select')?.value || '').trim(),
1085
- );
1086
1638
  const selectedSize = String(document.getElementById('new-conversation-size-select')?.value || '').trim().toLowerCase();
1087
1639
  if (selectedProvider === 'openai-image' && selectedSize) {
1088
1640
  localStorage.setItem(OPENAI_IMAGE_SIZE_STORAGE_KEY, selectedSize);
@@ -1091,17 +1643,26 @@ async function createNewConversation(selectedModel, selectedReasoningEffort = ''
1091
1643
  const result = await bootstrapConversationSession({
1092
1644
  model: selectedModel || undefined,
1093
1645
  providerType: bootstrapProviderType(selectedProvider),
1094
- reasoningEffort: String(selectedReasoningEffort || '').trim().toLowerCase() || undefined,
1646
+ reasoningEffort: isCloud
1647
+ ? undefined
1648
+ : (String(selectedReasoningEffort || '').trim().toLowerCase() || undefined),
1095
1649
  // Bootstrap writes the conversation's preferences, so the current mode has
1096
1650
  // to travel with it or every new chat would come back as the default one.
1097
1651
  // The composer's selector wins over storage: storage holds the last
1098
1652
  // explicit choice, which is not the mode of the conversation on screen.
1099
- relayMode: String(
1100
- document.getElementById('mode-select')?.value
1101
- || localStorage.getItem(MODE_STORAGE_KEY)
1102
- || '',
1103
- ).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),
1104
1663
  workspaceRootPath: selectedCwd || undefined,
1664
+ // Claude Cloud only: the repository (and branch) the session clones.
1665
+ cloudSource,
1105
1666
  title: 'New Conversation',
1106
1667
  });
1107
1668
  const nextConversationId = String(result?.conversationId || '').trim();
@@ -1148,6 +1709,15 @@ async function createNewConversation(selectedModel, selectedReasoningEffort = ''
1148
1709
  // conversation exists and the sidebar refresh will show it.
1149
1710
  }
1150
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
+ }
1151
1721
  showTransientRelayNotice(recovered
1152
1722
  // The chat is usable, but its worker did not start, so say what broke.
1153
1723
  ? `The chat was created, but its session could not start: ${error?.message || 'unknown error'}.`
@@ -1290,6 +1860,106 @@ export function initConversationFilter() {
1290
1860
  syncClearButton();
1291
1861
  }
1292
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
+
1293
1963
  export async function deleteConv(e, id) {
1294
1964
  e.stopPropagation();
1295
1965
  if (!confirm('Delete this conversation?')) return;