@oar-sh/oar 0.9.9 → 0.9.11
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 +561 -1
- package/README.md +162 -909
- package/bin/bat/oar.bat +46 -46
- package/package.json +14 -14
- package/server/README.md +369 -13
- package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
- package/server/claude-worker/claude-session-process.mjs +121 -9
- package/server/claude-worker/claude-session-worker.mjs +9 -1
- package/server/claude-worker/claude-turn-publisher.mjs +3 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
- package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +79 -12
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +6 -0
- package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
- package/server/db-schema.mjs +95 -2
- package/server/grok-worker/acp-client.mjs +2 -1
- package/server/grok-worker/acp-host-services.mjs +4 -3
- package/server/migrations/0009-subagent-timeline.mjs +135 -0
- package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
- package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
- package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
- package/server/migrations/0013-previews.mjs +130 -0
- package/server/migrations/0014-conversation-groups.mjs +89 -0
- package/server/migrations/0015-conversation-group-position.mjs +76 -0
- package/server/migrations/0016-share-access-log.mjs +110 -0
- package/server/public/app/about-ui.js +73 -0
- package/server/public/app/about-view.mjs +234 -0
- package/server/public/app/action-confirmations.js +31 -14
- package/server/public/app/agent-exchange-fold-view.mjs +187 -0
- package/server/public/app/api-client.js +312 -16
- package/server/public/app/archive-confirm.mjs +28 -0
- package/server/public/app/ask-user-view.js +17 -12
- package/server/public/app/attachments-view.js +516 -6
- package/server/public/app/background-tasks-view.mjs +18 -26
- package/server/public/app/bootstrap.js +336 -59
- package/server/public/app/chat-actions-menu-state.mjs +126 -0
- package/server/public/app/claude-cloud-conversation-ui.js +10 -22
- package/server/public/app/composer-control-state.mjs +16 -0
- package/server/public/app/composer-keyboard.mjs +54 -0
- package/server/public/app/context-menu.mjs +90 -6
- package/server/public/app/conversation-group-drag.mjs +191 -0
- package/server/public/app/conversation-group-order.mjs +265 -0
- package/server/public/app/conversation-group-state.mjs +100 -0
- package/server/public/app/conversation-list-drag.mjs +449 -0
- package/server/public/app/conversation-turn-state.mjs +81 -0
- package/server/public/app/conversation-view.js +436 -117
- package/server/public/app/cwd-picker.js +1 -1
- package/server/public/app/explorer-edit-state.mjs +68 -0
- package/server/public/app/hidden-messages-view.mjs +312 -0
- package/server/public/app/host-suspend-ui.js +13 -4
- package/server/public/app/journal-view.js +930 -45
- package/server/public/app/no-conversation-state.mjs +58 -0
- package/server/public/app/preview-cards.mjs +117 -31
- package/server/public/app/preview-command.mjs +10 -1
- package/server/public/app/provider-readiness.mjs +120 -0
- package/server/public/app/relay-board-view.js +19 -3
- package/server/public/app/relay-event-subscriptions-ui.js +184 -0
- package/server/public/app/relay-event-view.mjs +234 -0
- package/server/public/app/relay-restart-error.mjs +16 -0
- package/server/public/app/settings-modal.js +93 -9
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/share-format.mjs +49 -0
- package/server/public/app/share-preview-marks.mjs +167 -0
- package/server/public/app/share-state.js +109 -0
- package/server/public/app/shared-conversations-settings.js +480 -0
- package/server/public/app/socket-handlers.js +137 -40
- package/server/public/app/store.js +115 -32
- package/server/public/app/subagent-row.mjs +138 -0
- package/server/public/app/subagent-timeline-view.mjs +186 -0
- package/server/public/app/subagent-viewer-watch.mjs +50 -0
- package/server/public/app/subagent-viewer.mjs +558 -0
- package/server/public/app/task-row-format.mjs +29 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +464 -0
- package/server/public/app/transcript-jump-bottom.mjs +210 -0
- package/server/public/app/transcript-reload-freshness.mjs +25 -0
- package/server/public/app/transcript-viewport-anchor.mjs +140 -0
- package/server/public/app/update-ui.js +1 -1
- package/server/public/app/viewer-history.mjs +5 -3
- package/server/public/index.html +774 -12
- package/server/relay-tools.md +2 -1
- package/server/relay.mjs +2 -1
- package/server/repositories/conversation-group-repository.mjs +444 -0
- package/server/repositories/message-repository.mjs +30 -3
- package/server/repositories/question-repository.mjs +9 -3
- package/server/repositories/remote-relay-repository.mjs +6 -2
- package/server/repositories/session-repository.mjs +12 -37
- package/server/repositories/share-repository.mjs +322 -0
- package/server/repositories/share-visibility-statements.mjs +10 -0
- package/server/repositories/subagent-timeline-statements.mjs +170 -0
- package/server/routes/about-routes.mjs +15 -0
- package/server/routes/ask-user-routes.mjs +3 -0
- package/server/routes/cache-routes.mjs +4 -0
- package/server/routes/conversation-group-routes.mjs +171 -0
- package/server/routes/messages-routes.mjs +698 -114
- package/server/routes/preview-routes.mjs +9 -1
- package/server/routes/relay-event-routes.mjs +157 -0
- package/server/routes/remote-relay-routes.mjs +15 -0
- package/server/routes/sessions-routes.mjs +843 -193
- package/server/server-runtime.mjs +559 -160
- package/server/services/about-service.mjs +461 -0
- package/server/services/background-task-store.mjs +128 -0
- package/server/services/background-work-view.mjs +228 -0
- package/server/services/cache-rebuild-service.mjs +30 -0
- package/server/services/delete-archive-service.mjs +4 -1
- package/server/services/detached-worker-watch-service.mjs +150 -0
- package/server/services/explorer-entry-name.mjs +26 -0
- package/server/services/explorer-mutations.mjs +122 -0
- package/server/services/hidden-message-list.mjs +21 -0
- package/server/services/host-suspend-command.mjs +2 -1
- package/server/services/message-pin-list.mjs +1 -1
- package/server/services/preview-health-probe.mjs +8 -1
- package/server/services/preview-proxy-server.mjs +49 -27
- package/server/services/preview-registry-service.mjs +676 -26
- package/server/services/preview-static-handler.mjs +40 -7
- package/server/services/preview-store.mjs +274 -0
- package/server/services/push-dispatch-service.mjs +18 -0
- package/server/services/rate-limit-service.mjs +12 -1
- package/server/services/relay-auth.mjs +183 -0
- package/server/services/relay-cli-launcher-service.mjs +3 -1
- package/server/services/relay-event-link-service.mjs +289 -0
- package/server/services/relay-event-service.mjs +1952 -0
- package/server/services/relay-shutdown-gate.mjs +31 -0
- package/server/services/remote-relay-caller-context.mjs +52 -7
- package/server/services/remote-relay-dispatcher.mjs +551 -7
- package/server/services/remote-relay-registry-service.mjs +11 -1
- package/server/services/request-boundary.mjs +183 -0
- package/server/services/session-activity-tracker.mjs +695 -0
- package/server/services/session-history-refresh-service.mjs +7 -0
- package/server/services/session-worker-launch-service.mjs +190 -6
- package/server/services/session-worker-process-service.mjs +10 -6
- package/server/services/share-access-log-service.mjs +123 -0
- package/server/services/share-path-redaction.mjs +424 -0
- package/server/services/stale-attempt-log-limiter.mjs +96 -0
- package/server/services/stale-recovery-clock-guard.mjs +57 -0
- package/server/services/stale-subagent-run-sweep.mjs +109 -0
- package/server/services/status-event-service.mjs +7 -3
- package/server/services/subagent-record-retention.mjs +164 -0
- package/server/services/subagent-timeline-service.mjs +328 -0
- package/server/services/subagent-viewer-rooms.mjs +119 -0
- package/server/services/tunnel-worker-path-guard.mjs +24 -41
- package/server/services/upgrade-socket-guard.mjs +16 -0
- package/server/services/windows-autostart-service.mjs +6 -1
- package/server/services/windows-boot-autostart-service.mjs +207 -23
- package/shared/preview-tool-core.mjs +15 -5
- package/shared/relay-events.mjs +371 -0
- package/shared/remote-relay-contract.mjs +160 -6
- package/shared/share-path-privacy.mjs +30 -0
- package/shared/subagent-record-retention.mjs +31 -0
- package/shared/subagent-timeline.mjs +79 -0
- package/shared/windows-system-tools.mjs +106 -0
- package/shared/worker-runtime/background-task-digest.mjs +30 -0
- package/shared/worker-runtime/heartbeat.mjs +24 -0
- package/shared/worker-runtime/kept-posts.mjs +83 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
|
@@ -23,13 +23,43 @@ import {
|
|
|
23
23
|
loadConversationLoadedMessageCount,
|
|
24
24
|
saveConversationScrollTop,
|
|
25
25
|
IS_SHARED_VIEW,
|
|
26
|
+
openSummaryModal,
|
|
27
|
+
closeSummaryModal,
|
|
28
|
+
setSummaryModalLeaveHandler,
|
|
29
|
+
conversationGroups,
|
|
30
|
+
mergeConversationGroups,
|
|
31
|
+
readConversationGroupFoldChoices,
|
|
32
|
+
pruneConversationGroups,
|
|
33
|
+
hasSessionWorkerStatus,
|
|
34
|
+
setConversationGroupFoldChoice,
|
|
26
35
|
} from './store.js';
|
|
36
|
+
import { archiveConfirmationFor } from './archive-confirm.mjs';
|
|
37
|
+
import { arrangeConversationList, conversationGroupPosition } from './conversation-group-order.mjs';
|
|
38
|
+
import { describeDragItems, dropAnnouncement, groupMoveStep } from './conversation-group-drag.mjs';
|
|
39
|
+
import { bindConversationListDrag } from './conversation-list-drag.mjs';
|
|
40
|
+
import { applyFetchedTurnStateToConversation } from './conversation-turn-state.mjs';
|
|
41
|
+
import {
|
|
42
|
+
groupIsActive,
|
|
43
|
+
groupSummaryText,
|
|
44
|
+
memberStateClass,
|
|
45
|
+
resolveGroupFold,
|
|
46
|
+
summarizeGroupMembers,
|
|
47
|
+
toggledGroupFoldChoice,
|
|
48
|
+
} from './conversation-group-state.mjs';
|
|
27
49
|
import {
|
|
28
50
|
loadConversations as loadConversationsApi,
|
|
29
51
|
loadConversation,
|
|
30
52
|
deleteConversation as deleteConversationApi,
|
|
31
53
|
archiveConversation as archiveConversationApi,
|
|
32
54
|
unarchiveConversation as unarchiveConversationApi,
|
|
55
|
+
createConversationGroup as createConversationGroupApi,
|
|
56
|
+
renameConversationGroup as renameConversationGroupApi,
|
|
57
|
+
moveConversationToGroup as moveConversationToGroupApi,
|
|
58
|
+
placeConversationInGroup as placeConversationInGroupApi,
|
|
59
|
+
removeConversationFromGroup as removeConversationFromGroupApi,
|
|
60
|
+
dissolveConversationGroup as dissolveConversationGroupApi,
|
|
61
|
+
archiveConversationGroup as archiveConversationGroupApi,
|
|
62
|
+
stopConversationGroup as stopConversationGroupApi,
|
|
33
63
|
bootstrapConversationSession,
|
|
34
64
|
scheduleContextUsageRefresh,
|
|
35
65
|
loadModelCatalog,
|
|
@@ -42,10 +72,15 @@ import {
|
|
|
42
72
|
loadGrokSettings,
|
|
43
73
|
loadOpenAISettings,
|
|
44
74
|
} from './api-client.js';
|
|
45
|
-
import {
|
|
75
|
+
import { isSetupRequired, updateProviderReadinessInputs } from './provider-readiness.mjs';
|
|
76
|
+
import { SETUP_TEXT } from './no-conversation-state.mjs';
|
|
77
|
+
import { openSettingsModal } from './settings-modal.js';
|
|
78
|
+
import { captureReadingBlock, restoreReadingBlock } from './transcript-viewport-anchor.mjs';
|
|
79
|
+
import { renderMessages, renderNoConversationState, restoreInFlightThinking, focusConversationMessageById, noteTranscriptJump, flushConversationDraft, hydrateConversationDraft, beginConversationDraftSwitch, openStopTurnConfirmationForConversation, conversationHasActiveTurn } from './conversation-view.js';
|
|
46
80
|
import { MENU_SEPARATOR, bindLongPress, closeContextMenu, openContextMenu } from './context-menu.mjs';
|
|
47
81
|
import { setBackgroundTasksConversation, setConversationBackgroundTasks } from './background-tasks-view.mjs';
|
|
48
82
|
import { setConversationPins, setPinsConversation } from './pinned-messages-view.mjs';
|
|
83
|
+
import { setConversationHiddenMessages, setHiddenMessagesConversation } from './hidden-messages-view.mjs';
|
|
49
84
|
import { setUsageLimitConversation } from './usage-limit-ui.js';
|
|
50
85
|
import { mergeConversationPreviews } from './preview-cards.mjs';
|
|
51
86
|
import { loadRelayQuestions, getPendingQuestionCountsByConversation } from './ask-user-view.js';
|
|
@@ -85,8 +120,10 @@ import {
|
|
|
85
120
|
validateCloudSourceInputs,
|
|
86
121
|
} from './claude-cloud-ui.mjs';
|
|
87
122
|
import { renderConversationOriginMarkerHtml } from './remote-relay-origin-view.mjs';
|
|
123
|
+
import { relayEventWatchedMarkerHtml } from './relay-event-subscriptions-ui.js';
|
|
88
124
|
import {
|
|
89
125
|
normalizeConversationFilter,
|
|
126
|
+
conversationMatchesFilter,
|
|
90
127
|
filterConversations,
|
|
91
128
|
drainRemainingPages,
|
|
92
129
|
describeFilterMatchCount,
|
|
@@ -111,6 +148,15 @@ const CONV_FILTER_DEBOUNCE_MS = 220;
|
|
|
111
148
|
let processingDotFrame = 0;
|
|
112
149
|
let processingDotTimer = null;
|
|
113
150
|
let lastConvListHtml = '';
|
|
151
|
+
// The list as last rendered, as drag items (conversation-group-drag.mjs).
|
|
152
|
+
let lastConvListDragItems = [];
|
|
153
|
+
// While a row is lifted or dragged the list is not re-rendered: what arrives
|
|
154
|
+
// meanwhile is applied to the records and drawn once the row is let go.
|
|
155
|
+
let convListRenderHeld = false;
|
|
156
|
+
let convListRenderDeferred = false;
|
|
157
|
+
// Groups held open without a stored choice: unfolded by hovering a drag over
|
|
158
|
+
// them, or the target of a drop. Until the page reloads or the user folds them.
|
|
159
|
+
const convListTransientOpenGroups = new Set();
|
|
114
160
|
let conversationListFilterText = '';
|
|
115
161
|
let conversationFilterDrainActive = false;
|
|
116
162
|
let conversationFilterGeneration = 0;
|
|
@@ -157,6 +203,8 @@ let newConversationCloudBranchTimer = null;
|
|
|
157
203
|
let newConversationCloudBranchAutoFill = '';
|
|
158
204
|
const newConversationCloudSuggestActive = { repo: -1, branch: -1 };
|
|
159
205
|
const NEW_CHAT_CLOUD_SUGGEST_BLUR_MS = 150;
|
|
206
|
+
// Rows the fetches since the last page-one refresh brought (refreshLoadedConversations).
|
|
207
|
+
let conversationRowsFetchedSinceRefresh = 0;
|
|
160
208
|
let conversationListBoundaryCheckFrame = 0;
|
|
161
209
|
let conversationListAutoLoadBlockedUntil = 0;
|
|
162
210
|
let conversationListPaginationState = {
|
|
@@ -190,6 +238,9 @@ function upsertConversationRecord(record) {
|
|
|
190
238
|
if (!id) return false;
|
|
191
239
|
const hadExistingRecord = !!conversations[id];
|
|
192
240
|
conversations[id] = mergeConversationRecord(conversations[id], record);
|
|
241
|
+
// The relay's view of the session (state and live task set) is the truth
|
|
242
|
+
// whenever a payload arrives; the events move it forward in between.
|
|
243
|
+
applyFetchedTurnStateToConversation(conversations[id], record);
|
|
193
244
|
return !hadExistingRecord;
|
|
194
245
|
}
|
|
195
246
|
|
|
@@ -230,6 +281,7 @@ const conversationListLoader = createInfiniteLoader({
|
|
|
230
281
|
archived: conversationListArchivedView ? 'only' : '',
|
|
231
282
|
});
|
|
232
283
|
if (!response) throw new Error('Could not load conversations');
|
|
284
|
+
mergeConversationGroups(response.groups);
|
|
233
285
|
return {
|
|
234
286
|
items: response.conversations || [],
|
|
235
287
|
hasMore: !!response.pageInfo?.hasMore,
|
|
@@ -238,6 +290,7 @@ const conversationListLoader = createInfiniteLoader({
|
|
|
238
290
|
},
|
|
239
291
|
applyPage: async (page) => {
|
|
240
292
|
applyConversationPage(page.items);
|
|
293
|
+
conversationRowsFetchedSinceRefresh += Array.isArray(page.items) ? page.items.length : 0;
|
|
241
294
|
conversationListPaginationState.hasLoadedOlderPages = true;
|
|
242
295
|
},
|
|
243
296
|
onError: (error) => {
|
|
@@ -269,6 +322,13 @@ function resetConversationListPageState(pageInfo = null, { preserveProgress = fa
|
|
|
269
322
|
scheduleConversationListBoundaryCheck();
|
|
270
323
|
}
|
|
271
324
|
|
|
325
|
+
const LIVE_WORKER_STATUSES = new Set(['starting', 'ready', 'processing']);
|
|
326
|
+
|
|
327
|
+
/** A worker entry from the status poll that stands for a live process. */
|
|
328
|
+
function isWorkerStateLive(workerState) {
|
|
329
|
+
return !!workerState && LIVE_WORKER_STATUSES.has(String(workerState?.status || '').trim().toLowerCase());
|
|
330
|
+
}
|
|
331
|
+
|
|
272
332
|
function isConversationProcessing(conversation, workerState) {
|
|
273
333
|
const workerStatus = String(workerState?.status || '').trim().toLowerCase();
|
|
274
334
|
if (workerStatus === 'processing') return true;
|
|
@@ -332,11 +392,18 @@ export async function refreshConversations(options = {}) {
|
|
|
332
392
|
if (!knownConversationIds.has(id)) delete conversations[id];
|
|
333
393
|
}
|
|
334
394
|
}
|
|
395
|
+
mergeConversationGroups(r.groups);
|
|
335
396
|
applyConversationPage(r.conversations || []);
|
|
397
|
+
conversationRowsFetchedSinceRefresh = Array.isArray(r.conversations) ? r.conversations.length : 0;
|
|
398
|
+
// A group no loaded row belongs to any more (dissolved, or its rows left)
|
|
399
|
+
// is forgotten: the picker offers the groups that exist.
|
|
400
|
+
pruneConversationGroups(Object.values(conversations).map((conversation) => conversation?.groupId));
|
|
336
401
|
resetConversationListPageState(r.pageInfo || null, {
|
|
337
402
|
preserveProgress: preservePagination,
|
|
338
403
|
});
|
|
339
404
|
renderConvList();
|
|
405
|
+
// With nothing open the transcript's screen depends on whether chats exist.
|
|
406
|
+
if (!currentConvId) renderNoConversationState();
|
|
340
407
|
updateCompactButton();
|
|
341
408
|
// A reset bumps the loader version, which aborts any in-flight filter drain;
|
|
342
409
|
// restart it so an active filter keeps covering the unloaded pages.
|
|
@@ -345,18 +412,228 @@ export async function refreshConversations(options = {}) {
|
|
|
345
412
|
}
|
|
346
413
|
}
|
|
347
414
|
|
|
415
|
+
/**
|
|
416
|
+
* Fetches every loaded page again: page one, then as many older pages as
|
|
417
|
+
* the list had, so a change on a row far down the list (a group action on
|
|
418
|
+
* an old chat, a reconnect) shows without a reload. One refresh runs at a
|
|
419
|
+
* time: a call while one runs marks it to run once more afterwards, so the
|
|
420
|
+
* last state always lands and two refreshes never interleave their pages.
|
|
421
|
+
*/
|
|
422
|
+
let loadedConversationsRefreshRunning = null;
|
|
423
|
+
let loadedConversationsRefreshAgain = false;
|
|
424
|
+
export function refreshLoadedConversations() {
|
|
425
|
+
if (loadedConversationsRefreshRunning) {
|
|
426
|
+
loadedConversationsRefreshAgain = true;
|
|
427
|
+
return loadedConversationsRefreshRunning;
|
|
428
|
+
}
|
|
429
|
+
loadedConversationsRefreshRunning = (async () => {
|
|
430
|
+
try {
|
|
431
|
+
do {
|
|
432
|
+
loadedConversationsRefreshAgain = false;
|
|
433
|
+
await refreshLoadedConversationsOnce();
|
|
434
|
+
} while (loadedConversationsRefreshAgain);
|
|
435
|
+
} finally {
|
|
436
|
+
loadedConversationsRefreshRunning = null;
|
|
437
|
+
}
|
|
438
|
+
})();
|
|
439
|
+
return loadedConversationsRefreshRunning;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
async function refreshLoadedConversationsOnce() {
|
|
443
|
+
const loadedBefore = Object.values(conversations)
|
|
444
|
+
.filter((conversation) => (conversation?.archived === true) === conversationListArchivedView).length;
|
|
445
|
+
// Rows of pages not fetched again stay in the map as they were, so the
|
|
446
|
+
// pages are counted by what the fetches bring, not by what the map holds.
|
|
447
|
+
await refreshConversations({ preservePagination: false });
|
|
448
|
+
let guard = 0;
|
|
449
|
+
while (conversationRowsFetchedSinceRefresh < loadedBefore && conversationListLoader.getState().hasMore && guard < 50) {
|
|
450
|
+
guard += 1;
|
|
451
|
+
const before = conversationRowsFetchedSinceRefresh;
|
|
452
|
+
await conversationListLoader.loadMore();
|
|
453
|
+
if (conversationRowsFetchedSinceRefresh === before) break;
|
|
454
|
+
}
|
|
455
|
+
pruneConversationGroups(Object.values(conversations).map((conversation) => conversation?.groupId));
|
|
456
|
+
renderConvList();
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Several events within a moment (a group action touches its members one by
|
|
460
|
+
// one) give one refresh.
|
|
461
|
+
const LOADED_REFRESH_COALESCE_MS = 150;
|
|
462
|
+
let loadedConversationsRefreshTimer = null;
|
|
463
|
+
export function scheduleLoadedConversationsRefresh() {
|
|
464
|
+
if (loadedConversationsRefreshTimer) return;
|
|
465
|
+
loadedConversationsRefreshTimer = setTimeout(() => {
|
|
466
|
+
loadedConversationsRefreshTimer = null;
|
|
467
|
+
refreshLoadedConversations().catch(() => {});
|
|
468
|
+
}, LOADED_REFRESH_COALESCE_MS);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* A `conversation_group_changed` event. What it carries is applied in
|
|
473
|
+
* place: the group's summary (or its end) and the named conversations'
|
|
474
|
+
* membership. A fetch is scheduled only when the event names a conversation
|
|
475
|
+
* this page has not loaded (it may now belong under a loaded head), or
|
|
476
|
+
* carries no detail (an older relay).
|
|
477
|
+
*/
|
|
478
|
+
export function applyConversationGroupEvent(payload) {
|
|
479
|
+
const groupId = String(payload?.groupId || '').trim();
|
|
480
|
+
const memberships = payload?.memberships && typeof payload.memberships === 'object' ? payload.memberships : null;
|
|
481
|
+
if (!groupId || !memberships) {
|
|
482
|
+
scheduleLoadedConversationsRefresh();
|
|
483
|
+
return 'fetch';
|
|
484
|
+
}
|
|
485
|
+
if (payload.group && typeof payload.group === 'object') {
|
|
486
|
+
mergeConversationGroups({ [groupId]: payload.group });
|
|
487
|
+
} else {
|
|
488
|
+
delete conversationGroups[groupId];
|
|
489
|
+
}
|
|
490
|
+
let needsFetch = false;
|
|
491
|
+
for (const [conversationId, membership] of Object.entries(memberships)) {
|
|
492
|
+
const record = conversations[conversationId];
|
|
493
|
+
if (!record) {
|
|
494
|
+
needsFetch = true;
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
record.groupId = membership?.groupId ? String(membership.groupId) : null;
|
|
498
|
+
record.groupRole = record.groupId ? (membership?.role === 'head' ? 'head' : 'member') : null;
|
|
499
|
+
if (!record.groupId) record.groupPosition = null;
|
|
500
|
+
}
|
|
501
|
+
applyConversationGroupPositions(payload.positions);
|
|
502
|
+
pruneConversationGroups(Object.values(conversations).map((conversation) => conversation?.groupId));
|
|
503
|
+
renderConvList();
|
|
504
|
+
if (pendingConvRowFlash && Date.now() < pendingConvRowFlash.until && Object.hasOwn(memberships, pendingConvRowFlash.id)) {
|
|
505
|
+
flashConversationRow(pendingConvRowFlash.id);
|
|
506
|
+
pendingConvRowFlash = null;
|
|
507
|
+
}
|
|
508
|
+
if (needsFetch) scheduleLoadedConversationsRefresh();
|
|
509
|
+
return needsFetch ? 'fetch' : 'in-place';
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* The hand order of a group's rows (`{ [conversationId]: number | null }`),
|
|
514
|
+
* as the relay announces it. An older relay sends none.
|
|
515
|
+
*/
|
|
516
|
+
function applyConversationGroupPositions(positions) {
|
|
517
|
+
if (!positions || typeof positions !== 'object') return;
|
|
518
|
+
for (const [conversationId, position] of Object.entries(positions)) {
|
|
519
|
+
const record = conversations[conversationId];
|
|
520
|
+
if (!record) continue;
|
|
521
|
+
record.groupPosition = conversationGroupPosition({ groupPosition: position });
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** A sidebar row's state: its colour (green is 'ready') and whether its dots run. */
|
|
526
|
+
function conversationRowState(conversation, pendingByConversation = getPendingQuestionCountsByConversation()) {
|
|
527
|
+
const pendingCount = Number(pendingByConversation[conversation?.id] || 0);
|
|
528
|
+
const sdkSessionId = String(conversation?.sdkSessionId || '').trim();
|
|
529
|
+
const workerState = sdkSessionId ? getSessionWorkerState(sdkSessionId) : null;
|
|
530
|
+
const visualState = resolveConversationUiState({
|
|
531
|
+
conversation,
|
|
532
|
+
workerState,
|
|
533
|
+
hasPendingQuestion: pendingCount > 0,
|
|
534
|
+
});
|
|
535
|
+
const processing = isConversationProcessing(conversation, workerState)
|
|
536
|
+
|| String(conversation?.sessionState || '').trim() === 'running_turn';
|
|
537
|
+
// Background work lives in the session's worker: once the worker status is
|
|
538
|
+
// known, a session without a live worker has none, whatever the last
|
|
539
|
+
// event or fetch said (the worker was killed, or the relay restarted).
|
|
540
|
+
let sessionState = String(conversation?.sessionState || '').trim();
|
|
541
|
+
if (sessionState === 'background_only' && hasSessionWorkerStatus() && !isWorkerStateLive(workerState)) sessionState = 'idle';
|
|
542
|
+
return {
|
|
543
|
+
visualState,
|
|
544
|
+
processing,
|
|
545
|
+
pendingCount,
|
|
546
|
+
// A turn or background work, whether or not a card waits in it: what
|
|
547
|
+
// "Stop all running" acts on, and what keeps an archive from it.
|
|
548
|
+
live: processing || pendingCount > 0 || conversation?.activeTurn === true || sessionState === 'running_turn' || sessionState === 'background_only',
|
|
549
|
+
// The member class the group's head row sums up (conversation-group-state.mjs).
|
|
550
|
+
memberState: memberStateClass({
|
|
551
|
+
pendingCount,
|
|
552
|
+
visualState,
|
|
553
|
+
processing,
|
|
554
|
+
activeTurn: conversation?.activeTurn === true,
|
|
555
|
+
sessionState,
|
|
556
|
+
}),
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** The group's head and its name for the picker and the menus. */
|
|
561
|
+
function groupDisplayName(groupId) {
|
|
562
|
+
const info = conversationGroups[groupId] || null;
|
|
563
|
+
const head = info?.parentConversationId ? conversations[info.parentConversationId] : null;
|
|
564
|
+
return String(info?.name || head?.title || info?.displayName || 'Group').trim() || 'Group';
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** The loaded rows of a group, in the current view and across it. */
|
|
568
|
+
function groupMemberRows(groupId, { inViewOnly = true } = {}) {
|
|
569
|
+
return Object.values(conversations).filter((conversation) => String(conversation?.groupId || '') === groupId
|
|
570
|
+
&& (!inViewOnly || (conversation?.archived === true) === conversationListArchivedView));
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* What a head row shows for its group: the classes of every row in it, the
|
|
575
|
+
* head included (the menu's "Archive group (N)" and "Stop all running (N)"
|
|
576
|
+
* count the same rows), their summary, and the first row a waiting card
|
|
577
|
+
* sits in.
|
|
578
|
+
*/
|
|
579
|
+
function describeGroupForHead(group, viewOf) {
|
|
580
|
+
const rows = group.head ? [group.head, ...group.members] : [...group.members];
|
|
581
|
+
const states = rows.map((row) => viewOf(row).memberState);
|
|
582
|
+
const summary = summarizeGroupMembers(states);
|
|
583
|
+
const firstWaiting = rows.find((row) => viewOf(row).memberState === 'waiting') || null;
|
|
584
|
+
return { summary, firstWaiting, forcedOpen: false };
|
|
585
|
+
}
|
|
586
|
+
|
|
348
587
|
export function renderConvList() {
|
|
588
|
+
if (convListRenderHeld) {
|
|
589
|
+
convListRenderDeferred = true;
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
paintConvList();
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function holdConvListRender(held) {
|
|
596
|
+
convListRenderHeld = held === true;
|
|
597
|
+
if (convListRenderHeld || !convListRenderDeferred) return;
|
|
598
|
+
convListRenderDeferred = false;
|
|
599
|
+
paintConvList();
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function paintConvList() {
|
|
349
603
|
const list = document.getElementById('conv-list');
|
|
350
|
-
const sorted = Object.values(conversations).sort((a, b) => {
|
|
351
|
-
const updatedAtDelta = parseTimestampMs(b.updatedAt) - parseTimestampMs(a.updatedAt);
|
|
352
|
-
if (updatedAtDelta !== 0) return updatedAtDelta;
|
|
353
|
-
return String(b?.id || '').trim().localeCompare(String(a?.id || '').trim());
|
|
354
|
-
});
|
|
355
604
|
const activeFilter = normalizeConversationFilter(conversationListFilterText);
|
|
356
605
|
// The live list and the Archived view are two views of the same records.
|
|
357
|
-
const inView =
|
|
358
|
-
const visible = activeFilter ? filterConversations(inView, activeFilter) : inView;
|
|
606
|
+
const inView = Object.values(conversations).filter((conversation) => (conversation?.archived === true) === conversationListArchivedView);
|
|
359
607
|
const pendingByConversation = getPendingQuestionCountsByConversation();
|
|
608
|
+
const rowViews = new Map();
|
|
609
|
+
const viewOf = (conversation) => {
|
|
610
|
+
const id = String(conversation?.id || '');
|
|
611
|
+
if (!rowViews.has(id)) rowViews.set(id, conversationRowState(conversation, pendingByConversation));
|
|
612
|
+
return rowViews.get(id);
|
|
613
|
+
};
|
|
614
|
+
const foldChoices = readConversationGroupFoldChoices();
|
|
615
|
+
const groupDescriptions = new Map();
|
|
616
|
+
// Groups order with the same rules the list route pages by; a group is
|
|
617
|
+
// folded once nothing in it runs or waits, unless the user chose otherwise.
|
|
618
|
+
const items = arrangeConversationList(inView, conversationGroups, {
|
|
619
|
+
matches: activeFilter ? (row) => conversationMatchesFilter(row, activeFilter) : null,
|
|
620
|
+
groupNameMatches: activeFilter ? (info) => conversationMatchesFilter({ title: info?.displayName || info?.name }, activeFilter) : null,
|
|
621
|
+
isOpen: (groupId, group) => {
|
|
622
|
+
const described = describeGroupForHead(group, viewOf);
|
|
623
|
+
const holdsOpenConversation = !!currentConvId && group.members.some((row) => row.id === currentConvId);
|
|
624
|
+
// Open whatever the choice: the fold button then says so and does nothing.
|
|
625
|
+
described.forcedOpen = holdsOpenConversation || !!activeFilter;
|
|
626
|
+
groupDescriptions.set(groupId, described);
|
|
627
|
+
if (convListTransientOpenGroups.has(groupId)) return true;
|
|
628
|
+
return resolveGroupFold({
|
|
629
|
+
choice: foldChoices[groupId] || null,
|
|
630
|
+
active: groupIsActive(described.summary),
|
|
631
|
+
holdsOpenConversation,
|
|
632
|
+
filterActive: !!activeFilter,
|
|
633
|
+
});
|
|
634
|
+
},
|
|
635
|
+
});
|
|
636
|
+
lastConvListDragItems = describeDragItems(items);
|
|
360
637
|
let hasProcessingConversation = false;
|
|
361
638
|
const footerHtml = (() => {
|
|
362
639
|
if (activeFilter) {
|
|
@@ -375,7 +652,7 @@ export function renderConvList() {
|
|
|
375
652
|
}
|
|
376
653
|
return '';
|
|
377
654
|
})();
|
|
378
|
-
if (
|
|
655
|
+
if (items.length === 0) {
|
|
379
656
|
ensureProcessingDotTimer(false);
|
|
380
657
|
const emptyText = activeFilter && inView.length > 0
|
|
381
658
|
? 'No conversations match your filter'
|
|
@@ -386,19 +663,31 @@ export function renderConvList() {
|
|
|
386
663
|
return;
|
|
387
664
|
}
|
|
388
665
|
const conversationView = (conversation) => {
|
|
389
|
-
const
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
const visualState = resolveConversationUiState({
|
|
393
|
-
conversation,
|
|
394
|
-
workerState,
|
|
395
|
-
hasPendingQuestion: pendingCount > 0,
|
|
396
|
-
});
|
|
397
|
-
const processing = isConversationProcessing(conversation, workerState);
|
|
398
|
-
if (processing) hasProcessingConversation = true;
|
|
399
|
-
return { visualState, processing };
|
|
666
|
+
const view = viewOf(conversation);
|
|
667
|
+
if (view.processing) hasProcessingConversation = true;
|
|
668
|
+
return view;
|
|
400
669
|
};
|
|
401
|
-
|
|
670
|
+
// The head row's pill: the member count and what the members are doing;
|
|
671
|
+
// while folded, its "waiting" part opens the member the card sits in.
|
|
672
|
+
const groupPillHtml = (item) => {
|
|
673
|
+
const described = groupDescriptions.get(item.group.id) || describeGroupForHead(item.group, viewOf);
|
|
674
|
+
const text = groupSummaryText(described.summary);
|
|
675
|
+
if (!text) return '';
|
|
676
|
+
const waitingId = described.firstWaiting ? String(described.firstWaiting.id) : '';
|
|
677
|
+
// Inline like the row's own buttons: the row's click handler must not
|
|
678
|
+
// run (it would open the head and re-render the list first).
|
|
679
|
+
// The full text is the title: a narrow sidebar shortens the pill with an ellipsis.
|
|
680
|
+
const title = waitingId ? `${text} · open the session that is waiting for you` : text;
|
|
681
|
+
return `<span class="conv-group-summary conv-group-summary-${described.summary.strongest}" title="${escHtml(title)}"${waitingId ? ` data-open-waiting="${escHtml(waitingId)}" role="button" tabindex="0" onclick="event.stopPropagation();openConversation('${escHtml(waitingId)}')"` : ''}>${escHtml(text)}</span>`;
|
|
682
|
+
};
|
|
683
|
+
const foldButtonHtml = (item) => {
|
|
684
|
+
const forced = (groupDescriptions.get(item.group.id) || {}).forcedOpen === true || item.group.contextOnly === true || !!activeFilter;
|
|
685
|
+
const label = forced
|
|
686
|
+
? (activeFilter ? 'Open while the filter is on' : 'Open while its chat is open')
|
|
687
|
+
: (item.group.open ? 'Fold group' : 'Unfold group');
|
|
688
|
+
return `<button class="conv-group-fold" type="button" data-group-fold="${escHtml(item.group.id)}" aria-expanded="${item.group.open ? 'true' : 'false'}" aria-label="${label}" title="${label}"${forced ? ' disabled' : ''} onclick="toggleConversationGroupFold(event,'${escHtml(item.group.id)}')">${item.group.open ? '▾' : '▸'}</button>`;
|
|
689
|
+
};
|
|
690
|
+
const rowHtml = (c, item) => {
|
|
402
691
|
const view = conversationView(c);
|
|
403
692
|
const processingDots = view.processing ? PROCESSING_DOT_FRAMES[processingDotFrame] : '';
|
|
404
693
|
const providerIndicatorLabel = conversationProviderIndicatorLabel(c);
|
|
@@ -408,27 +697,88 @@ export function renderConvList() {
|
|
|
408
697
|
: '';
|
|
409
698
|
// "↗ win-test": another relay's agent created this conversation.
|
|
410
699
|
// "via agent · “title”": an agent in that conversation on this relay did.
|
|
411
|
-
|
|
700
|
+
// A member under its creator's head row says that already, so it is left out there.
|
|
701
|
+
const originMarkerHtml = item.kind === 'member' && item.group.head && c.origin?.local === true && c.origin.conversationId === item.group.head.id
|
|
702
|
+
? ''
|
|
703
|
+
: renderConversationOriginMarkerHtml(c, { conversations });
|
|
704
|
+
// "watched": a session another conversation watches by id (relay events).
|
|
705
|
+
const watchedMarkerHtml = relayEventWatchedMarkerHtml(c.id);
|
|
706
|
+
const groupClass = item.kind === 'head' ? ' conv-group-head' : (item.kind === 'member' ? ' conv-group-member' : '');
|
|
707
|
+
const groupAttr = item.kind === 'head' || item.kind === 'member' ? ` data-group-id="${escHtml(item.group.id)}"` : '';
|
|
708
|
+
// A head row shows the name the group was given by hand; until then the
|
|
709
|
+
// conversation's own title (the group's name follows it).
|
|
710
|
+
const handName = item.kind === 'head' ? String(item.group.info?.name || '').trim() : '';
|
|
711
|
+
const rowTitle = handName || c.title;
|
|
712
|
+
const titleAttr = handName && handName !== c.title ? ` title="${escHtml(`${handName} · ${c.title}`)}"` : '';
|
|
412
713
|
return `
|
|
413
|
-
<div class="conv-item worker-ui-${view.visualState}${c.id === currentConvId ? ' active' : ''}${c.archived ? ' archived' : ''}" data-conversation-id="${c.id}" onclick="openConversation('${c.id}')">
|
|
414
|
-
<div class="conv-title">${escHtml(
|
|
415
|
-
<div class="conv-meta"><span class="conv-meta-primary">${fmtDate(c.updatedAt)} · ${c.messageCount} msg${c.messageCount !== 1 ? 's' : ''}</span>${originMarkerHtml}${providerIndicatorHtml}</div>
|
|
714
|
+
<div class="conv-item worker-ui-${view.visualState}${c.id === currentConvId ? ' active' : ''}${c.archived ? ' archived' : ''}${groupClass}" data-conversation-id="${c.id}"${groupAttr} onclick="openConversation('${c.id}')">
|
|
715
|
+
<div class="conv-title"${titleAttr}>${item.kind === 'head' ? foldButtonHtml(item) : ''}${escHtml(rowTitle)}${processingDots ? `<span class="conv-processing-dots">${escHtml(` ${processingDots}`)}</span>` : ''}${pendingByConversation[c.id] ? ` <span class="conv-open-questions">${pendingByConversation[c.id]} open</span>` : ''}</div>
|
|
716
|
+
<div class="conv-meta"><span class="conv-meta-primary">${fmtDate(c.updatedAt)} · ${c.messageCount} msg${c.messageCount !== 1 ? 's' : ''}</span>${item.kind === 'head' ? groupPillHtml(item) : ''}${originMarkerHtml}${watchedMarkerHtml}${providerIndicatorHtml}</div>
|
|
416
717
|
${c.archived
|
|
417
718
|
? `<button class="conv-archive" onclick="unarchiveConv(event,'${c.id}')" title="Unarchive" aria-label="Unarchive">📂</button>`
|
|
418
719
|
: `<button class="conv-archive" onclick="archiveConv(event,'${c.id}')" title="Archive" aria-label="Archive">🗄</button>`}
|
|
419
720
|
<button class="conv-delete" onclick="deleteConv(event,'${c.id}')" title="Delete">🗑</button>
|
|
420
721
|
</div>`;
|
|
421
|
-
}
|
|
722
|
+
};
|
|
723
|
+
// A group whose head is not in this view (made by hand, or its head is
|
|
724
|
+
// archived or deleted): a head line of its own, with the name and the pill.
|
|
725
|
+
const syntheticHeadHtml = (item) => {
|
|
726
|
+
const name = groupDisplayName(item.group.id);
|
|
727
|
+
const described = groupDescriptions.get(item.group.id) || describeGroupForHead(item.group, viewOf);
|
|
728
|
+
return `
|
|
729
|
+
<div class="conv-item conv-group-head conv-group-synthetic worker-ui-${described.summary.strongest === 'waiting' ? 'question' : 'offline'}" data-group-id="${escHtml(item.group.id)}" onclick="toggleConversationGroupFold(event,'${escHtml(item.group.id)}')">
|
|
730
|
+
<div class="conv-title">${foldButtonHtml(item)}${escHtml(name)}</div>
|
|
731
|
+
<div class="conv-meta"><span class="conv-meta-primary">group</span>${groupPillHtml(item)}</div>
|
|
732
|
+
</div>`;
|
|
733
|
+
};
|
|
734
|
+
const listHtml = `${items.map((item) => (item.conversation ? rowHtml(item.conversation, item) : syntheticHeadHtml(item))).join('')}${footerHtml}`;
|
|
422
735
|
if (listHtml !== lastConvListHtml) {
|
|
423
736
|
list.innerHTML = listHtml;
|
|
424
737
|
lastConvListHtml = listHtml;
|
|
425
738
|
}
|
|
426
739
|
bindConversationContextMenu(list);
|
|
740
|
+
bindConversationGroupControls(list);
|
|
427
741
|
ensureProcessingDotTimer(hasProcessingConversation);
|
|
428
742
|
window.syncChatTitleControls?.();
|
|
429
743
|
scheduleConversationListBoundaryCheck();
|
|
430
744
|
}
|
|
431
745
|
|
|
746
|
+
/**
|
|
747
|
+
* Keyboard access to the waiting pill (its click is inline, like the rows'
|
|
748
|
+
* buttons; the fold is a real button). Bound once on the list.
|
|
749
|
+
*/
|
|
750
|
+
function bindConversationGroupControls(list) {
|
|
751
|
+
if (!list || list.dataset.groupControlsBound === '1') return;
|
|
752
|
+
list.dataset.groupControlsBound = '1';
|
|
753
|
+
list.addEventListener('keydown', (event) => {
|
|
754
|
+
if (event.key !== 'Enter' && event.key !== ' ') return;
|
|
755
|
+
const waiting = event.target?.closest?.('[data-open-waiting]');
|
|
756
|
+
if (!waiting || !list.contains(waiting)) return;
|
|
757
|
+
event.preventDefault();
|
|
758
|
+
event.stopPropagation();
|
|
759
|
+
void (window.openConversation || openConversation)(waiting.dataset.openWaiting);
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* Folds or unfolds a group; the choice is kept until the user changes it. A
|
|
765
|
+
* group held open (its chat is open, or a filter is on) keeps its choice.
|
|
766
|
+
* The fold button keeps the focus across the re-render.
|
|
767
|
+
*/
|
|
768
|
+
export function toggleConversationGroupFold(event, groupId) {
|
|
769
|
+
event?.stopPropagation?.();
|
|
770
|
+
event?.preventDefault?.();
|
|
771
|
+
const id = String(groupId || '').trim();
|
|
772
|
+
if (!id) return;
|
|
773
|
+
const button = document.querySelector(`#conv-list [data-group-fold="${CSS.escape(id)}"]`);
|
|
774
|
+
if (button?.disabled) return;
|
|
775
|
+
const currentlyOpen = button ? button.getAttribute('aria-expanded') === 'true' : true;
|
|
776
|
+
convListTransientOpenGroups.delete(id);
|
|
777
|
+
setConversationGroupFoldChoice(id, toggledGroupFoldChoice(currentlyOpen));
|
|
778
|
+
renderConvList();
|
|
779
|
+
document.querySelector(`#conv-list [data-group-fold="${CSS.escape(id)}"]`)?.focus?.({ preventScroll: true });
|
|
780
|
+
}
|
|
781
|
+
|
|
432
782
|
// keepTranscript: the page shows a window this payload must not replace (the
|
|
433
783
|
// user jumped into the history; the payload is the end of the conversation).
|
|
434
784
|
// Everything but the messages still applies.
|
|
@@ -464,15 +814,22 @@ export function applyLoadedConversationState(id, response, {
|
|
|
464
814
|
runtimeWorkspaceRootName: response.runtimeWorkspaceRootName ?? existingConversation.runtimeWorkspaceRootName ?? null,
|
|
465
815
|
currentWorkspaceRootPath: response.currentWorkspaceRootPath ?? existingConversation.currentWorkspaceRootPath ?? null,
|
|
466
816
|
currentWorkspaceRootName: response.currentWorkspaceRootName ?? existingConversation.currentWorkspaceRootName ?? null,
|
|
817
|
+
// A shared payload has no runtime session, only the provider (null when
|
|
818
|
+
// none is bound); the shared view keeps an unknown one unknown.
|
|
467
819
|
runtimeProviderType: response.runtimeSession?.providerType
|
|
820
|
+
?? response.runtimeProviderType
|
|
468
821
|
?? existingConversation.runtimeProviderType
|
|
469
|
-
?? 'github',
|
|
822
|
+
?? (IS_SHARED_VIEW ? null : 'github'),
|
|
470
823
|
runtimeProviderModel: response.runtimeSession?.providerModel
|
|
471
824
|
?? existingConversation.runtimeProviderModel
|
|
472
825
|
?? null,
|
|
473
826
|
runtimeModel: response.runtimeSession?.model
|
|
474
827
|
?? existingConversation.runtimeModel
|
|
475
828
|
?? null,
|
|
829
|
+
// The session's state and live tasks, as the single payload reports them.
|
|
830
|
+
...(typeof response.activeTurn === 'boolean' ? { activeTurn: response.activeTurn } : {}),
|
|
831
|
+
...(response.sessionState ? { sessionState: response.sessionState } : {}),
|
|
832
|
+
...(Array.isArray(response.backgroundTasks) ? { backgroundTasks: response.backgroundTasks } : {}),
|
|
476
833
|
sessionUsageSummary: response.sessionUsageSummary ?? existingConversation.sessionUsageSummary ?? null,
|
|
477
834
|
// Claude Cloud only: repository, branch, session link, pushed branches.
|
|
478
835
|
// An explicit null is a real answer ("not a cloud chat"); only a payload
|
|
@@ -483,6 +840,9 @@ export function applyLoadedConversationState(id, response, {
|
|
|
483
840
|
messageCount: Array.isArray(response.messages)
|
|
484
841
|
? Math.max(existingConversation.messageCount || 0, response.messages.length)
|
|
485
842
|
: (existingConversation.messageCount || 0),
|
|
843
|
+
// This payload's verdict only: the record's copy can predate the turn the
|
|
844
|
+
// socket has since reported running, and would end it on the page.
|
|
845
|
+
activeTurn: response.activeTurn,
|
|
486
846
|
});
|
|
487
847
|
renderConvList();
|
|
488
848
|
window.applyConversationPreferences?.(id, {
|
|
@@ -499,6 +859,9 @@ export function applyLoadedConversationState(id, response, {
|
|
|
499
859
|
// Absent from an older relay's payload: keep what the socket said.
|
|
500
860
|
if ('pins' in response) setConversationPins(id, response.pins, response.pinsRevision);
|
|
501
861
|
setPinsConversation(id);
|
|
862
|
+
// Their Hide / Unhide too, from the hidden list (owner payloads only).
|
|
863
|
+
if ('hiddenMessages' in response) setConversationHiddenMessages(id, response.hiddenMessages, response.hiddenMessagesRevision);
|
|
864
|
+
setHiddenMessagesConversation(id);
|
|
502
865
|
const didRenderMessages = keepTranscript
|
|
503
866
|
? false
|
|
504
867
|
: renderMessages(response.messages, !restoreScroll, response);
|
|
@@ -555,7 +918,10 @@ export async function openConversation(id, options = {}) {
|
|
|
555
918
|
const previousConversationId = String(currentConvId || '').trim();
|
|
556
919
|
const nextConversationId = String(id || '').trim();
|
|
557
920
|
if (previousConversationId && nextConversationId && previousConversationId !== nextConversationId) {
|
|
558
|
-
|
|
921
|
+
// Reads the composer now, before it changes hands below, and saves in the
|
|
922
|
+
// background: a switch that waited for the save would take over the view
|
|
923
|
+
// when the save returns, after any click the user made meanwhile.
|
|
924
|
+
void flushConversationDraft(previousConversationId);
|
|
559
925
|
window.clearImageEditTarget?.();
|
|
560
926
|
}
|
|
561
927
|
const switchingConversation = !!nextConversationId && previousConversationId !== nextConversationId;
|
|
@@ -616,10 +982,15 @@ export async function openConversation(id, options = {}) {
|
|
|
616
982
|
return;
|
|
617
983
|
}
|
|
618
984
|
if (r) {
|
|
985
|
+
// The conversation on screen opened again (a worker bound its session):
|
|
986
|
+
// its rows are rebuilt under the reader, who keeps the text they are at.
|
|
987
|
+
const messagesEl = document.getElementById('messages');
|
|
988
|
+
const reading = !switchingConversation && restoreScroll ? captureReadingBlock(messagesEl) : null;
|
|
619
989
|
// Counted when the window is put on the page, not when it is asked for:
|
|
620
990
|
// a reload of the end that is still on its way must see the change.
|
|
621
991
|
if (forceFreshWindow) noteTranscriptJump();
|
|
622
992
|
applyLoadedConversationState(id, r, { restoreScroll, savedScrollTop });
|
|
993
|
+
if (reading) restoreReadingBlock(messagesEl, reading);
|
|
623
994
|
if (focusMessageId) {
|
|
624
995
|
requestAnimationFrame(() => {
|
|
625
996
|
focusConversationMessageById(focusMessageId, { behavior: 'smooth', block: 'center' });
|
|
@@ -1489,6 +1860,31 @@ function populateNewConversationCwdSelect() {
|
|
|
1489
1860
|
syncNewConversationCwdControls();
|
|
1490
1861
|
}
|
|
1491
1862
|
|
|
1863
|
+
// The New Chat modal with no provider known to be ready: a note above the
|
|
1864
|
+
// buttons, Open Settings → Providers as the main button, and the usual Start
|
|
1865
|
+
// (same id, same handler) as a secondary "Start a Copilot chat anyway".
|
|
1866
|
+
function applyNewConversationSetupState(setupRequired) {
|
|
1867
|
+
const note = document.getElementById('new-conversation-setup-note');
|
|
1868
|
+
const openSettings = document.getElementById('new-conversation-open-settings');
|
|
1869
|
+
const confirm = document.getElementById('new-conversation-model-confirm');
|
|
1870
|
+
if (note) {
|
|
1871
|
+
note.hidden = !setupRequired;
|
|
1872
|
+
note.textContent = setupRequired ? SETUP_TEXT : '';
|
|
1873
|
+
}
|
|
1874
|
+
if (openSettings) {
|
|
1875
|
+
openSettings.hidden = !setupRequired;
|
|
1876
|
+
if (openSettings.dataset.bound !== '1') {
|
|
1877
|
+
openSettings.dataset.bound = '1';
|
|
1878
|
+
openSettings.addEventListener('click', (event) => {
|
|
1879
|
+
event.preventDefault();
|
|
1880
|
+
closeNewConversationModelModal();
|
|
1881
|
+
openSettingsModal('providers');
|
|
1882
|
+
});
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1885
|
+
if (confirm) confirm.textContent = setupRequired ? 'Start a Copilot chat anyway' : 'Start chat';
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1492
1888
|
async function openNewConversationModelModal() {
|
|
1493
1889
|
const [catalog, settings, claudeSettings, cursorSettings, grokSettings, claudeCloudSettings] = await Promise.all([
|
|
1494
1890
|
loadModelCatalog(),
|
|
@@ -1505,6 +1901,18 @@ async function openNewConversationModelModal() {
|
|
|
1505
1901
|
newConversationCursorSettingsCache = cursorSettings || null;
|
|
1506
1902
|
newConversationGrokSettingsCache = grokSettings || null;
|
|
1507
1903
|
newConversationClaudeCloudSettingsCache = claudeCloudSettings || null;
|
|
1904
|
+
// Fresh answers for the readiness question, shared with the empty screen
|
|
1905
|
+
// and the composer. With no provider known to be ready the modal opens in
|
|
1906
|
+
// its setup state: Settings is the main button, Start stays as "anyway".
|
|
1907
|
+
const readiness = updateProviderReadinessInputs({
|
|
1908
|
+
openai: settings || null,
|
|
1909
|
+
claude: claudeSettings || null,
|
|
1910
|
+
claudeCloud: claudeCloudSettings || null,
|
|
1911
|
+
cursor: cursorSettings || null,
|
|
1912
|
+
grok: grokSettings || null,
|
|
1913
|
+
catalog: catalog || null,
|
|
1914
|
+
});
|
|
1915
|
+
applyNewConversationSetupState(isSetupRequired(readiness));
|
|
1508
1916
|
// Every open starts from empty cloud fields; the folder lookup refills them.
|
|
1509
1917
|
resetNewConversationCloudState();
|
|
1510
1918
|
bindNewConversationCloudInputs();
|
|
@@ -1826,7 +2234,7 @@ function announceConversationFilterResults() {
|
|
|
1826
2234
|
if (status.textContent !== text) status.textContent = text;
|
|
1827
2235
|
}
|
|
1828
2236
|
|
|
1829
|
-
function setConversationListFilter(text) {
|
|
2237
|
+
export function setConversationListFilter(text) {
|
|
1830
2238
|
const next = String(text ?? '');
|
|
1831
2239
|
if (next === conversationListFilterText) return;
|
|
1832
2240
|
conversationListFilterText = next;
|
|
@@ -1917,9 +2325,61 @@ async function setConversationArchived(id, archived) {
|
|
|
1917
2325
|
return true;
|
|
1918
2326
|
}
|
|
1919
2327
|
|
|
2328
|
+
const ARCHIVE_CONFIRM_KIND = 'archive-confirm';
|
|
2329
|
+
|
|
2330
|
+
/** Resolves true when the user confirms; Cancel, Escape, the backdrop or × keep the chat. */
|
|
2331
|
+
function askBeforeArchive(id, confirmation) {
|
|
2332
|
+
const title = String(conversations[id]?.title || '').trim();
|
|
2333
|
+
return new Promise((resolve) => {
|
|
2334
|
+
let settled = false;
|
|
2335
|
+
const settle = (value) => {
|
|
2336
|
+
if (settled) return;
|
|
2337
|
+
settled = true;
|
|
2338
|
+
resolve(value);
|
|
2339
|
+
};
|
|
2340
|
+
openSummaryModal({
|
|
2341
|
+
title: 'Archive session',
|
|
2342
|
+
subtitle: title,
|
|
2343
|
+
kind: ARCHIVE_CONFIRM_KIND,
|
|
2344
|
+
bodyHtml: `
|
|
2345
|
+
<p>${escHtml(confirmation.message)}</p>
|
|
2346
|
+
<p>${escHtml(confirmation.detail)}</p>
|
|
2347
|
+
<div class="summary-modal-actions">
|
|
2348
|
+
<button class="chat-title-action-btn" type="button" data-archive-confirm="1">🗄 Archive</button>
|
|
2349
|
+
<button class="chat-title-action-btn" type="button" data-archive-cancel="1">Cancel</button>
|
|
2350
|
+
</div>
|
|
2351
|
+
`,
|
|
2352
|
+
});
|
|
2353
|
+
// Runs on every way out of the modal, another modal replacing it included.
|
|
2354
|
+
setSummaryModalLeaveHandler(ARCHIVE_CONFIRM_KIND, () => settle(false));
|
|
2355
|
+
const body = document.getElementById('summary-modal-body');
|
|
2356
|
+
body?.querySelector('[data-archive-confirm]')?.addEventListener('click', (event) => {
|
|
2357
|
+
event.preventDefault();
|
|
2358
|
+
settle(true);
|
|
2359
|
+
closeSummaryModal();
|
|
2360
|
+
}, { once: true });
|
|
2361
|
+
const cancel = body?.querySelector('[data-archive-cancel]');
|
|
2362
|
+
cancel?.addEventListener('click', (event) => {
|
|
2363
|
+
event.preventDefault();
|
|
2364
|
+
closeSummaryModal();
|
|
2365
|
+
}, { once: true });
|
|
2366
|
+
cancel?.focus?.();
|
|
2367
|
+
});
|
|
2368
|
+
}
|
|
2369
|
+
|
|
1920
2370
|
export async function archiveConv(e, id) {
|
|
1921
2371
|
e?.stopPropagation?.();
|
|
1922
2372
|
closeContextMenu();
|
|
2373
|
+
// A chat whose process is live (green, idle or not) asks first: archiving
|
|
2374
|
+
// stops that process. The relay still refuses a chat with a running turn.
|
|
2375
|
+
const conversation = conversations[id];
|
|
2376
|
+
const view = conversation ? conversationRowState(conversation) : null;
|
|
2377
|
+
const confirmation = archiveConfirmationFor({
|
|
2378
|
+
archived: conversation?.archived === true,
|
|
2379
|
+
visualState: view?.visualState,
|
|
2380
|
+
running: conversationHasActiveTurn(id) || conversation?.activeTurn === true || view?.processing === true,
|
|
2381
|
+
});
|
|
2382
|
+
if (confirmation && !(await askBeforeArchive(id, confirmation))) return;
|
|
1923
2383
|
await setConversationArchived(id, true);
|
|
1924
2384
|
}
|
|
1925
2385
|
|
|
@@ -1941,6 +2401,35 @@ function conversationMenuItems(id) {
|
|
|
1941
2401
|
if (!isCurrent) await openConversation(id);
|
|
1942
2402
|
then();
|
|
1943
2403
|
};
|
|
2404
|
+
const groupId = String(conversation.groupId || '').trim();
|
|
2405
|
+
const groupInfo = groupId ? conversationGroups[groupId] || null : null;
|
|
2406
|
+
const isHead = !!groupId && groupInfo?.parentConversationId === id;
|
|
2407
|
+
// Group entries: a head carries the group's actions; a member can leave or
|
|
2408
|
+
// move, and step up or down the group's order (the keyboard's way to do
|
|
2409
|
+
// what a drag does); an ungrouped conversation can join or start a group.
|
|
2410
|
+
const memberIds = !groupId || isHead
|
|
2411
|
+
? []
|
|
2412
|
+
: (lastConvListDragItems.find((item) => item.kind === 'head' && item.groupId === groupId)?.memberIds || []);
|
|
2413
|
+
const stepEntries = memberIds.includes(id)
|
|
2414
|
+
? ['up', 'down'].map((direction) => {
|
|
2415
|
+
const step = groupMoveStep(memberIds, id, direction);
|
|
2416
|
+
return {
|
|
2417
|
+
id: `group-move-${direction}`,
|
|
2418
|
+
label: direction === 'up' ? 'Move up' : 'Move down',
|
|
2419
|
+
disabled: !step,
|
|
2420
|
+
onSelect: () => { if (step) void placeConversationInGroupOrder(id, groupId, step.beforeConversationId); },
|
|
2421
|
+
};
|
|
2422
|
+
})
|
|
2423
|
+
: [];
|
|
2424
|
+
const groupEntries = !groupId
|
|
2425
|
+
? [{ id: 'group-add', label: 'Add to group…', onSelect: () => { openConversationGroupPicker(id); } }]
|
|
2426
|
+
: [
|
|
2427
|
+
...(isHead ? groupMenuItems(groupId) : []),
|
|
2428
|
+
...(isHead ? [MENU_SEPARATOR] : []),
|
|
2429
|
+
...stepEntries,
|
|
2430
|
+
{ id: 'group-move', label: 'Move to another group…', onSelect: () => { openConversationGroupPicker(id); } },
|
|
2431
|
+
{ id: 'group-remove', label: 'Remove from group', onSelect: () => { void removeConversationFromGroup(id); } },
|
|
2432
|
+
];
|
|
1944
2433
|
return [
|
|
1945
2434
|
{ id: 'open', label: 'Open', onSelect: () => { void openConversation(id); } },
|
|
1946
2435
|
{ id: 'edit-title', label: 'Edit title', onSelect: () => { void openFirst(() => window.openChatTitleEditor?.()); } },
|
|
@@ -1948,6 +2437,8 @@ function conversationMenuItems(id) {
|
|
|
1948
2437
|
{ id: 'stop-turn', label: 'Stop turn', disabled: !running, onSelect: () => { void openFirst(() => openStopTurnConfirmationForConversation(id)); } },
|
|
1949
2438
|
{ id: 'kill-session', label: 'Kill session', disabled: !workerState && !sdkSessionId, onSelect: () => { void openFirst(() => window.openKillSessionConfirmation?.()); } },
|
|
1950
2439
|
MENU_SEPARATOR,
|
|
2440
|
+
...groupEntries,
|
|
2441
|
+
MENU_SEPARATOR,
|
|
1951
2442
|
conversation.archived === true
|
|
1952
2443
|
? { id: 'unarchive', label: 'Unarchive', onSelect: () => { void unarchiveConv(null, id); } }
|
|
1953
2444
|
: { id: 'archive', label: 'Archive', onSelect: () => { void archiveConv(null, id); } },
|
|
@@ -1955,14 +2446,390 @@ function conversationMenuItems(id) {
|
|
|
1955
2446
|
];
|
|
1956
2447
|
}
|
|
1957
2448
|
|
|
1958
|
-
/**
|
|
2449
|
+
/** The group actions of a head row (live or synthetic): archive, stop, rename, dissolve. */
|
|
2450
|
+
function groupMenuItems(groupId) {
|
|
2451
|
+
const id = String(groupId || '').trim();
|
|
2452
|
+
if (!id || !conversationGroups[id]) return [];
|
|
2453
|
+
const pendingByConversation = getPendingQuestionCountsByConversation();
|
|
2454
|
+
const rows = groupMemberRows(id, { inViewOnly: false });
|
|
2455
|
+
const liveRows = rows.filter((row) => row.archived !== true);
|
|
2456
|
+
// What the action stops: every turn and every stoppable background task,
|
|
2457
|
+
// in a member that waits on a card as much as in one that works.
|
|
2458
|
+
const running = liveRows.filter((row) => conversationRowState(row, pendingByConversation).live).length;
|
|
2459
|
+
return [
|
|
2460
|
+
{ id: 'group-archive', label: `Archive group (${liveRows.length})`, disabled: !liveRows.length, onSelect: () => { void archiveConversationGroup(id); } },
|
|
2461
|
+
{ id: 'group-stop', label: `Stop all running (${running})`, disabled: !running, onSelect: () => { void stopConversationGroup(id); } },
|
|
2462
|
+
{ id: 'group-rename', label: 'Rename group', onSelect: () => { openConversationGroupRename(id); } },
|
|
2463
|
+
{ id: 'group-dissolve', label: 'Dissolve group', onSelect: () => { void dissolveConversationGroup(id); } },
|
|
2464
|
+
];
|
|
2465
|
+
}
|
|
2466
|
+
|
|
2467
|
+
/** Resolves true when the user confirms; Cancel, Escape, the backdrop or × say no. */
|
|
2468
|
+
function askConfirmation({ title, subtitle = '', message, detail = '', confirmLabel, kind }) {
|
|
2469
|
+
return new Promise((resolve) => {
|
|
2470
|
+
let settled = false;
|
|
2471
|
+
const settle = (value) => {
|
|
2472
|
+
if (settled) return;
|
|
2473
|
+
settled = true;
|
|
2474
|
+
resolve(value);
|
|
2475
|
+
};
|
|
2476
|
+
openSummaryModal({
|
|
2477
|
+
title,
|
|
2478
|
+
subtitle,
|
|
2479
|
+
kind,
|
|
2480
|
+
bodyHtml: `
|
|
2481
|
+
<p>${escHtml(message)}</p>
|
|
2482
|
+
${detail ? `<p>${escHtml(detail)}</p>` : ''}
|
|
2483
|
+
<div class="summary-modal-actions">
|
|
2484
|
+
<button class="chat-title-action-btn" type="button" data-confirm="1">${escHtml(confirmLabel)}</button>
|
|
2485
|
+
<button class="chat-title-action-btn" type="button" data-cancel="1">Cancel</button>
|
|
2486
|
+
</div>
|
|
2487
|
+
`,
|
|
2488
|
+
});
|
|
2489
|
+
setSummaryModalLeaveHandler(kind, () => settle(false));
|
|
2490
|
+
const body = document.getElementById('summary-modal-body');
|
|
2491
|
+
body?.querySelector('[data-confirm]')?.addEventListener('click', (event) => {
|
|
2492
|
+
event.preventDefault();
|
|
2493
|
+
settle(true);
|
|
2494
|
+
closeSummaryModal();
|
|
2495
|
+
}, { once: true });
|
|
2496
|
+
const cancel = body?.querySelector('[data-cancel]');
|
|
2497
|
+
cancel?.addEventListener('click', (event) => {
|
|
2498
|
+
event.preventDefault();
|
|
2499
|
+
closeSummaryModal();
|
|
2500
|
+
}, { once: true });
|
|
2501
|
+
cancel?.focus?.();
|
|
2502
|
+
});
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
function groupActionFailed(result, fallback) {
|
|
2506
|
+
showTransientRelayNotice(String(result?.error || result?.message || '').trim() || fallback, 6000);
|
|
2507
|
+
}
|
|
2508
|
+
|
|
2509
|
+
/** Archive every member that can be archived now; the skipped ones are named. */
|
|
2510
|
+
async function archiveConversationGroup(groupId) {
|
|
2511
|
+
closeContextMenu();
|
|
2512
|
+
const name = groupDisplayName(groupId);
|
|
2513
|
+
const liveRows = groupMemberRows(groupId, { inViewOnly: false }).filter((row) => row.archived !== true);
|
|
2514
|
+
const pendingByConversation = getPendingQuestionCountsByConversation();
|
|
2515
|
+
const busy = liveRows.filter((row) => conversationRowState(row, pendingByConversation).live).length;
|
|
2516
|
+
const confirmed = await askConfirmation({
|
|
2517
|
+
title: 'Archive group',
|
|
2518
|
+
subtitle: name,
|
|
2519
|
+
kind: 'group-archive-confirm',
|
|
2520
|
+
message: `Archive the ${liveRows.length} session${liveRows.length === 1 ? '' : 's'} of this group?`,
|
|
2521
|
+
detail: busy
|
|
2522
|
+
? `${busy} of them ${busy === 1 ? 'is' : 'are'} still working and will stay; stop them first to archive everything.`
|
|
2523
|
+
: 'Archiving stops their idle processes. The chats stay in the Archived view.',
|
|
2524
|
+
confirmLabel: '🗄 Archive group',
|
|
2525
|
+
});
|
|
2526
|
+
if (!confirmed) return;
|
|
2527
|
+
const result = await archiveConversationGroupApi(groupId);
|
|
2528
|
+
if (!result) return;
|
|
2529
|
+
if (result.ok === false) {
|
|
2530
|
+
groupActionFailed(result, 'The group could not be archived.');
|
|
2531
|
+
return;
|
|
2532
|
+
}
|
|
2533
|
+
const skipped = Array.isArray(result.skipped) ? result.skipped : [];
|
|
2534
|
+
for (const id of result.archived || []) if (conversations[id]) conversations[id].archived = true;
|
|
2535
|
+
renderConvList();
|
|
2536
|
+
if (skipped.length) {
|
|
2537
|
+
const names = skipped.map((entry) => `“${String(entry.title || entry.id).trim()}”`).join(', ');
|
|
2538
|
+
showTransientRelayNotice(`${result.archived?.length || 0} archived; still working and kept: ${names}.`, 8000);
|
|
2539
|
+
} else {
|
|
2540
|
+
showTransientRelayNotice(`${result.archived?.length || 0} session${result.archived?.length === 1 ? '' : 's'} archived.`, 4000);
|
|
2541
|
+
}
|
|
2542
|
+
// The relay's conversation_group_changed event applies the change in place.
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2545
|
+
/** A stop for every member's live turn and stoppable background task. */
|
|
2546
|
+
async function stopConversationGroup(groupId) {
|
|
2547
|
+
closeContextMenu();
|
|
2548
|
+
const name = groupDisplayName(groupId);
|
|
2549
|
+
const confirmed = await askConfirmation({
|
|
2550
|
+
title: 'Stop all running',
|
|
2551
|
+
subtitle: name,
|
|
2552
|
+
kind: 'group-stop-confirm',
|
|
2553
|
+
message: 'Stop every running turn and background task in this group?',
|
|
2554
|
+
detail: 'Each session stops at its next safe point; the chats stay where they are.',
|
|
2555
|
+
confirmLabel: '⏹ Stop all',
|
|
2556
|
+
});
|
|
2557
|
+
if (!confirmed) return;
|
|
2558
|
+
const result = await stopConversationGroupApi(groupId);
|
|
2559
|
+
if (!result) return;
|
|
2560
|
+
if (result.ok === false) {
|
|
2561
|
+
groupActionFailed(result, 'The group could not be stopped.');
|
|
2562
|
+
return;
|
|
2563
|
+
}
|
|
2564
|
+
const turns = result.turns?.length || 0;
|
|
2565
|
+
const tasks = result.tasks?.length || 0;
|
|
2566
|
+
const unstoppable = result.unstoppable?.length || 0;
|
|
2567
|
+
const parts = [];
|
|
2568
|
+
if (turns) parts.push(`${turns} turn${turns === 1 ? '' : 's'}`);
|
|
2569
|
+
if (tasks) parts.push(`${tasks} background task${tasks === 1 ? '' : 's'}`);
|
|
2570
|
+
let text = parts.length ? `Stop requested for ${parts.join(' and ')}.` : 'Nothing was running in this group.';
|
|
2571
|
+
if (unstoppable) text += ` ${unstoppable} task${unstoppable === 1 ? ' has' : 's have'} no Stop button; use Kill session there.`;
|
|
2572
|
+
showTransientRelayNotice(text, 6000);
|
|
2573
|
+
}
|
|
2574
|
+
|
|
2575
|
+
/** Members become ungrouped rows; nothing else changes. */
|
|
2576
|
+
async function dissolveConversationGroup(groupId) {
|
|
2577
|
+
closeContextMenu();
|
|
2578
|
+
const confirmed = await askConfirmation({
|
|
2579
|
+
title: 'Dissolve group',
|
|
2580
|
+
subtitle: groupDisplayName(groupId),
|
|
2581
|
+
kind: 'group-dissolve-confirm',
|
|
2582
|
+
message: 'Dissolve this group? Its sessions stay, as single rows.',
|
|
2583
|
+
confirmLabel: 'Dissolve',
|
|
2584
|
+
});
|
|
2585
|
+
if (!confirmed) return;
|
|
2586
|
+
const result = await dissolveConversationGroupApi(groupId);
|
|
2587
|
+
if (!result) return;
|
|
2588
|
+
if (result.ok === false) {
|
|
2589
|
+
groupActionFailed(result, 'The group could not be dissolved.');
|
|
2590
|
+
return;
|
|
2591
|
+
}
|
|
2592
|
+
// The relay's conversation_group_changed event applies the change in place.
|
|
2593
|
+
}
|
|
2594
|
+
|
|
2595
|
+
async function removeConversationFromGroup(conversationId) {
|
|
2596
|
+
closeContextMenu();
|
|
2597
|
+
const result = await removeConversationFromGroupApi(conversationId);
|
|
2598
|
+
if (!result) return;
|
|
2599
|
+
if (result.ok === false) {
|
|
2600
|
+
groupActionFailed(result, 'The conversation could not leave its group.');
|
|
2601
|
+
return;
|
|
2602
|
+
}
|
|
2603
|
+
// The relay's conversation_group_changed event applies the change in place.
|
|
2604
|
+
}
|
|
2605
|
+
|
|
2606
|
+
const GROUP_RENAME_KIND = 'group-rename';
|
|
2607
|
+
const GROUP_PICKER_KIND = 'group-picker';
|
|
2608
|
+
const GROUP_NAME_MAX_LENGTH = 60;
|
|
2609
|
+
|
|
2610
|
+
/** A name for the group; empty returns it to the head's title. */
|
|
2611
|
+
export function openConversationGroupRename(groupId) {
|
|
2612
|
+
closeContextMenu();
|
|
2613
|
+
const id = String(groupId || '').trim();
|
|
2614
|
+
const info = conversationGroups[id];
|
|
2615
|
+
if (!info) return;
|
|
2616
|
+
openSummaryModal({
|
|
2617
|
+
title: 'Rename group',
|
|
2618
|
+
subtitle: groupDisplayName(id),
|
|
2619
|
+
kind: GROUP_RENAME_KIND,
|
|
2620
|
+
bodyHtml: `
|
|
2621
|
+
<form id="conv-group-rename-form" class="conv-group-form">
|
|
2622
|
+
<input id="conv-group-rename-input" class="conv-group-input" type="text" maxlength="${GROUP_NAME_MAX_LENGTH}" value="${escHtml(info.name || '')}" placeholder="${escHtml(info.parentConversationId ? 'Leave empty to use the head session’s title' : 'Group name')}" autocomplete="off" spellcheck="false" aria-label="Group name">
|
|
2623
|
+
<div class="summary-modal-actions">
|
|
2624
|
+
<button class="chat-title-action-btn" type="submit" data-rename-save="1">Save</button>
|
|
2625
|
+
<button class="chat-title-action-btn" type="button" data-cancel="1">Cancel</button>
|
|
2626
|
+
</div>
|
|
2627
|
+
</form>
|
|
2628
|
+
`,
|
|
2629
|
+
});
|
|
2630
|
+
const body = document.getElementById('summary-modal-body');
|
|
2631
|
+
const form = body?.querySelector('#conv-group-rename-form');
|
|
2632
|
+
const input = body?.querySelector('#conv-group-rename-input');
|
|
2633
|
+
form?.addEventListener('submit', async (event) => {
|
|
2634
|
+
event.preventDefault();
|
|
2635
|
+
const name = String(input?.value || '').trim();
|
|
2636
|
+
closeSummaryModal();
|
|
2637
|
+
const result = await renameConversationGroupApi(id, name);
|
|
2638
|
+
if (!result) return;
|
|
2639
|
+
if (result.ok === false) {
|
|
2640
|
+
groupActionFailed(result, 'The group could not be renamed.');
|
|
2641
|
+
return;
|
|
2642
|
+
}
|
|
2643
|
+
// The relay's conversation_group_changed event applies the change in place.
|
|
2644
|
+
});
|
|
2645
|
+
body?.querySelector('[data-cancel]')?.addEventListener('click', (event) => {
|
|
2646
|
+
event.preventDefault();
|
|
2647
|
+
closeSummaryModal();
|
|
2648
|
+
}, { once: true });
|
|
2649
|
+
requestAnimationFrame(() => {
|
|
2650
|
+
input?.focus({ preventScroll: true });
|
|
2651
|
+
input?.select?.();
|
|
2652
|
+
});
|
|
2653
|
+
}
|
|
2654
|
+
|
|
2655
|
+
/**
|
|
2656
|
+
* The groups a conversation can join (the loaded ones, by name, with their
|
|
2657
|
+
* counts) and a new group with a name. Desktop and phone share it; it also
|
|
2658
|
+
* reaches the groups a drag cannot: on another page, or in the other view.
|
|
2659
|
+
*/
|
|
2660
|
+
export function openConversationGroupPicker(conversationId) {
|
|
2661
|
+
closeContextMenu();
|
|
2662
|
+
const id = String(conversationId || '').trim();
|
|
2663
|
+
const conversation = conversations[id];
|
|
2664
|
+
if (!conversation) return;
|
|
2665
|
+
const currentGroupId = String(conversation.groupId || '').trim();
|
|
2666
|
+
const choices = Object.values(conversationGroups)
|
|
2667
|
+
.map((info) => ({
|
|
2668
|
+
id: info.id,
|
|
2669
|
+
name: groupDisplayName(info.id),
|
|
2670
|
+
count: Number(info.memberCount || groupMemberRows(info.id, { inViewOnly: false }).length || 0),
|
|
2671
|
+
current: info.id === currentGroupId,
|
|
2672
|
+
}))
|
|
2673
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
2674
|
+
const listHtml = choices.length
|
|
2675
|
+
? choices.map((choice) => `
|
|
2676
|
+
<button class="conv-group-choice" type="button" data-group-choice="${escHtml(choice.id)}"${choice.current ? ' disabled aria-current="true"' : ''}>
|
|
2677
|
+
<span class="conv-group-choice-name">${escHtml(choice.name)}</span>
|
|
2678
|
+
<span class="conv-group-choice-count">${choice.count} session${choice.count === 1 ? '' : 's'}${choice.current ? ' · current' : ''}</span>
|
|
2679
|
+
</button>`).join('')
|
|
2680
|
+
: '<p class="conv-group-picker-empty">No groups yet. Make one below.</p>';
|
|
2681
|
+
openSummaryModal({
|
|
2682
|
+
title: currentGroupId ? 'Move to another group' : 'Add to group',
|
|
2683
|
+
subtitle: String(conversation.title || '').trim(),
|
|
2684
|
+
kind: GROUP_PICKER_KIND,
|
|
2685
|
+
bodyHtml: `
|
|
2686
|
+
<div id="conv-group-picker" class="conv-group-picker">
|
|
2687
|
+
<div class="conv-group-choices">${listHtml}</div>
|
|
2688
|
+
<form id="conv-group-create-form" class="conv-group-form">
|
|
2689
|
+
<label class="conv-group-form-label" for="conv-group-create-input">New group</label>
|
|
2690
|
+
<div class="conv-group-form-row">
|
|
2691
|
+
<input id="conv-group-create-input" class="conv-group-input" type="text" maxlength="${GROUP_NAME_MAX_LENGTH}" placeholder="Group name" autocomplete="off" spellcheck="false" aria-label="New group name">
|
|
2692
|
+
<button class="chat-title-action-btn" type="submit" data-group-create="1">Create</button>
|
|
2693
|
+
</div>
|
|
2694
|
+
</form>
|
|
2695
|
+
</div>
|
|
2696
|
+
`,
|
|
2697
|
+
});
|
|
2698
|
+
const body = document.getElementById('summary-modal-body');
|
|
2699
|
+
body?.querySelector('.conv-group-choices')?.addEventListener('click', async (event) => {
|
|
2700
|
+
const button = event.target?.closest?.('[data-group-choice]');
|
|
2701
|
+
if (!button || button.disabled) return;
|
|
2702
|
+
event.preventDefault();
|
|
2703
|
+
closeSummaryModal();
|
|
2704
|
+
const result = await moveConversationToGroupApi(id, button.dataset.groupChoice);
|
|
2705
|
+
if (!result) return;
|
|
2706
|
+
if (result.ok === false) {
|
|
2707
|
+
groupActionFailed(result, 'The conversation could not join that group.');
|
|
2708
|
+
return;
|
|
2709
|
+
}
|
|
2710
|
+
// The relay's conversation_group_changed event applies the change in place.
|
|
2711
|
+
});
|
|
2712
|
+
const form = body?.querySelector('#conv-group-create-form');
|
|
2713
|
+
const input = body?.querySelector('#conv-group-create-input');
|
|
2714
|
+
form?.addEventListener('submit', async (event) => {
|
|
2715
|
+
event.preventDefault();
|
|
2716
|
+
const name = String(input?.value || '').trim();
|
|
2717
|
+
closeSummaryModal();
|
|
2718
|
+
const result = await createConversationGroupApi({ name: name || null, conversationIds: [id] });
|
|
2719
|
+
if (!result) return;
|
|
2720
|
+
if (result.ok === false) {
|
|
2721
|
+
groupActionFailed(result, 'The group could not be made.');
|
|
2722
|
+
return;
|
|
2723
|
+
}
|
|
2724
|
+
// The relay's conversation_group_changed event applies the change in place.
|
|
2725
|
+
});
|
|
2726
|
+
requestAnimationFrame(() => {
|
|
2727
|
+
(choices.length ? body?.querySelector('[data-group-choice]:not([disabled])') : input)?.focus?.({ preventScroll: true });
|
|
2728
|
+
});
|
|
2729
|
+
}
|
|
2730
|
+
|
|
2731
|
+
/** A move inside a group's order from the row menu (Move up / Move down). */
|
|
2732
|
+
async function placeConversationInGroupOrder(conversationId, groupId, beforeConversationId) {
|
|
2733
|
+
closeContextMenu();
|
|
2734
|
+
await applyConversationListDrop({ action: 'reorder', groupId, beforeConversationId }, conversationId);
|
|
2735
|
+
}
|
|
2736
|
+
|
|
2737
|
+
/** Says what a drop did, for a screen reader. */
|
|
2738
|
+
function announceConversationList(text) {
|
|
2739
|
+
const region = document.getElementById('conv-list-announce');
|
|
2740
|
+
if (!region || !text) return;
|
|
2741
|
+
// Emptied first so the same sentence twice is announced twice.
|
|
2742
|
+
region.textContent = '';
|
|
2743
|
+
requestAnimationFrame(() => { region.textContent = text; });
|
|
2744
|
+
}
|
|
2745
|
+
|
|
2746
|
+
const CONV_ROW_FLASH_MS = 1200;
|
|
2747
|
+
// The relay's event moves the row; it may arrive after the call's answer, so
|
|
2748
|
+
// the row flashes again where the event puts it, for a moment after a drop.
|
|
2749
|
+
const CONV_ROW_FLASH_PENDING_MS = 3000;
|
|
2750
|
+
let pendingConvRowFlash = null;
|
|
2751
|
+
|
|
2752
|
+
/** The moved row, briefly highlighted and brought into view. */
|
|
2753
|
+
function flashConversationRow(conversationId) {
|
|
2754
|
+
const row = document.querySelector(`#conv-list .conv-item[data-conversation-id="${CSS.escape(String(conversationId))}"]`);
|
|
2755
|
+
if (!row) return;
|
|
2756
|
+
row.classList.remove('conv-drop-flash');
|
|
2757
|
+
// Restarts the animation on a row that is still flashing.
|
|
2758
|
+
void row.offsetWidth;
|
|
2759
|
+
row.classList.add('conv-drop-flash');
|
|
2760
|
+
setTimeout(() => row.classList.remove('conv-drop-flash'), CONV_ROW_FLASH_MS);
|
|
2761
|
+
row.scrollIntoView?.({ block: 'nearest' });
|
|
2762
|
+
}
|
|
2763
|
+
|
|
2764
|
+
/**
|
|
2765
|
+
* A drop from the sidebar (conversation-group-drag.mjs), or a step from the
|
|
2766
|
+
* row menu: the relay call, then the row flashed and the move announced. The
|
|
2767
|
+
* records change only by the relay's `conversation_group_changed` event, as
|
|
2768
|
+
* for every group action: an answer that arrives after a newer event must
|
|
2769
|
+
* not undo it. A refusal says why and reloads the list.
|
|
2770
|
+
*/
|
|
2771
|
+
async function applyConversationListDrop(result, conversationId) {
|
|
2772
|
+
const id = String(conversationId || '').trim();
|
|
2773
|
+
const title = String(conversations[id]?.title || '').trim();
|
|
2774
|
+
const groupName = result.groupId ? groupDisplayName(result.groupId) : '';
|
|
2775
|
+
let response = null;
|
|
2776
|
+
let fallback = '';
|
|
2777
|
+
let announcement = dropAnnouncement(result.action, { title, groupName });
|
|
2778
|
+
if (result.action === 'new-group') {
|
|
2779
|
+
const otherId = String(result.targetConversationId || '').trim();
|
|
2780
|
+
response = await createConversationGroupApi({ name: null, conversationIds: [otherId, id] });
|
|
2781
|
+
fallback = 'The group could not be made.';
|
|
2782
|
+
// Shown open, though nothing in it runs (which would fold it).
|
|
2783
|
+
if (response && response.ok !== false && response.group?.id) convListTransientOpenGroups.add(String(response.group.id));
|
|
2784
|
+
announcement = dropAnnouncement('new-group', { title, otherTitle: conversations[otherId]?.title });
|
|
2785
|
+
} else if (result.action === 'leave') {
|
|
2786
|
+
response = await removeConversationFromGroupApi(id);
|
|
2787
|
+
fallback = 'The conversation could not leave its group.';
|
|
2788
|
+
} else if (result.action === 'reorder' || result.action === 'join') {
|
|
2789
|
+
response = await placeConversationInGroupApi(id, { groupId: result.groupId, beforeConversationId: result.beforeConversationId ?? null });
|
|
2790
|
+
fallback = result.action === 'join' ? 'The conversation could not join that group.' : 'The conversation could not be moved.';
|
|
2791
|
+
if (response && response.ok !== false) convListTransientOpenGroups.add(result.groupId);
|
|
2792
|
+
} else {
|
|
2793
|
+
return;
|
|
2794
|
+
}
|
|
2795
|
+
if (!response || response.ok === false) {
|
|
2796
|
+
if (response) groupActionFailed(response, fallback);
|
|
2797
|
+
scheduleLoadedConversationsRefresh();
|
|
2798
|
+
return;
|
|
2799
|
+
}
|
|
2800
|
+
renderConvList();
|
|
2801
|
+
flashConversationRow(id);
|
|
2802
|
+
pendingConvRowFlash = { id, until: Date.now() + CONV_ROW_FLASH_PENDING_MS };
|
|
2803
|
+
announceConversationList(announcement);
|
|
2804
|
+
}
|
|
2805
|
+
|
|
2806
|
+
/**
|
|
2807
|
+
* Right-click (and long press on touch) on a row opens its menu; a mouse
|
|
2808
|
+
* drag, or a long press that moves, drags it. Bound once; the list's rows
|
|
2809
|
+
* are re-rendered freely.
|
|
2810
|
+
*/
|
|
1959
2811
|
function bindConversationContextMenu(list) {
|
|
1960
2812
|
if (!list || list.dataset.contextMenuBound === '1') return;
|
|
1961
2813
|
list.dataset.contextMenuBound = '1';
|
|
2814
|
+
// Rows move only where their place means something: not in the Archived
|
|
2815
|
+
// view and not under a title filter (the menu entries still work there).
|
|
2816
|
+
const drag = bindConversationListDrag(list, {
|
|
2817
|
+
isEnabled: () => !IS_SHARED_VIEW && !conversationListArchivedView && !normalizeConversationFilter(conversationListFilterText),
|
|
2818
|
+
describeItems: () => lastConvListDragItems,
|
|
2819
|
+
onHold: (held) => holdConvListRender(held),
|
|
2820
|
+
rerender: () => paintConvList(),
|
|
2821
|
+
setDragOpen: (groupId, open) => {
|
|
2822
|
+
if (open) convListTransientOpenGroups.add(groupId);
|
|
2823
|
+
else convListTransientOpenGroups.delete(groupId);
|
|
2824
|
+
},
|
|
2825
|
+
onDrop: (result, conversationId) => { void applyConversationListDrop(result, conversationId); },
|
|
2826
|
+
});
|
|
1962
2827
|
const openFor = (row, x, y) => {
|
|
1963
2828
|
const id = String(row?.dataset?.conversationId || '').trim();
|
|
1964
|
-
|
|
1965
|
-
const items =
|
|
2829
|
+
// A synthetic head row stands for its group: the group's actions only.
|
|
2830
|
+
const items = id
|
|
2831
|
+
? conversationMenuItems(id)
|
|
2832
|
+
: (row?.classList?.contains('conv-group-synthetic') ? groupMenuItems(row.dataset.groupId) : []);
|
|
1966
2833
|
if (items.length) openContextMenu(x, y, items);
|
|
1967
2834
|
};
|
|
1968
2835
|
list.addEventListener('contextmenu', (event) => {
|
|
@@ -1971,7 +2838,17 @@ function bindConversationContextMenu(list) {
|
|
|
1971
2838
|
event.preventDefault();
|
|
1972
2839
|
openFor(row, event.clientX, event.clientY);
|
|
1973
2840
|
});
|
|
1974
|
-
|
|
2841
|
+
// A long press lifts the row: released in place it opens the menu, moved
|
|
2842
|
+
// it becomes a drag. Where rows cannot move (a synthetic head, the
|
|
2843
|
+
// Archived view, a filter) it opens the menu at the delay, as it always did.
|
|
2844
|
+
bindLongPress(list, '.conv-item', (row, x, y) => openFor(row, x, y), {
|
|
2845
|
+
lift: {
|
|
2846
|
+
canLift: (row) => drag.canLift(row),
|
|
2847
|
+
onLift: (row) => drag.lift(row),
|
|
2848
|
+
onMove: (row, x, y, event) => drag.beginTouchDrag(row, x, y, event),
|
|
2849
|
+
onEnd: () => drag.unlift(),
|
|
2850
|
+
},
|
|
2851
|
+
});
|
|
1975
2852
|
}
|
|
1976
2853
|
|
|
1977
2854
|
export async function deleteConv(e, id) {
|
|
@@ -1985,16 +2862,24 @@ export async function deleteConv(e, id) {
|
|
|
1985
2862
|
}
|
|
1986
2863
|
delete conversations[id];
|
|
1987
2864
|
renderConvList();
|
|
1988
|
-
if (currentConvId === id)
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2865
|
+
if (currentConvId === id) clearOpenConversation();
|
|
2866
|
+
}
|
|
2867
|
+
|
|
2868
|
+
/**
|
|
2869
|
+
* The conversation on screen is gone (deleted here or from another device):
|
|
2870
|
+
* nothing is open, the header says so, the transcript shows the empty state
|
|
2871
|
+
* and the ⋯ menu keeps its relay-wide entries. One place for both callers.
|
|
2872
|
+
*/
|
|
2873
|
+
export function clearOpenConversation() {
|
|
2874
|
+
setCurrentConv(null);
|
|
2875
|
+
clearAttachments();
|
|
2876
|
+
setRepoBrowserSessionInfo('', '');
|
|
2877
|
+
restoreInFlightThinking(null);
|
|
2878
|
+
renderMessages([]);
|
|
2879
|
+
const title = document.getElementById('chat-title');
|
|
2880
|
+
if (title) title.textContent = 'Select or start a conversation';
|
|
2881
|
+
window.syncChatTitleControls?.();
|
|
2882
|
+
updateSessionPill(null, null);
|
|
2883
|
+
updateCompactButton();
|
|
2884
|
+
scheduleContextUsageRefresh(null);
|
|
2000
2885
|
}
|