@oar-sh/oar 0.9.10 → 0.9.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/CHANGELOG.md +440 -1
  2. package/README.md +156 -972
  3. package/package.json +13 -13
  4. package/server/README.md +112 -25
  5. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  6. package/server/claude-worker/claude-sdk-adapter.mjs +57 -4
  7. package/server/claude-worker/claude-session-process.mjs +217 -11
  8. package/server/claude-worker/claude-session-worker.mjs +26 -1
  9. package/server/claude-worker/task-output-discovery.mjs +126 -0
  10. package/server/copilot-worker/copilot-sdk-session-process.mjs +235 -14
  11. package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -0
  12. package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
  13. package/server/copilot-worker/copilot-task-settle.mjs +96 -0
  14. package/server/db-schema.mjs +68 -2
  15. package/server/grok-worker/acp-client.mjs +2 -1
  16. package/server/grok-worker/acp-host-services.mjs +46 -6
  17. package/server/grok-worker/grok-background-tasks.mjs +241 -0
  18. package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
  19. package/server/grok-worker/grok-session-worker.mjs +33 -1
  20. package/server/grok-worker/grok-turn-runner.mjs +11 -0
  21. package/server/migrations/0013-previews.mjs +130 -0
  22. package/server/migrations/0014-conversation-groups.mjs +89 -0
  23. package/server/migrations/0015-conversation-group-position.mjs +76 -0
  24. package/server/migrations/0016-share-access-log.mjs +110 -0
  25. package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
  26. package/server/public/app/about-ui.js +73 -0
  27. package/server/public/app/about-view.mjs +234 -0
  28. package/server/public/app/action-confirmations.js +31 -14
  29. package/server/public/app/agent-exchange-fold-view.mjs +40 -101
  30. package/server/public/app/api-client.js +264 -16
  31. package/server/public/app/ask-user-view.js +17 -15
  32. package/server/public/app/background-tasks-view.mjs +285 -16
  33. package/server/public/app/bootstrap.js +253 -37
  34. package/server/public/app/chat-actions-menu-state.mjs +20 -10
  35. package/server/public/app/claude-background-command-limit-options.mjs +21 -0
  36. package/server/public/app/context-menu.mjs +90 -6
  37. package/server/public/app/conversation-group-drag.mjs +191 -0
  38. package/server/public/app/conversation-group-order.mjs +265 -0
  39. package/server/public/app/conversation-group-state.mjs +100 -0
  40. package/server/public/app/conversation-list-drag.mjs +449 -0
  41. package/server/public/app/conversation-payload-cache.mjs +357 -0
  42. package/server/public/app/conversation-turn-state.mjs +81 -0
  43. package/server/public/app/conversation-view.js +567 -94
  44. package/server/public/app/conversation-window-retention.mjs +96 -0
  45. package/server/public/app/cwd-picker.js +1 -1
  46. package/server/public/app/hidden-messages-view.mjs +312 -0
  47. package/server/public/app/host-suspend-ui.js +7 -1
  48. package/server/public/app/journal-view.js +1033 -82
  49. package/server/public/app/preview-cards.mjs +117 -31
  50. package/server/public/app/preview-command.mjs +10 -1
  51. package/server/public/app/relay-board-view.js +19 -6
  52. package/server/public/app/relay-prompt-sanitizer.mjs +11 -4
  53. package/server/public/app/relay-restart-error.mjs +16 -0
  54. package/server/public/app/settings-modal.js +150 -10
  55. package/server/public/app/settings-tabs.js +1 -1
  56. package/server/public/app/share-format.mjs +49 -0
  57. package/server/public/app/share-preview-marks.mjs +167 -0
  58. package/server/public/app/share-state.js +109 -0
  59. package/server/public/app/shared-conversations-settings.js +480 -0
  60. package/server/public/app/socket-handlers.js +138 -20
  61. package/server/public/app/store.js +121 -30
  62. package/server/public/app/subagent-row.mjs +3 -3
  63. package/server/public/app/subagent-viewer.mjs +37 -7
  64. package/server/public/app/task-console-render.mjs +237 -0
  65. package/server/public/app/task-console-watch.mjs +42 -0
  66. package/server/public/app/task-console.mjs +630 -0
  67. package/server/public/app/task-output-text.mjs +234 -0
  68. package/server/public/app/thinking-follow.js +0 -4
  69. package/server/public/app/transcript-island.mjs +73 -0
  70. package/server/public/app/transcript-jump-bottom-view.mjs +230 -26
  71. package/server/public/app/transcript-jump-bottom.mjs +170 -22
  72. package/server/public/app/transcript-reading-anchor.mjs +256 -0
  73. package/server/public/app/transcript-reload-freshness.mjs +17 -1
  74. package/server/public/app/transcript-viewport-anchor.mjs +159 -0
  75. package/server/public/index.html +625 -21
  76. package/server/relay-tools.md +3 -1
  77. package/server/relay.mjs +2 -1
  78. package/server/repositories/conversation-group-repository.mjs +444 -0
  79. package/server/repositories/message-repository.mjs +9 -0
  80. package/server/repositories/question-repository.mjs +4 -0
  81. package/server/repositories/session-repository.mjs +12 -37
  82. package/server/repositories/share-repository.mjs +322 -0
  83. package/server/repositories/share-visibility-statements.mjs +10 -0
  84. package/server/routes/about-routes.mjs +15 -0
  85. package/server/routes/cache-routes.mjs +4 -0
  86. package/server/routes/conversation-group-routes.mjs +171 -0
  87. package/server/routes/messages-routes.mjs +433 -68
  88. package/server/routes/preview-routes.mjs +9 -1
  89. package/server/routes/sessions-routes.mjs +901 -253
  90. package/server/server-runtime.mjs +392 -99
  91. package/server/services/about-service.mjs +461 -0
  92. package/server/services/background-task-store.mjs +365 -0
  93. package/server/services/background-work-view.mjs +228 -0
  94. package/server/services/cache-rebuild-service.mjs +30 -0
  95. package/server/services/cli-process-runner.mjs +4 -21
  96. package/server/services/conversation-history-page.mjs +199 -0
  97. package/server/services/delete-archive-service.mjs +4 -1
  98. package/server/services/detached-worker-watch-service.mjs +150 -0
  99. package/server/services/hidden-message-list.mjs +21 -0
  100. package/server/services/host-suspend-command.mjs +2 -1
  101. package/server/services/message-pin-list.mjs +1 -1
  102. package/server/services/preview-health-probe.mjs +8 -1
  103. package/server/services/preview-proxy-server.mjs +8 -4
  104. package/server/services/preview-registry-service.mjs +676 -26
  105. package/server/services/preview-static-handler.mjs +40 -7
  106. package/server/services/preview-store.mjs +274 -0
  107. package/server/services/rate-limit-service.mjs +12 -1
  108. package/server/services/relay-auth.mjs +183 -0
  109. package/server/services/relay-cli-launcher-service.mjs +3 -1
  110. package/server/services/relay-event-service.mjs +17 -0
  111. package/server/services/relay-prompt-sanitizer.mjs +11 -4
  112. package/server/services/remote-relay-dispatcher.mjs +187 -7
  113. package/server/services/request-boundary.mjs +183 -0
  114. package/server/services/session-activity-tracker.mjs +231 -19
  115. package/server/services/session-worker-launch-service.mjs +190 -6
  116. package/server/services/session-worker-process-service.mjs +10 -6
  117. package/server/services/share-access-log-service.mjs +123 -0
  118. package/server/services/share-path-redaction.mjs +424 -0
  119. package/server/services/status-event-service.mjs +7 -3
  120. package/server/services/task-output-rooms.mjs +259 -0
  121. package/server/services/task-output-service.mjs +562 -0
  122. package/server/services/tunnel-worker-path-guard.mjs +24 -41
  123. package/server/services/windows-autostart-service.mjs +6 -1
  124. package/server/services/windows-boot-autostart-service.mjs +207 -23
  125. package/shared/claude-background-command-limit.mjs +83 -0
  126. package/shared/preview-tool-core.mjs +15 -5
  127. package/shared/remote-relay-contract.mjs +35 -3
  128. package/shared/share-path-privacy.mjs +30 -0
  129. package/shared/task-output-text.mjs +737 -0
  130. package/shared/windows-system-tools.mjs +106 -0
  131. package/shared/worker-runtime/background-task-digest.mjs +30 -0
  132. package/shared/worker-runtime/heartbeat.mjs +24 -0
  133. package/shared/worker-runtime/kept-posts.mjs +83 -0
  134. package/shared/worker-runtime/task-output-spool.mjs +181 -0
  135. package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
@@ -19,21 +19,48 @@ import {
19
19
  applyContextUsageBar,
20
20
  isMobileComposerViewport,
21
21
  releaseComposerFocusAfterSend,
22
- loadConversationScrollTop,
23
- loadConversationLoadedMessageCount,
24
22
  saveConversationScrollTop,
23
+ loadConversationReadingAnchor,
24
+ saveConversationReadingAnchor,
25
+ isMessagesNearBottom,
25
26
  IS_SHARED_VIEW,
26
27
  openSummaryModal,
27
28
  closeSummaryModal,
28
29
  setSummaryModalLeaveHandler,
30
+ conversationGroups,
31
+ mergeConversationGroups,
32
+ readConversationGroupFoldChoices,
33
+ pruneConversationGroups,
34
+ hasSessionWorkerStatus,
35
+ setConversationGroupFoldChoice,
29
36
  } from './store.js';
30
37
  import { archiveConfirmationFor } from './archive-confirm.mjs';
