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