@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.
- package/CHANGELOG.md +172 -0
- package/README.md +176 -22
- package/package.json +1 -1
- package/server/README.md +174 -7
- package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +340 -0
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +1325 -0
- package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +155 -0
- package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +18 -2
- package/server/claude-worker/claude-session-process.mjs +33 -0
- package/server/claude-worker/claude-session-worker.mjs +6 -0
- package/server/db-schema.mjs +17 -0
- package/server/migrations/0006-workspace-root-settings.mjs +67 -0
- package/server/migrations/0007-claude-cloud.mjs +82 -0
- package/server/public/app/agent-sessions-settings-ui.js +197 -0
- package/server/public/app/agent-sessions-settings.mjs +134 -0
- package/server/public/app/api-client.js +143 -4
- package/server/public/app/attachments-view.js +20 -1
- package/server/public/app/attribution-control.mjs +46 -0
- package/server/public/app/bootstrap.js +344 -31
- package/server/public/app/claude-cloud-conversation-ui.js +110 -0
- package/server/public/app/claude-cloud-settings-ui.js +374 -0
- package/server/public/app/claude-cloud-ui.mjs +611 -0
- package/server/public/app/composer-control-state.mjs +11 -0
- package/server/public/app/context-menu.mjs +140 -0
- package/server/public/app/context-tier-options.mjs +4 -0
- package/server/public/app/conversation-origin-ui.js +27 -0
- package/server/public/app/conversation-provider-indicator.mjs +22 -4
- package/server/public/app/conversation-view.js +83 -4
- package/server/public/app/cwd-picker.js +12 -0
- package/server/public/app/journal-view.js +698 -28
- package/server/public/app/math-dollars.mjs +89 -0
- package/server/public/app/model-selector-options.mjs +1 -1
- package/server/public/app/plan-usage-view.mjs +36 -8
- package/server/public/app/relay-error-ctas.mjs +16 -2
- package/server/public/app/remote-relay-origin-view.mjs +120 -4
- package/server/public/app/router.js +49 -12
- package/server/public/app/settings-modal.js +33 -2
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +51 -0
- package/server/public/index.html +381 -7
- package/server/relay-tools.md +13 -1
- package/server/repositories/remote-relay-repository.mjs +42 -1
- package/server/repositories/session-repository.mjs +47 -1
- package/server/routes/claude-cloud-routes.mjs +140 -0
- package/server/routes/git-routes.mjs +37 -0
- package/server/routes/messages-routes.mjs +120 -15
- package/server/routes/remote-relay-routes.mjs +15 -1
- package/server/routes/sessions-routes.mjs +502 -72
- package/server/server-runtime.mjs +258 -25
- package/server/services/claude-account-usage-service.mjs +176 -0
- package/server/services/claude-cloud-repo-service.mjs +408 -0
- package/server/services/claude-cloud-session-service.mjs +326 -0
- package/server/services/claude-cloud-settings-service.mjs +269 -0
- package/server/services/git-remote-service.mjs +115 -0
- package/server/services/plan-usage-claude-cloud.mjs +211 -0
- package/server/services/plan-usage-claude.mjs +202 -40
- package/server/services/plan-usage-contract.mjs +39 -3
- package/server/services/plan-usage-service.mjs +82 -2
- package/server/services/provider-reasoning-effort.mjs +3 -0
- package/server/services/remote-relay-caller-context.mjs +49 -10
- package/server/services/remote-relay-client.mjs +9 -0
- package/server/services/remote-relay-dispatcher.mjs +383 -35
- package/server/services/remote-relay-inbound.mjs +49 -2
- package/server/services/remote-relay-loopback.mjs +245 -0
- package/server/services/remote-relay-registry-service.mjs +52 -0
- package/server/services/session-worker-launch-service.mjs +100 -15
- package/server/services/session-worker-process-service.mjs +348 -129
- package/server/services/session-worker-stop-service.mjs +24 -13
- package/server/services/session-worker-supervisor-service.mjs +56 -1
- package/server/services/usage-limit-pause-service.mjs +3 -1
- package/server/services/worker-launch-note-service.mjs +151 -0
- package/shared/claude-attribution.mjs +117 -0
- package/shared/claude-cloud/api-client.mjs +809 -0
- package/shared/claude-cloud/base-url.mjs +47 -0
- package/shared/claude-cloud/credentials.mjs +221 -0
- package/shared/claude-cloud/repo-url.mjs +111 -0
- package/shared/provider-routing.mjs +4 -2
- package/shared/remote-relay-contract.mjs +153 -22
- package/shared/remote-relay-tool-core.mjs +11 -8
- 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({
|
|
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
|
-
|
|
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 &&
|
|
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
|
-
|
|
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>` : ''}${
|
|
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
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
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;
|