38
+ import { arrangeConversationList, conversationGroupPosition } from './conversation-group-order.mjs';
39
+ import { describeDragItems, dropAnnouncement, groupMoveStep } from './conversation-group-drag.mjs';
40
+ import { bindConversationListDrag } from './conversation-list-drag.mjs';
41
+ import { applyFetchedTurnStateToConversation } from './conversation-turn-state.mjs';
42
+ import {
43
+ groupIsActive,
44
+ groupSummaryText,
45
+ memberStateClass,
46
+ resolveGroupFold,
47
+ summarizeGroupMembers,
48
+ toggledGroupFoldChoice,
49
+ } from './conversation-group-state.mjs';
31
50
  import {
32
51
  loadConversations as loadConversationsApi,
33
52
  loadConversation,
34
53
  deleteConversation as deleteConversationApi,
35
54
  archiveConversation as archiveConversationApi,
36
55
  unarchiveConversation as unarchiveConversationApi,
56
+ createConversationGroup as createConversationGroupApi,
57
+ renameConversationGroup as renameConversationGroupApi,
58
+ moveConversationToGroup as moveConversationToGroupApi,
59
+ placeConversationInGroup as placeConversationInGroupApi,
60
+ removeConversationFromGroup as removeConversationFromGroupApi,
61
+ dissolveConversationGroup as dissolveConversationGroupApi,
62
+ archiveConversationGroup as archiveConversationGroupApi,
63
+ stopConversationGroup as stopConversationGroupApi,
37
64
  bootstrapConversationSession,
38
65
  scheduleContextUsageRefresh,
39
66
  loadModelCatalog,
@@ -49,10 +76,19 @@ import {
49
76
  import { isSetupRequired, updateProviderReadinessInputs } from './provider-readiness.mjs';
50
77
  import { SETUP_TEXT } from './no-conversation-state.mjs';
51
78
  import { openSettingsModal } from './settings-modal.js';
52
- import { renderMessages, renderNoConversationState, restoreInFlightThinking, focusConversationMessageById, noteTranscriptJump, flushConversationDraft, hydrateConversationDraft, beginConversationDraftSwitch, openStopTurnConfirmationForConversation, conversationHasActiveTurn } from './conversation-view.js';
79
+ import { captureReadingBlock, restoreReadingBlock } from './transcript-viewport-anchor.mjs';
80
+ import { renderMessages, renderNoConversationState, restoreInFlightThinking, focusConversationMessageById, noteTranscriptJump, getTranscriptJumpEpoch, getTranscriptLiveRowEpoch, flushConversationDraft, hydrateConversationDraft, beginConversationDraftSwitch, openStopTurnConfirmationForConversation, conversationHasActiveTurn, captureConversationReadingAnchor, restoreConversationReadingAnchor, detachConversationWindowFromTail, mergeMessagesIntoTranscript, isConversationWindowAtTail } from './conversation-view.js';
81
+ import { mergeTailPage } from './transcript-reading-anchor.mjs';
82
+ import { readWindowRetention } from './conversation-window-retention.mjs';
53
83
  import { MENU_SEPARATOR, bindLongPress, closeContextMenu, openContextMenu } from './context-menu.mjs';
54
- import { setBackgroundTasksConversation, setConversationBackgroundTasks } from './background-tasks-view.mjs';
84
+ import {
85
+ setBackgroundTasksConversation,
86
+ setConversationBackgroundTasks,
87
+ setConversationEndedBackgroundTasks,
88
+ setConversationTaskStallMinutes,
89
+ } from './background-tasks-view.mjs';
55
90
  import { setConversationPins, setPinsConversation } from './pinned-messages-view.mjs';
91
+ import { setConversationHiddenMessages, setHiddenMessagesConversation } from './hidden-messages-view.mjs';
56
92
  import { setUsageLimitConversation } from './usage-limit-ui.js';
57
93
  import { mergeConversationPreviews } from './preview-cards.mjs';
58
94
  import { loadRelayQuestions, getPendingQuestionCountsByConversation } from './ask-user-view.js';
@@ -60,6 +96,8 @@ import { loadRelayBoards } from './relay-board-view.js';
60
96
  import { clearAttachments, setRepoBrowserSessionInfo, loadRepoBrowserTree, getRepoBrowserLaunchCwdPath, openRepoBrowserForCwdPick } from './attachments-view.js';
61
97
  import { buildKnownCwdOptions, normalizeKnownCwdPath } from './known-cwd-options.mjs';
62
98
  import { shouldApplyConversationLoad } from './activity-replay-state.mjs';
99
+ import { conversationPayloadCache, payloadHasMoreOlder, windowPageInfo } from './conversation-payload-cache.mjs';
100
+ import { loadKeepingLiveRows, shouldKeepTranscriptOnReload } from './transcript-reload-freshness.mjs';
63
101
  import { createInfiniteLoader } from './infinite-loader.js';
64
102
  import {
65
103
  buildNewConversationModelChoices,
@@ -95,13 +133,12 @@ import { renderConversationOriginMarkerHtml } from './remote-relay-origin-view.m
95
133
  import { relayEventWatchedMarkerHtml } from './relay-event-subscriptions-ui.js';
96
134
  import {
97
135
  normalizeConversationFilter,
136
+ conversationMatchesFilter,
98
137
  filterConversations,
99
138
  drainRemainingPages,
100
139
  describeFilterMatchCount,
101
140
  } from './conversation-list-filter.mjs';
102
141
 
103
- const PROCESSING_DOT_FRAMES = [' ', '. ', '.. ', '...'];
104
- const PROCESSING_DOT_INTERVAL_MS = 1000;
105
142
  const LOCAL_PROCESSING_STALE_MS = 5 * 60 * 1000;
106
143
  const CONVERSATION_LIST_PAGE_SIZE = 40;
107
144
  const REASONING_STORAGE_KEY = 'copilot_selected_reasoning_effort';
@@ -116,9 +153,16 @@ const OPENAI_IMAGE_SIZE_STORAGE_KEY = 'copilot_openai_image_size';
116
153
  const NEW_CHAT_CWD_STORAGE_KEY = 'copilot_new_chat_cwd';
117
154
  const NEW_CHAT_CUSTOM_CWD_VALUE = '__custom__';
118
155
  const CONV_FILTER_DEBOUNCE_MS = 220;
119
- let processingDotFrame = 0;
120
- let processingDotTimer = null;
121
156
  let lastConvListHtml = '';
157
+ // The list as last rendered, as drag items (conversation-group-drag.mjs).
158
+ let lastConvListDragItems = [];
159
+ // While a row is lifted or dragged the list is not re-rendered: what arrives
160
+ // meanwhile is applied to the records and drawn once the row is let go.
161
+ let convListRenderHeld = false;
162
+ let convListRenderDeferred = false;
163
+ // Groups held open without a stored choice: unfolded by hovering a drag over
164
+ // them, or the target of a drop. Until the page reloads or the user folds them.
165
+ const convListTransientOpenGroups = new Set();
122
166
  let conversationListFilterText = '';
123
167
  let conversationFilterDrainActive = false;
124
168
  let conversationFilterGeneration = 0;
@@ -165,6 +209,8 @@ let newConversationCloudBranchTimer = null;
165
209
  let newConversationCloudBranchAutoFill = '';
166
210
  const newConversationCloudSuggestActive = { repo: -1, branch: -1 };
167
211
  const NEW_CHAT_CLOUD_SUGGEST_BLUR_MS = 150;
212
+ // Rows the fetches since the last page-one refresh brought (refreshLoadedConversations).
213
+ let conversationRowsFetchedSinceRefresh = 0;
168
214
  let conversationListBoundaryCheckFrame = 0;
169
215
  let conversationListAutoLoadBlockedUntil = 0;
170
216
  let conversationListPaginationState = {
@@ -184,7 +230,7 @@ function mergeConversationRecord(current, next) {
184
230
  // localTurnStatus is optimistic client state driven by one-shot message_status
185
231
  // socket events. If the socket drops between a turn finishing and the event
186
232
  // being delivered — a relay restart is the obvious case — the flag would stay
187
- // 'processing' until it aged out, leaving the list spinner running forever.
233
+ // 'processing' until it aged out, leaving the list row marked as working forever.
188
234
  // The server's activeTurn flag is authoritative, so let it clear the flag.
189
235
  if (next && typeof next === 'object' && next.activeTurn === false) {
190
236
  delete merged.localTurnStatus;
@@ -198,6 +244,9 @@ function upsertConversationRecord(record) {
198
244
  if (!id) return false;
199
245
  const hadExistingRecord = !!conversations[id];
200
246
  conversations[id] = mergeConversationRecord(conversations[id], record);
247
+ // The relay's view of the session (state and live task set) is the truth
248
+ // whenever a payload arrives; the events move it forward in between.
249
+ applyFetchedTurnStateToConversation(conversations[id], record);
201
250
  return !hadExistingRecord;
202
251
  }
203
252
 
@@ -238,6 +287,7 @@ const conversationListLoader = createInfiniteLoader({
238
287
  archived: conversationListArchivedView ? 'only' : '',
239
288
  });
240
289
  if (!response) throw new Error('Could not load conversations');
290
+ mergeConversationGroups(response.groups);
241
291
  return {
242
292
  items: response.conversations || [],
243
293
  hasMore: !!response.pageInfo?.hasMore,
@@ -246,6 +296,7 @@ const conversationListLoader = createInfiniteLoader({
246
296
  },
247
297
  applyPage: async (page) => {
248
298
  applyConversationPage(page.items);
299
+ conversationRowsFetchedSinceRefresh += Array.isArray(page.items) ? page.items.length : 0;
249
300
  conversationListPaginationState.hasLoadedOlderPages = true;
250
301
  },
251
302
  onError: (error) => {
@@ -277,6 +328,13 @@ function resetConversationListPageState(pageInfo = null, { preserveProgress = fa
277
328
  scheduleConversationListBoundaryCheck();
278
329
  }
279
330
 
331
+ const LIVE_WORKER_STATUSES = new Set(['starting', 'ready', 'processing']);
332
+
333
+ /** A worker entry from the status poll that stands for a live process. */
334
+ function isWorkerStateLive(workerState) {
335
+ return !!workerState && LIVE_WORKER_STATUSES.has(String(workerState?.status || '').trim().toLowerCase());
336
+ }
337
+
280
338
  function isConversationProcessing(conversation, workerState) {
281
339
  const workerStatus = String(workerState?.status || '').trim().toLowerCase();
282
340
  if (workerStatus === 'processing') return true;
@@ -296,27 +354,6 @@ function isConversationProcessing(conversation, workerState) {
296
354
  return runtimeStatus === 'processing';
297
355
  }
298
356
 
299
- function ensureProcessingDotTimer(enabled) {
300
- if (enabled) {
301
- if (processingDotTimer) return;
302
- processingDotTimer = setInterval(() => {
303
- processingDotFrame = (processingDotFrame + 1) % PROCESSING_DOT_FRAMES.length;
304
- // Touch only the dot spans: rewriting the whole list every second kills
305
- // sidebar selections and burns battery for a spinner frame.
306
- const frame = PROCESSING_DOT_FRAMES[processingDotFrame];
307
- document.querySelectorAll('#conv-list .conv-processing-dots').forEach((el) => {
308
- el.textContent = ` ${frame}`;
309
- });
310
- }, PROCESSING_DOT_INTERVAL_MS);
311
- return;
312
- }
313
- if (processingDotTimer) {
314
- clearInterval(processingDotTimer);
315
- processingDotTimer = null;
316
- }
317
- processingDotFrame = 0;
318
- }
319
-
320
357
  export async function loadConversations() {
321
358
  await refreshConversations({ preservePagination: false });
322
359
  const lastId = localStorage.getItem('copilot_last_conv');
@@ -340,7 +377,12 @@ export async function refreshConversations(options = {}) {
340
377
  if (!knownConversationIds.has(id)) delete conversations[id];
341
378
  }
342
379
  }
380
+ mergeConversationGroups(r.groups);
343
381
  applyConversationPage(r.conversations || []);
382
+ conversationRowsFetchedSinceRefresh = Array.isArray(r.conversations) ? r.conversations.length : 0;
383
+ // A group no loaded row belongs to any more (dissolved, or its rows left)
384
+ // is forgotten: the picker offers the groups that exist.
385
+ pruneConversationGroups(Object.values(conversations).map((conversation) => conversation?.groupId));
344
386
  resetConversationListPageState(r.pageInfo || null, {
345
387
  preserveProgress: preservePagination,
346
388
  });
@@ -355,7 +397,120 @@ export async function refreshConversations(options = {}) {
355
397
  }
356
398
  }
357
399
 
358
- /** A sidebar row's state: its colour (green is 'ready') and whether its dots run. */
400
+ /**
401
+ * Fetches every loaded page again: page one, then as many older pages as
402
+ * the list had, so a change on a row far down the list (a group action on
403
+ * an old chat, a reconnect) shows without a reload. One refresh runs at a
404
+ * time: a call while one runs marks it to run once more afterwards, so the
405
+ * last state always lands and two refreshes never interleave their pages.
406
+ */
407
+ let loadedConversationsRefreshRunning = null;
408
+ let loadedConversationsRefreshAgain = false;
409
+ export function refreshLoadedConversations() {
410
+ if (loadedConversationsRefreshRunning) {
411
+ loadedConversationsRefreshAgain = true;
412
+ return loadedConversationsRefreshRunning;
413
+ }
414
+ loadedConversationsRefreshRunning = (async () => {
415
+ try {
416
+ do {
417
+ loadedConversationsRefreshAgain = false;
418
+ await refreshLoadedConversationsOnce();
419
+ } while (loadedConversationsRefreshAgain);
420
+ } finally {
421
+ loadedConversationsRefreshRunning = null;
422
+ }
423
+ })();
424
+ return loadedConversationsRefreshRunning;
425
+ }
426
+
427
+ async function refreshLoadedConversationsOnce() {
428
+ const loadedBefore = Object.values(conversations)
429
+ .filter((conversation) => (conversation?.archived === true) === conversationListArchivedView).length;
430
+ // Rows of pages not fetched again stay in the map as they were, so the
431
+ // pages are counted by what the fetches bring, not by what the map holds.
432
+ await refreshConversations({ preservePagination: false });
433
+ let guard = 0;
434
+ while (conversationRowsFetchedSinceRefresh < loadedBefore && conversationListLoader.getState().hasMore && guard < 50) {
435
+ guard += 1;
436
+ const before = conversationRowsFetchedSinceRefresh;
437
+ await conversationListLoader.loadMore();
438
+ if (conversationRowsFetchedSinceRefresh === before) break;
439
+ }
440
+ pruneConversationGroups(Object.values(conversations).map((conversation) => conversation?.groupId));
441
+ renderConvList();
442
+ }
443
+
444
+ // Several events within a moment (a group action touches its members one by
445
+ // one) give one refresh.
446
+ const LOADED_REFRESH_COALESCE_MS = 150;
447
+ let loadedConversationsRefreshTimer = null;
448
+ export function scheduleLoadedConversationsRefresh() {
449
+ if (loadedConversationsRefreshTimer) return;
450
+ loadedConversationsRefreshTimer = setTimeout(() => {
451
+ loadedConversationsRefreshTimer = null;
452
+ refreshLoadedConversations().catch(() => {});
453
+ }, LOADED_REFRESH_COALESCE_MS);
454
+ }
455
+
456
+ /**
457
+ * A `conversation_group_changed` event. What it carries is applied in
458
+ * place: the group's summary (or its end) and the named conversations'
459
+ * membership. A fetch is scheduled only when the event names a conversation
460
+ * this page has not loaded (it may now belong under a loaded head), or
461
+ * carries no detail (an older relay).
462
+ */
463
+ export function applyConversationGroupEvent(payload) {
464
+ const groupId = String(payload?.groupId || '').trim();
465
+ const memberships = payload?.memberships && typeof payload.memberships === 'object' ? payload.memberships : null;
466
+ if (!groupId || !memberships) {
467
+ scheduleLoadedConversationsRefresh();
468
+ return 'fetch';
469
+ }
470
+ if (payload.group && typeof payload.group === 'object') {
471
+ mergeConversationGroups({ [groupId]: payload.group });
472
+ } else {
473
+ delete conversationGroups[groupId];
474
+ }
475
+ let needsFetch = false;
476
+ for (const [conversationId, membership] of Object.entries(memberships)) {
477
+ const record = conversations[conversationId];
478
+ if (!record) {
479
+ needsFetch = true;
480
+ continue;
481
+ }
482
+ record.groupId = membership?.groupId ? String(membership.groupId) : null;
483
+ record.groupRole = record.groupId ? (membership?.role === 'head' ? 'head' : 'member') : null;
484
+ if (!record.groupId) record.groupPosition = null;
485
+ }
486
+ applyConversationGroupPositions(payload.positions);
487
+ pruneConversationGroups(Object.values(conversations).map((conversation) => conversation?.groupId));
488
+ renderConvList();
489
+ if (pendingConvRowFlash && Date.now() < pendingConvRowFlash.until && Object.hasOwn(memberships, pendingConvRowFlash.id)) {
490
+ flashConversationRow(pendingConvRowFlash.id);
491
+ pendingConvRowFlash = null;
492
+ }
493
+ if (needsFetch) scheduleLoadedConversationsRefresh();
494
+ return needsFetch ? 'fetch' : 'in-place';
495
+ }
496
+
497
+ /**
498
+ * The hand order of a group's rows (`{ [conversationId]: number | null }`),
499
+ * as the relay announces it. An older relay sends none.
500
+ */
501
+ function applyConversationGroupPositions(positions) {
502
+ if (!positions || typeof positions !== 'object') return;
503
+ for (const [conversationId, position] of Object.entries(positions)) {
504
+ const record = conversations[conversationId];
505
+ if (!record) continue;
506
+ record.groupPosition = conversationGroupPosition({ groupPosition: position });
507
+ }
508
+ }
509
+
510
+ /**
511
+ * A sidebar row's state: its colour (green is 'ready') and whether it works
512
+ * (a turn or background work), which keeps a ready title bright green.
513
+ */
359
514
  function conversationRowState(conversation, pendingByConversation = getPendingQuestionCountsByConversation()) {
360
515
  const pendingCount = Number(pendingByConversation[conversation?.id] || 0);
361
516
  const sdkSessionId = String(conversation?.sdkSessionId || '').trim();
@@ -365,22 +520,114 @@ function conversationRowState(conversation, pendingByConversation = getPendingQu
365
520
  workerState,
366
521
  hasPendingQuestion: pendingCount > 0,
367
522
  });
368
- return { visualState, processing: isConversationProcessing(conversation, workerState) };
523
+ const processing = isConversationProcessing(conversation, workerState)
524
+ || String(conversation?.sessionState || '').trim() === 'running_turn';
525
+ // Background work lives in the session's worker: once the worker status is
526
+ // known, a session without a live worker has none, whatever the last
527
+ // event or fetch said (the worker was killed, or the relay restarted).
528
+ let sessionState = String(conversation?.sessionState || '').trim();
529
+ if (sessionState === 'background_only' && hasSessionWorkerStatus() && !isWorkerStateLive(workerState)) sessionState = 'idle';
530
+ return {
531
+ visualState,
532
+ processing,
533
+ working: processing || sessionState === 'background_only',
534
+ pendingCount,
535
+ // A turn or background work, whether or not a card waits in it: what
536
+ // "Stop all running" acts on, and what keeps an archive from it.
537
+ live: processing || pendingCount > 0 || conversation?.activeTurn === true || sessionState === 'running_turn' || sessionState === 'background_only',
538
+ // The member class the group's head row sums up (conversation-group-state.mjs).
539
+ memberState: memberStateClass({
540
+ pendingCount,
541
+ visualState,
542
+ processing,
543
+ activeTurn: conversation?.activeTurn === true,
544
+ sessionState,
545
+ }),
546
+ };
547
+ }
548
+
549
+ /** Whether the conversation works now: the chat header's title follows the row's. */
550
+ export function isConversationWorking(conversation) {
551
+ return !!conversation && conversationRowState(conversation).working;
552
+ }
553
+
554
+ /** The group's head and its name for the picker and the menus. */
555
+ function groupDisplayName(groupId) {
556
+ const info = conversationGroups[groupId] || null;
557
+ const head = info?.parentConversationId ? conversations[info.parentConversationId] : null;
558
+ return String(info?.name || head?.title || info?.displayName || 'Group').trim() || 'Group';
559
+ }
560
+
561
+ /** The loaded rows of a group, in the current view and across it. */
562
+ function groupMemberRows(groupId, { inViewOnly = true } = {}) {
563
+ return Object.values(conversations).filter((conversation) => String(conversation?.groupId || '') === groupId
564
+ && (!inViewOnly || (conversation?.archived === true) === conversationListArchivedView));
565
+ }
566
+
567
+ /**
568
+ * What a head row shows for its group: the classes of every row in it, the
569
+ * head included (the menu's "Archive group (N)" and "Stop all running (N)"
570
+ * count the same rows), their summary, and the first row a waiting card
571
+ * sits in.
572
+ */
573
+ function describeGroupForHead(group, viewOf) {
574
+ const rows = group.head ? [group.head, ...group.members] : [...group.members];
575
+ const states = rows.map((row) => viewOf(row).memberState);
576
+ const summary = summarizeGroupMembers(states);
577
+ const firstWaiting = rows.find((row) => viewOf(row).memberState === 'waiting') || null;
578
+ return { summary, firstWaiting, forcedOpen: false };
369
579
  }
370
580
 
371
581
  export function renderConvList() {
582
+ if (convListRenderHeld) {
583
+ convListRenderDeferred = true;
584
+ return;
585
+ }
586
+ paintConvList();
587
+ }
588
+
589
+ function holdConvListRender(held) {
590
+ convListRenderHeld = held === true;
591
+ if (convListRenderHeld || !convListRenderDeferred) return;
592
+ convListRenderDeferred = false;
593
+ paintConvList();
594
+ }
595
+
596
+ function paintConvList() {
372
597
  const list = document.getElementById('conv-list');
373
- const sorted = Object.values(conversations).sort((a, b) => {
374
- const updatedAtDelta = parseTimestampMs(b.updatedAt) - parseTimestampMs(a.updatedAt);
375
- if (updatedAtDelta !== 0) return updatedAtDelta;
376
- return String(b?.id || '').trim().localeCompare(String(a?.id || '').trim());
377
- });
378
598
  const activeFilter = normalizeConversationFilter(conversationListFilterText);
379
599
  // The live list and the Archived view are two views of the same records.
380
- const inView = sorted.filter((conversation) => (conversation?.archived === true) === conversationListArchivedView);
381
- const visible = activeFilter ? filterConversations(inView, activeFilter) : inView;
600
+ const inView = Object.values(conversations).filter((conversation) => (conversation?.archived === true) === conversationListArchivedView);
382
601
  const pendingByConversation = getPendingQuestionCountsByConversation();
383
- let hasProcessingConversation = false;
602
+ const rowViews = new Map();
603
+ const viewOf = (conversation) => {
604
+ const id = String(conversation?.id || '');
605
+ if (!rowViews.has(id)) rowViews.set(id, conversationRowState(conversation, pendingByConversation));
606
+ return rowViews.get(id);
607
+ };
608
+ const foldChoices = readConversationGroupFoldChoices();
609
+ const groupDescriptions = new Map();
610
+ // Groups order with the same rules the list route pages by; a group is
611
+ // folded once nothing in it runs or waits, unless the user chose otherwise.
612
+ const items = arrangeConversationList(inView, conversationGroups, {
613
+ matches: activeFilter ? (row) => conversationMatchesFilter(row, activeFilter) : null,
614
+ groupNameMatches: activeFilter ? (info) => conversationMatchesFilter({ title: info?.displayName || info?.name }, activeFilter) : null,
615
+ isOpen: (groupId, group) => {
616
+ const described = describeGroupForHead(group, viewOf);
617
+ const holdsOpenConversation = !!currentConvId && group.members.some((row) => row.id === currentConvId);
618
+ // Open whatever the choice: the fold button then says so and does nothing.
619
+ described.forcedOpen = holdsOpenConversation || !!activeFilter;
620
+ groupDescriptions.set(groupId, described);
621
+ if (convListTransientOpenGroups.has(groupId)) return true;
622
+ return resolveGroupFold({
623
+ choice: foldChoices[groupId] || null,
624
+ active: groupIsActive(described.summary),
625
+ holdsOpenConversation,
626
+ filterActive: !!activeFilter,
627
+ });
628
+ },
629
+ });
630
+ lastConvListDragItems = describeDragItems(items);
384
631
  const footerHtml = (() => {
385
632
  if (activeFilter) {
386
633
  // While a filter is active the drain loads older pages by itself; a
@@ -398,8 +645,7 @@ export function renderConvList() {
398
645
  }
399
646
  return '';
400
647
  })();
401
- if (visible.length === 0) {
402
- ensureProcessingDotTimer(false);
648
+ if (items.length === 0) {
403
649
  const emptyText = activeFilter && inView.length > 0
404
650
  ? 'No conversations match your filter'
405
651
  : (conversationListArchivedView ? 'No archived conversations' : 'No conversations yet');
@@ -408,14 +654,28 @@ export function renderConvList() {
408
654
  scheduleConversationListBoundaryCheck();
409
655
  return;
410
656
  }
411
- const conversationView = (conversation) => {
412
- const view = conversationRowState(conversation, pendingByConversation);
413
- if (view.processing) hasProcessingConversation = true;
414
- return view;
657
+ // The head row's pill: the member count and what the members are doing;
658
+ // while folded, its "waiting" part opens the member the card sits in.
659
+ const groupPillHtml = (item) => {
660
+ const described = groupDescriptions.get(item.group.id) || describeGroupForHead(item.group, viewOf);
661
+ const text = groupSummaryText(described.summary);
662
+ if (!text) return '';
663
+ const waitingId = described.firstWaiting ? String(described.firstWaiting.id) : '';
664
+ // Inline like the row's own buttons: the row's click handler must not
665
+ // run (it would open the head and re-render the list first).
666
+ // The full text is the title: a narrow sidebar shortens the pill with an ellipsis.
667
+ const title = waitingId ? `${text} · open the session that is waiting for you` : text;
668
+ 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>`;
415
669
  };
416
- const listHtml = `${visible.map((c) => {
417
- const view = conversationView(c);
418
- const processingDots = view.processing ? PROCESSING_DOT_FRAMES[processingDotFrame] : '';
670
+ const foldButtonHtml = (item) => {
671
+ const forced = (groupDescriptions.get(item.group.id) || {}).forcedOpen === true || item.group.contextOnly === true || !!activeFilter;
672
+ const label = forced
673
+ ? (activeFilter ? 'Open while the filter is on' : 'Open while its chat is open')
674
+ : (item.group.open ? 'Fold group' : 'Unfold group');
675
+ 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>`;
676
+ };
677
+ const rowHtml = (c, item) => {
678
+ const view = viewOf(c);
419
679
  const providerIndicatorLabel = conversationProviderIndicatorLabel(c);
420
680
  const providerIndicatorKey = conversationProviderIndicatorKey(c);
421
681
  const providerIndicatorHtml = providerIndicatorLabel
@@ -423,45 +683,132 @@ export function renderConvList() {
423
683
  : '';
424
684
  // "↗ win-test": another relay's agent created this conversation.
425
685
  // "via agent · “title”": an agent in that conversation on this relay did.
426
- const originMarkerHtml = renderConversationOriginMarkerHtml(c, { conversations });
686
+ // A member under its creator's head row says that already, so it is left out there.
687
+ const originMarkerHtml = item.kind === 'member' && item.group.head && c.origin?.local === true && c.origin.conversationId === item.group.head.id
688
+ ? ''
689
+ : renderConversationOriginMarkerHtml(c, { conversations });
427
690
  // "watched": a session another conversation watches by id (relay events).
428
691
  const watchedMarkerHtml = relayEventWatchedMarkerHtml(c.id);
692
+ const groupClass = item.kind === 'head' ? ' conv-group-head' : (item.kind === 'member' ? ' conv-group-member' : '');
693
+ const groupAttr = item.kind === 'head' || item.kind === 'member' ? ` data-group-id="${escHtml(item.group.id)}"` : '';
694
+ // A head row shows the name the group was given by hand; until then the
695
+ // conversation's own title (the group's name follows it).
696
+ const handName = item.kind === 'head' ? String(item.group.info?.name || '').trim() : '';
697
+ const rowTitle = handName || c.title;
698
+ const titleAttr = handName && handName !== c.title ? ` title="${escHtml(`${handName} · ${c.title}`)}"` : '';
429
699
  return `
430
- <div class="conv-item worker-ui-${view.visualState}${c.id === currentConvId ? ' active' : ''}${c.archived ? ' archived' : ''}" data-conversation-id="${c.id}" onclick="openConversation('${c.id}')">
431
- <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>
432
- <div class="conv-meta"><span class="conv-meta-primary">${fmtDate(c.updatedAt)} · ${c.messageCount} msg${c.messageCount !== 1 ? 's' : ''}</span>${originMarkerHtml}${watchedMarkerHtml}${providerIndicatorHtml}</div>
700
+ <div class="conv-item worker-ui-${view.visualState}${view.working ? ' conv-working' : ''}${c.id === currentConvId ? ' active' : ''}${c.archived ? ' archived' : ''}${groupClass}" data-conversation-id="${c.id}"${groupAttr} onclick="openConversation('${c.id}')">
701
+ <div class="conv-title"${titleAttr}>${item.kind === 'head' ? foldButtonHtml(item) : ''}${escHtml(rowTitle)}${pendingByConversation[c.id] ? ` <span class="conv-open-questions">${pendingByConversation[c.id]} open</span>` : ''}</div>
702
+ <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>
433
703
  ${c.archived
434
704
  ? `<button class="conv-archive" onclick="unarchiveConv(event,'${c.id}')" title="Unarchive" aria-label="Unarchive">📂</button>`
435
705
  : `<button class="conv-archive" onclick="archiveConv(event,'${c.id}')" title="Archive" aria-label="Archive">🗄</button>`}
436
706
  <button class="conv-delete" onclick="deleteConv(event,'${c.id}')" title="Delete">🗑</button>
437
707
  </div>`;
438
- }).join('')}${footerHtml}`;
708
+ };
709
+ // A group whose head is not in this view (made by hand, or its head is
710
+ // archived or deleted): a head line of its own, with the name and the pill.
711
+ const syntheticHeadHtml = (item) => {
712
+ const name = groupDisplayName(item.group.id);
713
+ const described = groupDescriptions.get(item.group.id) || describeGroupForHead(item.group, viewOf);
714
+ return `
715
+ <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)}')">
716
+ <div class="conv-title">${foldButtonHtml(item)}${escHtml(name)}</div>
717
+ <div class="conv-meta"><span class="conv-meta-primary">group</span>${groupPillHtml(item)}</div>
718
+ </div>`;
719
+ };
720
+ const listHtml = `${items.map((item) => (item.conversation ? rowHtml(item.conversation, item) : syntheticHeadHtml(item))).join('')}${footerHtml}`;
439
721
  if (listHtml !== lastConvListHtml) {
440
722
  list.innerHTML = listHtml;
441
723
  lastConvListHtml = listHtml;
442
724
  }
443
725
  bindConversationContextMenu(list);
444
- ensureProcessingDotTimer(hasProcessingConversation);
726
+ bindConversationGroupControls(list);
445
727
  window.syncChatTitleControls?.();
446
728
  scheduleConversationListBoundaryCheck();
447
729
  }
448
730
 
731
+ /**
732
+ * Keyboard access to the waiting pill (its click is inline, like the rows'
733
+ * buttons; the fold is a real button). Bound once on the list.
734
+ */
735
+ function bindConversationGroupControls(list) {
736
+ if (!list || list.dataset.groupControlsBound === '1') return;
737
+ list.dataset.groupControlsBound = '1';
738
+ list.addEventListener('keydown', (event) => {
739
+ if (event.key !== 'Enter' && event.key !== ' ') return;
740
+ const waiting = event.target?.closest?.('[data-open-waiting]');
741
+ if (!waiting || !list.contains(waiting)) return;
742
+ event.preventDefault();
743
+ event.stopPropagation();
744
+ void (window.openConversation || openConversation)(waiting.dataset.openWaiting);
745
+ });
746
+ }
747
+
748
+ /**
749
+ * Folds or unfolds a group; the choice is kept until the user changes it. A
750
+ * group held open (its chat is open, or a filter is on) keeps its choice.
751
+ * The fold button keeps the focus across the re-render.
752
+ */
753
+ export function toggleConversationGroupFold(event, groupId) {
754
+ event?.stopPropagation?.();
755
+ event?.preventDefault?.();
756
+ const id = String(groupId || '').trim();
757
+ if (!id) return;
758
+ const button = document.querySelector(`#conv-list [data-group-fold="${CSS.escape(id)}"]`);
759
+ if (button?.disabled) return;
760
+ const currentlyOpen = button ? button.getAttribute('aria-expanded') === 'true' : true;
761
+ convListTransientOpenGroups.delete(id);
762
+ setConversationGroupFoldChoice(id, toggledGroupFoldChoice(currentlyOpen));
763
+ renderConvList();
764
+ document.querySelector(`#conv-list [data-group-fold="${CSS.escape(id)}"]`)?.focus?.({ preventScroll: true });
765
+ }
766
+
449
767
  // keepTranscript: the page shows a window this payload must not replace (the
450
768
  // user jumped into the history; the payload is the end of the conversation).
451
769
  // Everything but the messages still applies.
770
+ // fromCache: the payload is the window kept from an earlier visit
771
+ // (conversation-payload-cache.mjs), drawn while the fresh one is on its way.
772
+ // Only the transcript and what goes with it is drawn: the record, the
773
+ // composer, the draft, the live bubble and the tasks wait for the fresh answer.
774
+ // viewAnchor: the place read (transcript-reading-anchor.mjs) the restore
775
+ // puts back, in place of savedScrollTop.
776
+ // mergeFrom: the messages on the page; the payload's are merged into them
777
+ // rather than drawn anew (mergeMessagesIntoTranscript).
778
+ // windowPayload: with keepTranscript, the window the page keeps, kept for
779
+ // the next visit in place of nothing.
452
780
  export function applyLoadedConversationState(id, response, {
453
781
  restoreScroll = false,
454
782
  savedScrollTop = null,
783
+ viewAnchor = null,
455
784
  followLiveUpdates = !restoreScroll,
456
785
  keepTranscript = false,
786
+ fromCache = false,
787
+ mergeFrom = null,
788
+ windowPayload = null,
457
789
  } = {}) {
458
790
  if (!response) {
791
+ if (!IS_SHARED_VIEW) conversationPayloadCache.forget(id);
459
792
  setRepoBrowserSessionInfo('', '');
460
793
  restoreInFlightThinking(null);
461
794
  renderMessages([]);
462
795
  window.syncChatTitleControls?.();
463
796
  return;
464
797
  }
798
+ if (fromCache) {
799
+ applyCachedConversationState(id, response, { viewAnchor });
800
+ return;
801
+ }
802
+ if (!IS_SHARED_VIEW) {
803
+ // The page keeps a window this payload does not show: what it shows is
804
+ // not this payload, so only a window the caller names is kept for the
805
+ // next visit. Without one the entry already there stays: it follows the
806
+ // page (its pages and live rows are added to it), so it still is the
807
+ // window on screen. The cache keeps this very object, uncopied: nothing
808
+ // below may change it.
809
+ if (windowPayload) conversationPayloadCache.remember(id, windowPayload);
810
+ else if (!keepTranscript) conversationPayloadCache.remember(id, response);
811
+ }
465
812
  const existingConversation = conversations[id] || {};
466
813
  upsertConversationRecord({
467
814
  ...existingConversation,
@@ -481,15 +828,22 @@ export function applyLoadedConversationState(id, response, {
481
828
  runtimeWorkspaceRootName: response.runtimeWorkspaceRootName ?? existingConversation.runtimeWorkspaceRootName ?? null,
482
829
  currentWorkspaceRootPath: response.currentWorkspaceRootPath ?? existingConversation.currentWorkspaceRootPath ?? null,
483
830
  currentWorkspaceRootName: response.currentWorkspaceRootName ?? existingConversation.currentWorkspaceRootName ?? null,
831
+ // A shared payload has no runtime session, only the provider (null when
832
+ // none is bound); the shared view keeps an unknown one unknown.
484
833
  runtimeProviderType: response.runtimeSession?.providerType
834
+ ?? response.runtimeProviderType
485
835
  ?? existingConversation.runtimeProviderType
486
- ?? 'github',
836
+ ?? (IS_SHARED_VIEW ? null : 'github'),
487
837
  runtimeProviderModel: response.runtimeSession?.providerModel
488
838
  ?? existingConversation.runtimeProviderModel
489
839
  ?? null,
490
840
  runtimeModel: response.runtimeSession?.model
491
841
  ?? existingConversation.runtimeModel
492
842
  ?? null,
843
+ // The session's state and live tasks, as the single payload reports them.
844
+ ...(typeof response.activeTurn === 'boolean' ? { activeTurn: response.activeTurn } : {}),
845
+ ...(response.sessionState ? { sessionState: response.sessionState } : {}),
846
+ ...(Array.isArray(response.backgroundTasks) ? { backgroundTasks: response.backgroundTasks } : {}),
493
847
  sessionUsageSummary: response.sessionUsageSummary ?? existingConversation.sessionUsageSummary ?? null,
494
848
  // Claude Cloud only: repository, branch, session link, pushed branches.
495
849
  // An explicit null is a real answer ("not a cloud chat"); only a payload
@@ -519,9 +873,15 @@ export function applyLoadedConversationState(id, response, {
519
873
  // Absent from an older relay's payload: keep what the socket said.
520
874
  if ('pins' in response) setConversationPins(id, response.pins, response.pinsRevision);
521
875
  setPinsConversation(id);
522
- const didRenderMessages = keepTranscript
523
- ? false
524
- : renderMessages(response.messages, !restoreScroll, response);
876
+ // Their Hide / Unhide too, from the hidden list (owner payloads only).
877
+ if ('hiddenMessages' in response) setConversationHiddenMessages(id, response.hiddenMessages, response.hiddenMessagesRevision);
878
+ setHiddenMessagesConversation(id);
879
+ let didRenderMessages = false;
880
+ if (!keepTranscript) {
881
+ didRenderMessages = mergeFrom
882
+ ? mergeMessagesIntoTranscript(mergeFrom, response.messages, response)
883
+ : renderMessages(response.messages, !restoreScroll, response);
884
+ }
525
885
  hydrateConversationDraft(id, {
526
886
  draftText: response.draftText,
527
887
  draftAttachments: response.draftAttachments,
@@ -530,7 +890,11 @@ export function applyLoadedConversationState(id, response, {
530
890
  });
531
891
  restoreInFlightThinking(response.inFlight || null, followLiveUpdates);
532
892
  setBackgroundTasksConversation(id);
893
+ // Absent from an older relay's payload: the threshold keeps its default.
894
+ if ('taskStallMinutes' in response) setConversationTaskStallMinutes(id, response.taskStallMinutes);
533
895
  setConversationBackgroundTasks(id, response.backgroundTasks || []);
896
+ // Absent from an older relay's payload: keep what the socket said.
897
+ if (Array.isArray(response.endedBackgroundTasks)) setConversationEndedBackgroundTasks(id, response.endedBackgroundTasks);
534
898
  setUsageLimitConversation(id, {
535
899
  // Absent from an older relay's payload: keep what the socket said.
536
900
  pause: 'usageLimitPause' in response ? response.usageLimitPause : undefined,
@@ -541,8 +905,18 @@ export function applyLoadedConversationState(id, response, {
541
905
  updateSessionPill(conversations[id], response.runtimeSession || null);
542
906
  window.syncChatTitleControls?.();
543
907
  if (!restoreScroll || !didRenderMessages) return;
908
+ restoreConversationScroll(id, savedScrollTop, viewAnchor);
909
+ }
910
+
911
+ // The place read when it is on the page, else the pixel position given, else
912
+ // the end.
913
+ function restoreConversationScroll(id, savedScrollTop, viewAnchor = null) {
544
914
  const el = document.getElementById('messages');
545
915
  if (!el) return;
916
+ if (viewAnchor && restoreConversationReadingAnchor(viewAnchor)) {
917
+ saveConversationScrollTop(id, el.scrollTop);
918
+ return;
919
+ }
546
920
  if (Number.isFinite(savedScrollTop)) {
547
921
  el.scrollTop = savedScrollTop;
548
922
  saveConversationScrollTop(id, el.scrollTop);
@@ -552,6 +926,54 @@ export function applyLoadedConversationState(id, response, {
552
926
  saveConversationScrollTop(id, el.scrollTop);
553
927
  }
554
928
 
929
+ function payloadHasMessage(response, messageId) {
930
+ const id = String(messageId || '').trim();
931
+ return !!id && Array.isArray(response?.messages) && response.messages.some((message) => String(message?.id || '').trim() === id);
932
+ }
933
+
934
+ // The kept window of a conversation, when it holds the place read: a window
935
+ // without it (a row edited since dropped it, the kept rows were cut) would
936
+ // put the reader somewhere else.
937
+ function readCachedWindow(id, anchor) {
938
+ const cached = conversationPayloadCache.read(id);
939
+ if (!cached) return null;
940
+ if (anchor && !anchor.atTail && anchor.messageId && !payloadHasMessage(cached, anchor.messageId)) return null;
941
+ return cached;
942
+ }
943
+
944
+ // The conversation being left: its place is noted as it is on the page, and
945
+ // its kept window is cut down to the retention cap around that place.
946
+ function keepPlaceOfConversationLeft(conversationId) {
947
+ const captured = captureConversationReadingAnchor();
948
+ const shown = captured?.conversationId === conversationId;
949
+ // During a quick run through several conversations the page still shows
950
+ // an earlier one: the place saved for this one stands.
951
+ const anchor = shown ? captured.anchor : loadConversationReadingAnchor(conversationId);
952
+ if (shown) saveConversationReadingAnchor(conversationId, anchor);
953
+ conversationPayloadCache.trim(conversationId, {
954
+ cap: readWindowRetention(),
955
+ anchorMessageId: anchor?.messageId || '',
956
+ atTail: anchor?.atTail === true,
957
+ });
958
+ }
959
+
960
+ // The kept window of a conversation, drawn on a switch back to it, at the
961
+ // place read. Pins and the hidden list are revision-guarded, so an older copy
962
+ // cannot undo a newer change the socket brought.
963
+ function applyCachedConversationState(id, response, { viewAnchor = null } = {}) {
964
+ setRepoBrowserSessionInfo(response.sessionRootPath || '', response.sessionRootName || response.title || '');
965
+ if ('pins' in response) setConversationPins(id, response.pins, response.pinsRevision);
966
+ setPinsConversation(id);
967
+ if ('hiddenMessages' in response) setConversationHiddenMessages(id, response.hiddenMessages, response.hiddenMessagesRevision);
968
+ setHiddenMessagesConversation(id);
969
+ const didRenderMessages = renderMessages(response.messages, false, response);
970
+ // The panel shows this conversation's tasks as the socket keeps them.
971
+ setBackgroundTasksConversation(id);
972
+ window.syncChatTitleControls?.();
973
+ if (!didRenderMessages) return;
974
+ restoreConversationScroll(id, null, viewAnchor);
975
+ }
976
+
555
977
  // The "via agent" marker (sidebar row, header, message badge): opens the
556
978
  // conversation whose agent started this one. The marker sits inside a sidebar
557
979
  // row that opens on click itself, so the click stops here.
@@ -569,6 +991,67 @@ export function openOriginConversation(event, element) {
569
991
  return openConversation(id);
570
992
  }
571
993
 
994
+ // The fresh answer for a conversation whose kept window is on the page (drawn
995
+ // on the switch back, or still there when the conversation on screen is
996
+ // opened again). A window that reaches the end takes the answer's rows: those
997
+ // it has are replaced (a reply may have finished since), the new ones join
998
+ // it, nothing else is rebuilt. A reader at the end stays at the end, one who
999
+ // is not keeps the message they are at where it is, wherever they scrolled
1000
+ // to while the answer was on its way. A window in the middle of the history
1001
+ // keeps its rows: they do not change (an edit comes as message_updated, which
1002
+ // drops the window of a conversation not on screen and updates the one on
1003
+ // screen); the rest of the answer applies.
1004
+ function applyFreshOverCachedConversation(id, response, { cached, keepTranscript = false } = {}) {
1005
+ const atEnd = isMessagesNearBottom() && isConversationWindowAtTail();
1006
+ if (keepTranscript) {
1007
+ applyLoadedConversationState(id, response, { keepTranscript: true, followLiveUpdates: false });
1008
+ return;
1009
+ }
1010
+ const keptWindow = (hasMoreNewer) => ({
1011
+ ...response,
1012
+ messages: cached.messages,
1013
+ hasMoreOlder: cached.hasMoreOlder,
1014
+ hasMoreNewer,
1015
+ });
1016
+ if (cached.hasMoreNewer) {
1017
+ applyLoadedConversationState(id, response, { keepTranscript: true, followLiveUpdates: false, windowPayload: keptWindow(true) });
1018
+ return;
1019
+ }
1020
+ // The kept copy was read before the request: every row of it predates the
1021
+ // answer, so a newer one the answer lacks was deleted while away.
1022
+ const merged = mergeTailPage(cached.messages, response.messages, {
1023
+ pageHasMoreOlder: payloadHasMoreOlder(response),
1024
+ windowPredatesPage: true,
1025
+ });
1026
+ if (!merged) {
1027
+ // More was written than the answer holds: a reader at the end gets the
1028
+ // end; anyone else keeps the window, which no longer reaches it.
1029
+ if (atEnd) {
1030
+ applyLoadedConversationState(id, response, { restoreScroll: false });
1031
+ return;
1032
+ }
1033
+ detachConversationWindowFromTail();
1034
+ applyLoadedConversationState(id, response, { keepTranscript: true, followLiveUpdates: false, windowPayload: keptWindow(true) });
1035
+ return;
1036
+ }
1037
+ const place = atEnd ? null : captureConversationReadingAnchor()?.anchor;
1038
+ const flags = { hasMoreOlder: cached.hasMoreOlder, hasMoreNewer: false };
1039
+ applyLoadedConversationState(id, {
1040
+ ...response,
1041
+ messages: merged.messages,
1042
+ ...flags,
1043
+ pageInfo: windowPageInfo(merged.messages, flags),
1044
+ }, {
1045
+ mergeFrom: cached.messages,
1046
+ followLiveUpdates: atEnd,
1047
+ });
1048
+ const messagesEl = document.getElementById('messages');
1049
+ if (!messagesEl) return;
1050
+ if (atEnd) messagesEl.scrollTop = messagesEl.scrollHeight;
1051
+ else if (place) restoreConversationReadingAnchor(place);
1052
+ saveConversationScrollTop(id, messagesEl.scrollTop);
1053
+ }
1054
+
572
1055
  export async function openConversation(id, options = {}) {
573
1056
  const didLeaveStatusView = leaveStatusView();
574
1057
  document.getElementById('input-area')?.removeAttribute('hidden');
@@ -582,6 +1065,7 @@ export async function openConversation(id, options = {}) {
582
1065
  window.clearImageEditTarget?.();
583
1066
  }
584
1067
  const switchingConversation = !!nextConversationId && previousConversationId !== nextConversationId;
1068
+ if (switchingConversation && previousConversationId && !IS_SHARED_VIEW) keepPlaceOfConversationLeft(previousConversationId);
585
1069
  const capturedVersion = ++openConversationVersion;
586
1070
  setCurrentConv(id);
587
1071
  if (repoBrowserState.activeRoot === 'workspace') {
@@ -618,37 +1102,82 @@ export async function openConversation(id, options = {}) {
618
1102
  const focusMessageId = String(options.focusMessageId || '').trim();
619
1103
  const aroundMessageId = String(options.aroundMessageId || focusMessageId || '').trim();
620
1104
  const forceFreshWindow = !!aroundMessageId;
621
- const savedScrollTop = loadConversationScrollTop(id);
622
- const restoreScroll = !forceFreshWindow && Number.isFinite(savedScrollTop);
623
- const savedLoadedCount = loadConversationLoadedMessageCount(id);
624
- const requestLimit = forceFreshWindow
625
- ? 40
626
- : (Number.isFinite(savedLoadedCount)
627
- ? Math.max(20, savedLoadedCount)
628
- : 20);
629
- const r = await loadConversation(id, {
630
- limit: requestLimit,
631
- aroundMessageId: aroundMessageId || undefined,
1105
+ const keepsPlace = !forceFreshWindow && !IS_SHARED_VIEW;
1106
+ const savedAnchor = keepsPlace ? loadConversationReadingAnchor(id) : null;
1107
+ // Switching back to a conversation seen before on this page: its kept
1108
+ // window is drawn at once, at the place read, and the fresh answer below
1109
+ // is merged into it.
1110
+ const cached = keepsPlace ? readCachedWindow(id, savedAnchor) : null;
1111
+ if (cached && (switchingConversation || didLeaveStatusView)) {
1112
+ applyLoadedConversationState(id, cached, { fromCache: true, viewAnchor: savedAnchor });
1113
+ }
1114
+ // Nothing kept (a reload, a window dropped): a reader who was not at the
1115
+ // end gets the messages around the one they read, as after a search jump.
1116
+ // Only when the page does not show the conversation already: reopening the
1117
+ // one on screen (a session bound, a click on it, a push) rebuilds its end
1118
+ // under the reader, a jump would throw away the window they scrolled.
1119
+ const showsOtherTranscript = switchingConversation || didLeaveStatusView;
1120
+ const anchorMessageId = !cached && showsOtherTranscript && savedAnchor && !savedAnchor.atTail
1121
+ ? String(savedAnchor.messageId || '').trim()
1122
+ : '';
1123
+ const cachedJumpEpoch = cached ? getTranscriptJumpEpoch() : 0;
1124
+ const load = (around = aroundMessageId || anchorMessageId) => loadConversation(id, {
1125
+ limit: around ? 40 : 20,
1126
+ aroundMessageId: around || undefined,
632
1127
  });
633
- if (!shouldApplyConversationLoad({
1128
+ const stillWanted = () => shouldApplyConversationLoad({
634
1129
  requestedConversationId: id,
635
1130
  activeConversationId: currentConvId,
636
1131
  capturedVersion,
637
1132
  currentVersion: openConversationVersion,
638
- })) {
639
- return;
640
- }
641
- if (r) {
1133
+ });
1134
+ // The drawn copy takes live rows from the socket while the answer is on its
1135
+ // way; an answer that may lack them is asked again, as the live poll does.
1136
+ const { response: fetched, stale } = cached
1137
+ ? await loadKeepingLiveRows(load, getTranscriptLiveRowEpoch)
1138
+ : { response: await load(), stale: false };
1139
+ if (!stillWanted()) return;
1140
+ let r = fetched;
1141
+ const anchorFound = !!anchorMessageId && payloadHasMessage(r, anchorMessageId);
1142
+ if (r && anchorMessageId && !anchorFound) {
1143
+ // The message read is gone (deleted, compacted away): the end instead.
1144
+ r = await load('');
1145
+ if (!stillWanted()) return;
1146
+ }
1147
+ if (r && cached) {
1148
+ applyFreshOverCachedConversation(id, r, {
1149
+ cached,
1150
+ // A jump into the history from the drawn copy (a pin) wins. A stale
1151
+ // answer is still merged, unlike on the live poll: the drawn copy
1152
+ // predates the rows stored while the reader was away.
1153
+ keepTranscript: shouldKeepTranscriptOnReload({
1154
+ stale,
1155
+ jumped: getTranscriptJumpEpoch() !== cachedJumpEpoch,
1156
+ showsLiveTranscript: false,
1157
+ }),
1158
+ });
1159
+ } else if (r) {
1160
+ // The conversation on screen opened again (a worker bound its session):
1161
+ // its rows are rebuilt under the reader, who keeps the text they are at.
1162
+ const messagesEl = document.getElementById('messages');
1163
+ const rereading = !switchingConversation && !anchorFound && !isMessagesNearBottom();
1164
+ const reading = rereading ? captureReadingBlock(messagesEl) : null;
642
1165
  // Counted when the window is put on the page, not when it is asked for:
643
1166
  // a reload of the end that is still on its way must see the change.
644
- if (forceFreshWindow) noteTranscriptJump();
645
- applyLoadedConversationState(id, r, { restoreScroll, savedScrollTop });
1167
+ if (forceFreshWindow || anchorFound) noteTranscriptJump();
1168
+ applyLoadedConversationState(id, r, {
1169
+ restoreScroll: rereading || anchorFound,
1170
+ savedScrollTop: rereading ? (messagesEl?.scrollTop ?? null) : null,
1171
+ viewAnchor: anchorFound ? savedAnchor : null,
1172
+ });
1173
+ if (reading) restoreReadingBlock(messagesEl, reading);
646
1174
  if (focusMessageId) {
647
1175
  requestAnimationFrame(() => {
648
1176
  focusConversationMessageById(focusMessageId, { behavior: 'smooth', block: 'center' });
649
1177
  });
650
1178
  }
651
1179
  } else {
1180
+ conversationPayloadCache.forget(id);
652
1181
  setRepoBrowserSessionInfo('', '');
653
1182
  restoreInFlightThinking(null);
654
1183
  renderMessages([]);
@@ -1886,7 +2415,7 @@ function announceConversationFilterResults() {
1886
2415
  if (status.textContent !== text) status.textContent = text;
1887
2416
  }
1888
2417
 
1889
- function setConversationListFilter(text) {
2418
+ export function setConversationListFilter(text) {
1890
2419
  const next = String(text ?? '');
1891
2420
  if (next === conversationListFilterText) return;
1892
2421
  conversationListFilterText = next;
@@ -1971,6 +2500,7 @@ async function setConversationArchived(id, archived) {
1971
2500
  return false;
1972
2501
  }
1973
2502
  if (conversations[id]) conversations[id].archived = archived;
2503
+ conversationPayloadCache.forget(id);
1974
2504
  renderConvList();
1975
2505
  window.syncChatTitleControls?.();
1976
2506
  window.syncComposerButtonState?.();
@@ -2053,6 +2583,35 @@ function conversationMenuItems(id) {
2053
2583
  if (!isCurrent) await openConversation(id);
2054
2584
  then();
2055
2585
  };
2586
+ const groupId = String(conversation.groupId || '').trim();
2587
+ const groupInfo = groupId ? conversationGroups[groupId] || null : null;
2588
+ const isHead = !!groupId && groupInfo?.parentConversationId === id;
2589
+ // Group entries: a head carries the group's actions; a member can leave or
2590
+ // move, and step up or down the group's order (the keyboard's way to do
2591
+ // what a drag does); an ungrouped conversation can join or start a group.
2592
+ const memberIds = !groupId || isHead
2593
+ ? []
2594
+ : (lastConvListDragItems.find((item) => item.kind === 'head' && item.groupId === groupId)?.memberIds || []);
2595
+ const stepEntries = memberIds.includes(id)
2596
+ ? ['up', 'down'].map((direction) => {
2597
+ const step = groupMoveStep(memberIds, id, direction);
2598
+ return {
2599
+ id: `group-move-${direction}`,
2600
+ label: direction === 'up' ? 'Move up' : 'Move down',
2601
+ disabled: !step,
2602
+ onSelect: () => { if (step) void placeConversationInGroupOrder(id, groupId, step.beforeConversationId); },
2603
+ };
2604
+ })
2605
+ : [];
2606
+ const groupEntries = !groupId
2607
+ ? [{ id: 'group-add', label: 'Add to group…', onSelect: () => { openConversationGroupPicker(id); } }]
2608
+ : [
2609
+ ...(isHead ? groupMenuItems(groupId) : []),
2610
+ ...(isHead ? [MENU_SEPARATOR] : []),
2611
+ ...stepEntries,
2612
+ { id: 'group-move', label: 'Move to another group…', onSelect: () => { openConversationGroupPicker(id); } },
2613
+ { id: 'group-remove', label: 'Remove from group', onSelect: () => { void removeConversationFromGroup(id); } },
2614
+ ];
2056
2615
  return [
2057
2616
  { id: 'open', label: 'Open', onSelect: () => { void openConversation(id); } },
2058
2617
  { id: 'edit-title', label: 'Edit title', onSelect: () => { void openFirst(() => window.openChatTitleEditor?.()); } },
@@ -2060,6 +2619,8 @@ function conversationMenuItems(id) {
2060
2619
  { id: 'stop-turn', label: 'Stop turn', disabled: !running, onSelect: () => { void openFirst(() => openStopTurnConfirmationForConversation(id)); } },
2061
2620
  { id: 'kill-session', label: 'Kill session', disabled: !workerState && !sdkSessionId, onSelect: () => { void openFirst(() => window.openKillSessionConfirmation?.()); } },
2062
2621
  MENU_SEPARATOR,
2622
+ ...groupEntries,
2623
+ MENU_SEPARATOR,
2063
2624
  conversation.archived === true
2064
2625
  ? { id: 'unarchive', label: 'Unarchive', onSelect: () => { void unarchiveConv(null, id); } }
2065
2626
  : { id: 'archive', label: 'Archive', onSelect: () => { void archiveConv(null, id); } },
@@ -2067,14 +2628,393 @@ function conversationMenuItems(id) {
2067
2628
  ];
2068
2629
  }
2069
2630
 
2070
- /** Right-click (and long press on touch) on a row opens its menu. Bound once; the list's rows are re-rendered freely. */
2631
+ /** The group actions of a head row (live or synthetic): archive, stop, rename, dissolve. */
2632
+ function groupMenuItems(groupId) {
2633
+ const id = String(groupId || '').trim();
2634
+ if (!id || !conversationGroups[id]) return [];
2635
+ const pendingByConversation = getPendingQuestionCountsByConversation();
2636
+ const rows = groupMemberRows(id, { inViewOnly: false });
2637
+ const liveRows = rows.filter((row) => row.archived !== true);
2638
+ // What the action stops: every turn and every stoppable background task,
2639
+ // in a member that waits on a card as much as in one that works.
2640
+ const running = liveRows.filter((row) => conversationRowState(row, pendingByConversation).live).length;
2641
+ return [
2642
+ { id: 'group-archive', label: `Archive group (${liveRows.length})`, disabled: !liveRows.length, onSelect: () => { void archiveConversationGroup(id); } },
2643
+ { id: 'group-stop', label: `Stop all running (${running})`, disabled: !running, onSelect: () => { void stopConversationGroup(id); } },
2644
+ { id: 'group-rename', label: 'Rename group', onSelect: () => { openConversationGroupRename(id); } },
2645
+ { id: 'group-dissolve', label: 'Dissolve group', onSelect: () => { void dissolveConversationGroup(id); } },
2646
+ ];
2647
+ }
2648
+
2649
+ /** Resolves true when the user confirms; Cancel, Escape, the backdrop or × say no. */
2650
+ function askConfirmation({ title, subtitle = '', message, detail = '', confirmLabel, kind }) {
2651
+ return new Promise((resolve) => {
2652
+ let settled = false;
2653
+ const settle = (value) => {
2654
+ if (settled) return;
2655
+ settled = true;
2656
+ resolve(value);
2657
+ };
2658
+ openSummaryModal({
2659
+ title,
2660
+ subtitle,
2661
+ kind,
2662
+ bodyHtml: `
2663
+ <p>${escHtml(message)}</p>
2664
+ ${detail ? `<p>${escHtml(detail)}</p>` : ''}
2665
+ <div class="summary-modal-actions">
2666
+ <button class="chat-title-action-btn" type="button" data-confirm="1">${escHtml(confirmLabel)}</button>
2667
+ <button class="chat-title-action-btn" type="button" data-cancel="1">Cancel</button>
2668
+ </div>
2669
+ `,
2670
+ });
2671
+ setSummaryModalLeaveHandler(kind, () => settle(false));
2672
+ const body = document.getElementById('summary-modal-body');
2673
+ body?.querySelector('[data-confirm]')?.addEventListener('click', (event) => {
2674
+ event.preventDefault();
2675
+ settle(true);
2676
+ closeSummaryModal();
2677
+ }, { once: true });
2678
+ const cancel = body?.querySelector('[data-cancel]');
2679
+ cancel?.addEventListener('click', (event) => {
2680
+ event.preventDefault();
2681
+ closeSummaryModal();
2682
+ }, { once: true });
2683
+ cancel?.focus?.();
2684
+ });
2685
+ }
2686
+
2687
+ function groupActionFailed(result, fallback) {
2688
+ showTransientRelayNotice(String(result?.error || result?.message || '').trim() || fallback, 6000);
2689
+ }
2690
+
2691
+ /** Archive every member that can be archived now; the skipped ones are named. */
2692
+ async function archiveConversationGroup(groupId) {
2693
+ closeContextMenu();
2694
+ const name = groupDisplayName(groupId);
2695
+ const liveRows = groupMemberRows(groupId, { inViewOnly: false }).filter((row) => row.archived !== true);
2696
+ const pendingByConversation = getPendingQuestionCountsByConversation();
2697
+ const busy = liveRows.filter((row) => conversationRowState(row, pendingByConversation).live).length;
2698
+ const confirmed = await askConfirmation({
2699
+ title: 'Archive group',
2700
+ subtitle: name,
2701
+ kind: 'group-archive-confirm',
2702
+ message: `Archive the ${liveRows.length} session${liveRows.length === 1 ? '' : 's'} of this group?`,
2703
+ detail: busy
2704
+ ? `${busy} of them ${busy === 1 ? 'is' : 'are'} still working and will stay; stop them first to archive everything.`
2705
+ : 'Archiving stops their idle processes. The chats stay in the Archived view.',
2706
+ confirmLabel: '🗄 Archive group',
2707
+ });
2708
+ if (!confirmed) return;
2709
+ const result = await archiveConversationGroupApi(groupId);
2710
+ if (!result) return;
2711
+ if (result.ok === false) {
2712
+ groupActionFailed(result, 'The group could not be archived.');
2713
+ return;
2714
+ }
2715
+ const skipped = Array.isArray(result.skipped) ? result.skipped : [];
2716
+ for (const id of result.archived || []) {
2717
+ if (conversations[id]) conversations[id].archived = true;
2718
+ conversationPayloadCache.forget(id);
2719
+ }
2720
+ renderConvList();
2721
+ if (skipped.length) {
2722
+ const names = skipped.map((entry) => `“${String(entry.title || entry.id).trim()}”`).join(', ');
2723
+ showTransientRelayNotice(`${result.archived?.length || 0} archived; still working and kept: ${names}.`, 8000);
2724
+ } else {
2725
+ showTransientRelayNotice(`${result.archived?.length || 0} session${result.archived?.length === 1 ? '' : 's'} archived.`, 4000);
2726
+ }
2727
+ // The relay's conversation_group_changed event applies the change in place.
2728
+ }
2729
+
2730
+ /** A stop for every member's live turn and stoppable background task. */
2731
+ async function stopConversationGroup(groupId) {
2732
+ closeContextMenu();
2733
+ const name = groupDisplayName(groupId);
2734
+ const confirmed = await askConfirmation({
2735
+ title: 'Stop all running',
2736
+ subtitle: name,
2737
+ kind: 'group-stop-confirm',
2738
+ message: 'Stop every running turn and background task in this group?',
2739
+ detail: 'Each session stops at its next safe point; the chats stay where they are.',
2740
+ confirmLabel: '⏹ Stop all',
2741
+ });
2742
+ if (!confirmed) return;
2743
+ const result = await stopConversationGroupApi(groupId);
2744
+ if (!result) return;
2745
+ if (result.ok === false) {
2746
+ groupActionFailed(result, 'The group could not be stopped.');
2747
+ return;
2748
+ }
2749
+ const turns = result.turns?.length || 0;
2750
+ const tasks = result.tasks?.length || 0;
2751
+ const unstoppable = result.unstoppable?.length || 0;
2752
+ const parts = [];
2753
+ if (turns) parts.push(`${turns} turn${turns === 1 ? '' : 's'}`);
2754
+ if (tasks) parts.push(`${tasks} background task${tasks === 1 ? '' : 's'}`);
2755
+ let text = parts.length ? `Stop requested for ${parts.join(' and ')}.` : 'Nothing was running in this group.';
2756
+ if (unstoppable) text += ` ${unstoppable} task${unstoppable === 1 ? ' has' : 's have'} no Stop button; use Kill session there.`;
2757
+ showTransientRelayNotice(text, 6000);
2758
+ }
2759
+
2760
+ /** Members become ungrouped rows; nothing else changes. */
2761
+ async function dissolveConversationGroup(groupId) {
2762
+ closeContextMenu();
2763
+ const confirmed = await askConfirmation({
2764
+ title: 'Dissolve group',
2765
+ subtitle: groupDisplayName(groupId),
2766
+ kind: 'group-dissolve-confirm',
2767
+ message: 'Dissolve this group? Its sessions stay, as single rows.',
2768
+ confirmLabel: 'Dissolve',
2769
+ });
2770
+ if (!confirmed) return;
2771
+ const result = await dissolveConversationGroupApi(groupId);
2772
+ if (!result) return;
2773
+ if (result.ok === false) {
2774
+ groupActionFailed(result, 'The group could not be dissolved.');
2775
+ return;
2776
+ }
2777
+ // The relay's conversation_group_changed event applies the change in place.
2778
+ }
2779
+
2780
+ async function removeConversationFromGroup(conversationId) {
2781
+ closeContextMenu();
2782
+ const result = await removeConversationFromGroupApi(conversationId);
2783
+ if (!result) return;
2784
+ if (result.ok === false) {
2785
+ groupActionFailed(result, 'The conversation could not leave its group.');
2786
+ return;
2787
+ }
2788
+ // The relay's conversation_group_changed event applies the change in place.
2789
+ }
2790
+
2791
+ const GROUP_RENAME_KIND = 'group-rename';
2792
+ const GROUP_PICKER_KIND = 'group-picker';
2793
+ const GROUP_NAME_MAX_LENGTH = 60;
2794
+
2795
+ /** A name for the group; empty returns it to the head's title. */
2796
+ export function openConversationGroupRename(groupId) {
2797
+ closeContextMenu();
2798
+ const id = String(groupId || '').trim();
2799
+ const info = conversationGroups[id];
2800
+ if (!info) return;
2801
+ openSummaryModal({
2802
+ title: 'Rename group',
2803
+ subtitle: groupDisplayName(id),
2804
+ kind: GROUP_RENAME_KIND,
2805
+ bodyHtml: `
2806
+ <form id="conv-group-rename-form" class="conv-group-form">
2807
+ <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">
2808
+ <div class="summary-modal-actions">
2809
+ <button class="chat-title-action-btn" type="submit" data-rename-save="1">Save</button>
2810
+ <button class="chat-title-action-btn" type="button" data-cancel="1">Cancel</button>
2811
+ </div>
2812
+ </form>
2813
+ `,
2814
+ });
2815
+ const body = document.getElementById('summary-modal-body');
2816
+ const form = body?.querySelector('#conv-group-rename-form');
2817
+ const input = body?.querySelector('#conv-group-rename-input');
2818
+ form?.addEventListener('submit', async (event) => {
2819
+ event.preventDefault();
2820
+ const name = String(input?.value || '').trim();
2821
+ closeSummaryModal();
2822
+ const result = await renameConversationGroupApi(id, name);
2823
+ if (!result) return;
2824
+ if (result.ok === false) {
2825
+ groupActionFailed(result, 'The group could not be renamed.');
2826
+ return;
2827
+ }
2828
+ // The relay's conversation_group_changed event applies the change in place.
2829
+ });
2830
+ body?.querySelector('[data-cancel]')?.addEventListener('click', (event) => {
2831
+ event.preventDefault();
2832
+ closeSummaryModal();
2833
+ }, { once: true });
2834
+ requestAnimationFrame(() => {
2835
+ input?.focus({ preventScroll: true });
2836
+ input?.select?.();
2837
+ });
2838
+ }
2839
+
2840
+ /**
2841
+ * The groups a conversation can join (the loaded ones, by name, with their
2842
+ * counts) and a new group with a name. Desktop and phone share it; it also
2843
+ * reaches the groups a drag cannot: on another page, or in the other view.
2844
+ */
2845
+ export function openConversationGroupPicker(conversationId) {
2846
+ closeContextMenu();
2847
+ const id = String(conversationId || '').trim();
2848
+ const conversation = conversations[id];
2849
+ if (!conversation) return;
2850
+ const currentGroupId = String(conversation.groupId || '').trim();
2851
+ const choices = Object.values(conversationGroups)
2852
+ .map((info) => ({
2853
+ id: info.id,
2854
+ name: groupDisplayName(info.id),
2855
+ count: Number(info.memberCount || groupMemberRows(info.id, { inViewOnly: false }).length || 0),
2856
+ current: info.id === currentGroupId,
2857
+ }))
2858
+ .sort((a, b) => a.name.localeCompare(b.name));
2859
+ const listHtml = choices.length
2860
+ ? choices.map((choice) => `
2861
+ <button class="conv-group-choice" type="button" data-group-choice="${escHtml(choice.id)}"${choice.current ? ' disabled aria-current="true"' : ''}>
2862
+ <span class="conv-group-choice-name">${escHtml(choice.name)}</span>
2863
+ <span class="conv-group-choice-count">${choice.count} session${choice.count === 1 ? '' : 's'}${choice.current ? ' · current' : ''}</span>
2864
+ </button>`).join('')
2865
+ : '<p class="conv-group-picker-empty">No groups yet. Make one below.</p>';
2866
+ openSummaryModal({
2867
+ title: currentGroupId ? 'Move to another group' : 'Add to group',
2868
+ subtitle: String(conversation.title || '').trim(),
2869
+ kind: GROUP_PICKER_KIND,
2870
+ bodyHtml: `
2871
+ <div id="conv-group-picker" class="conv-group-picker">
2872
+ <div class="conv-group-choices">${listHtml}</div>
2873
+ <form id="conv-group-create-form" class="conv-group-form">
2874
+ <label class="conv-group-form-label" for="conv-group-create-input">New group</label>
2875
+ <div class="conv-group-form-row">
2876
+ <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">
2877
+ <button class="chat-title-action-btn" type="submit" data-group-create="1">Create</button>
2878
+ </div>
2879
+ </form>
2880
+ </div>
2881
+ `,
2882
+ });
2883
+ const body = document.getElementById('summary-modal-body');
2884
+ body?.querySelector('.conv-group-choices')?.addEventListener('click', async (event) => {
2885
+ const button = event.target?.closest?.('[data-group-choice]');
2886
+ if (!button || button.disabled) return;
2887
+ event.preventDefault();
2888
+ closeSummaryModal();
2889
+ const result = await moveConversationToGroupApi(id, button.dataset.groupChoice);
2890
+ if (!result) return;
2891
+ if (result.ok === false) {
2892
+ groupActionFailed(result, 'The conversation could not join that group.');
2893
+ return;
2894
+ }
2895
+ // The relay's conversation_group_changed event applies the change in place.
2896
+ });
2897
+ const form = body?.querySelector('#conv-group-create-form');
2898
+ const input = body?.querySelector('#conv-group-create-input');
2899
+ form?.addEventListener('submit', async (event) => {
2900
+ event.preventDefault();
2901
+ const name = String(input?.value || '').trim();
2902
+ closeSummaryModal();
2903
+ const result = await createConversationGroupApi({ name: name || null, conversationIds: [id] });
2904
+ if (!result) return;
2905
+ if (result.ok === false) {
2906
+ groupActionFailed(result, 'The group could not be made.');
2907
+ return;
2908
+ }
2909
+ // The relay's conversation_group_changed event applies the change in place.
2910
+ });
2911
+ requestAnimationFrame(() => {
2912
+ (choices.length ? body?.querySelector('[data-group-choice]:not([disabled])') : input)?.focus?.({ preventScroll: true });
2913
+ });
2914
+ }
2915
+
2916
+ /** A move inside a group's order from the row menu (Move up / Move down). */
2917
+ async function placeConversationInGroupOrder(conversationId, groupId, beforeConversationId) {
2918
+ closeContextMenu();
2919
+ await applyConversationListDrop({ action: 'reorder', groupId, beforeConversationId }, conversationId);
2920
+ }
2921
+
2922
+ /** Says what a drop did, for a screen reader. */
2923
+ function announceConversationList(text) {
2924
+ const region = document.getElementById('conv-list-announce');
2925
+ if (!region || !text) return;
2926
+ // Emptied first so the same sentence twice is announced twice.
2927
+ region.textContent = '';
2928
+ requestAnimationFrame(() => { region.textContent = text; });
2929
+ }
2930
+
2931
+ const CONV_ROW_FLASH_MS = 1200;
2932
+ // The relay's event moves the row; it may arrive after the call's answer, so
2933
+ // the row flashes again where the event puts it, for a moment after a drop.
2934
+ const CONV_ROW_FLASH_PENDING_MS = 3000;
2935
+ let pendingConvRowFlash = null;
2936
+
2937
+ /** The moved row, briefly highlighted and brought into view. */
2938
+ function flashConversationRow(conversationId) {
2939
+ const row = document.querySelector(`#conv-list .conv-item[data-conversation-id="${CSS.escape(String(conversationId))}"]`);
2940
+ if (!row) return;
2941
+ row.classList.remove('conv-drop-flash');
2942
+ // Restarts the animation on a row that is still flashing.
2943
+ void row.offsetWidth;
2944
+ row.classList.add('conv-drop-flash');
2945
+ setTimeout(() => row.classList.remove('conv-drop-flash'), CONV_ROW_FLASH_MS);
2946
+ row.scrollIntoView?.({ block: 'nearest' });
2947
+ }
2948
+
2949
+ /**
2950
+ * A drop from the sidebar (conversation-group-drag.mjs), or a step from the
2951
+ * row menu: the relay call, then the row flashed and the move announced. The
2952
+ * records change only by the relay's `conversation_group_changed` event, as
2953
+ * for every group action: an answer that arrives after a newer event must
2954
+ * not undo it. A refusal says why and reloads the list.
2955
+ */
2956
+ async function applyConversationListDrop(result, conversationId) {
2957
+ const id = String(conversationId || '').trim();
2958
+ const title = String(conversations[id]?.title || '').trim();
2959
+ const groupName = result.groupId ? groupDisplayName(result.groupId) : '';
2960
+ let response = null;
2961
+ let fallback = '';
2962
+ let announcement = dropAnnouncement(result.action, { title, groupName });
2963
+ if (result.action === 'new-group') {
2964
+ const otherId = String(result.targetConversationId || '').trim();
2965
+ response = await createConversationGroupApi({ name: null, conversationIds: [otherId, id] });
2966
+ fallback = 'The group could not be made.';
2967
+ // Shown open, though nothing in it runs (which would fold it).
2968
+ if (response && response.ok !== false && response.group?.id) convListTransientOpenGroups.add(String(response.group.id));
2969
+ announcement = dropAnnouncement('new-group', { title, otherTitle: conversations[otherId]?.title });
2970
+ } else if (result.action === 'leave') {
2971
+ response = await removeConversationFromGroupApi(id);
2972
+ fallback = 'The conversation could not leave its group.';
2973
+ } else if (result.action === 'reorder' || result.action === 'join') {
2974
+ response = await placeConversationInGroupApi(id, { groupId: result.groupId, beforeConversationId: result.beforeConversationId ?? null });
2975
+ fallback = result.action === 'join' ? 'The conversation could not join that group.' : 'The conversation could not be moved.';
2976
+ if (response && response.ok !== false) convListTransientOpenGroups.add(result.groupId);
2977
+ } else {
2978
+ return;
2979
+ }
2980
+ if (!response || response.ok === false) {
2981
+ if (response) groupActionFailed(response, fallback);
2982
+ scheduleLoadedConversationsRefresh();
2983
+ return;
2984
+ }
2985
+ renderConvList();
2986
+ flashConversationRow(id);
2987
+ pendingConvRowFlash = { id, until: Date.now() + CONV_ROW_FLASH_PENDING_MS };
2988
+ announceConversationList(announcement);
2989
+ }
2990
+
2991
+ /**
2992
+ * Right-click (and long press on touch) on a row opens its menu; a mouse
2993
+ * drag, or a long press that moves, drags it. Bound once; the list's rows
2994
+ * are re-rendered freely.
2995
+ */
2071
2996
  function bindConversationContextMenu(list) {
2072
2997
  if (!list || list.dataset.contextMenuBound === '1') return;
2073
2998
  list.dataset.contextMenuBound = '1';
2999
+ // Rows move only where their place means something: not in the Archived
3000
+ // view and not under a title filter (the menu entries still work there).
3001
+ const drag = bindConversationListDrag(list, {
3002
+ isEnabled: () => !IS_SHARED_VIEW && !conversationListArchivedView && !normalizeConversationFilter(conversationListFilterText),
3003
+ describeItems: () => lastConvListDragItems,
3004
+ onHold: (held) => holdConvListRender(held),
3005
+ rerender: () => paintConvList(),
3006
+ setDragOpen: (groupId, open) => {
3007
+ if (open) convListTransientOpenGroups.add(groupId);
3008
+ else convListTransientOpenGroups.delete(groupId);
3009
+ },
3010
+ onDrop: (result, conversationId) => { void applyConversationListDrop(result, conversationId); },
3011
+ });
2074
3012
  const openFor = (row, x, y) => {
2075
3013
  const id = String(row?.dataset?.conversationId || '').trim();
2076
- if (!id) return;
2077
- const items = conversationMenuItems(id);
3014
+ // A synthetic head row stands for its group: the group's actions only.
3015
+ const items = id
3016
+ ? conversationMenuItems(id)
3017
+ : (row?.classList?.contains('conv-group-synthetic') ? groupMenuItems(row.dataset.groupId) : []);
2078
3018
  if (items.length) openContextMenu(x, y, items);
2079
3019
  };
2080
3020
  list.addEventListener('contextmenu', (event) => {
@@ -2083,7 +3023,17 @@ function bindConversationContextMenu(list) {
2083
3023
  event.preventDefault();
2084
3024
  openFor(row, event.clientX, event.clientY);
2085
3025
  });
2086
- bindLongPress(list, '.conv-item', (row, x, y) => openFor(row, x, y));
3026
+ // A long press lifts the row: released in place it opens the menu, moved
3027
+ // it becomes a drag. Where rows cannot move (a synthetic head, the
3028
+ // Archived view, a filter) it opens the menu at the delay, as it always did.
3029
+ bindLongPress(list, '.conv-item', (row, x, y) => openFor(row, x, y), {
3030
+ lift: {
3031
+ canLift: (row) => drag.canLift(row),
3032
+ onLift: (row) => drag.lift(row),
3033
+ onMove: (row, x, y, event) => drag.beginTouchDrag(row, x, y, event),
3034
+ onEnd: () => drag.unlift(),
3035
+ },
3036
+ });
2087
3037
  }
2088
3038
 
2089
3039
  export async function deleteConv(e, id) {
@@ -2096,6 +3046,7 @@ export async function deleteConv(e, id) {
2096
3046
  return;
2097
3047
  }
2098
3048
  delete conversations[id];
3049
+ conversationPayloadCache.forget(id);
2099
3050
  renderConvList();
2100
3051
  if (currentConvId === id) clearOpenConversation();
2101
3052
  }