@oar-sh/oar 0.9.10 → 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.
Files changed (104) hide show
  1. package/CHANGELOG.md +314 -1
  2. package/README.md +156 -972
  3. package/package.json +13 -13
  4. package/server/README.md +95 -22
  5. package/server/claude-worker/claude-session-process.mjs +89 -11
  6. package/server/claude-worker/claude-session-worker.mjs +9 -1
  7. package/server/copilot-worker/copilot-sdk-session-process.mjs +76 -11
  8. package/server/copilot-worker/copilot-sdk-session-worker.mjs +6 -0
  9. package/server/db-schema.mjs +59 -2
  10. package/server/grok-worker/acp-client.mjs +2 -1
  11. package/server/grok-worker/acp-host-services.mjs +4 -3
  12. package/server/migrations/0013-previews.mjs +130 -0
  13. package/server/migrations/0014-conversation-groups.mjs +89 -0
  14. package/server/migrations/0015-conversation-group-position.mjs +76 -0
  15. package/server/migrations/0016-share-access-log.mjs +110 -0
  16. package/server/public/app/about-ui.js +73 -0
  17. package/server/public/app/about-view.mjs +234 -0
  18. package/server/public/app/action-confirmations.js +31 -14
  19. package/server/public/app/agent-exchange-fold-view.mjs +40 -101
  20. package/server/public/app/api-client.js +251 -16
  21. package/server/public/app/ask-user-view.js +17 -15
  22. package/server/public/app/bootstrap.js +158 -32
  23. package/server/public/app/chat-actions-menu-state.mjs +20 -10
  24. package/server/public/app/context-menu.mjs +90 -6
  25. package/server/public/app/conversation-group-drag.mjs +191 -0
  26. package/server/public/app/conversation-group-order.mjs +265 -0
  27. package/server/public/app/conversation-group-state.mjs +100 -0
  28. package/server/public/app/conversation-list-drag.mjs +449 -0
  29. package/server/public/app/conversation-turn-state.mjs +81 -0
  30. package/server/public/app/conversation-view.js +136 -93
  31. package/server/public/app/cwd-picker.js +1 -1
  32. package/server/public/app/hidden-messages-view.mjs +312 -0
  33. package/server/public/app/host-suspend-ui.js +7 -1
  34. package/server/public/app/journal-view.js +787 -22
  35. package/server/public/app/preview-cards.mjs +117 -31
  36. package/server/public/app/preview-command.mjs +10 -1
  37. package/server/public/app/relay-board-view.js +19 -6
  38. package/server/public/app/relay-restart-error.mjs +16 -0
  39. package/server/public/app/settings-modal.js +40 -8
  40. package/server/public/app/settings-tabs.js +1 -1
  41. package/server/public/app/share-format.mjs +49 -0
  42. package/server/public/app/share-preview-marks.mjs +167 -0
  43. package/server/public/app/share-state.js +109 -0
  44. package/server/public/app/shared-conversations-settings.js +480 -0
  45. package/server/public/app/socket-handlers.js +80 -14
  46. package/server/public/app/store.js +96 -30
  47. package/server/public/app/subagent-row.mjs +3 -3
  48. package/server/public/app/subagent-viewer.mjs +30 -6
  49. package/server/public/app/thinking-follow.js +0 -4
  50. package/server/public/app/transcript-jump-bottom-view.mjs +194 -20
  51. package/server/public/app/transcript-jump-bottom.mjs +132 -21
  52. package/server/public/app/transcript-viewport-anchor.mjs +140 -0
  53. package/server/public/index.html +386 -15
  54. package/server/relay-tools.md +2 -1
  55. package/server/relay.mjs +2 -1
  56. package/server/repositories/conversation-group-repository.mjs +444 -0
  57. package/server/repositories/message-repository.mjs +1 -0
  58. package/server/repositories/session-repository.mjs +12 -37
  59. package/server/repositories/share-repository.mjs +322 -0
  60. package/server/repositories/share-visibility-statements.mjs +10 -0
  61. package/server/routes/about-routes.mjs +15 -0
  62. package/server/routes/cache-routes.mjs +4 -0
  63. package/server/routes/conversation-group-routes.mjs +171 -0
  64. package/server/routes/messages-routes.mjs +184 -65
  65. package/server/routes/preview-routes.mjs +9 -1
  66. package/server/routes/sessions-routes.mjs +771 -193
  67. package/server/server-runtime.mjs +316 -100
  68. package/server/services/about-service.mjs +461 -0
  69. package/server/services/background-task-store.mjs +128 -0
  70. package/server/services/background-work-view.mjs +228 -0
  71. package/server/services/cache-rebuild-service.mjs +30 -0
  72. package/server/services/delete-archive-service.mjs +4 -1
  73. package/server/services/detached-worker-watch-service.mjs +150 -0
  74. package/server/services/hidden-message-list.mjs +21 -0
  75. package/server/services/host-suspend-command.mjs +2 -1
  76. package/server/services/message-pin-list.mjs +1 -1
  77. package/server/services/preview-health-probe.mjs +8 -1
  78. package/server/services/preview-proxy-server.mjs +8 -4
  79. package/server/services/preview-registry-service.mjs +676 -26
  80. package/server/services/preview-static-handler.mjs +40 -7
  81. package/server/services/preview-store.mjs +274 -0
  82. package/server/services/rate-limit-service.mjs +12 -1
  83. package/server/services/relay-auth.mjs +183 -0
  84. package/server/services/relay-cli-launcher-service.mjs +3 -1
  85. package/server/services/relay-event-service.mjs +17 -0
  86. package/server/services/remote-relay-dispatcher.mjs +57 -5
  87. package/server/services/request-boundary.mjs +183 -0
  88. package/server/services/session-activity-tracker.mjs +227 -19
  89. package/server/services/session-worker-launch-service.mjs +190 -6
  90. package/server/services/session-worker-process-service.mjs +10 -6
  91. package/server/services/share-access-log-service.mjs +123 -0
  92. package/server/services/share-path-redaction.mjs +424 -0
  93. package/server/services/status-event-service.mjs +7 -3
  94. package/server/services/tunnel-worker-path-guard.mjs +24 -41
  95. package/server/services/windows-autostart-service.mjs +6 -1
  96. package/server/services/windows-boot-autostart-service.mjs +207 -23
  97. package/shared/preview-tool-core.mjs +15 -5
  98. package/shared/remote-relay-contract.mjs +3 -1
  99. package/shared/share-path-privacy.mjs +30 -0
  100. package/shared/windows-system-tools.mjs +106 -0
  101. package/shared/worker-runtime/background-task-digest.mjs +30 -0
  102. package/shared/worker-runtime/heartbeat.mjs +24 -0
  103. package/shared/worker-runtime/kept-posts.mjs +83 -0
  104. package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
@@ -26,14 +26,40 @@ import {
26
26
  openSummaryModal,
27
27
  closeSummaryModal,
28
28
  setSummaryModalLeaveHandler,
29
+ conversationGroups,
30
+ mergeConversationGroups,
31
+ readConversationGroupFoldChoices,
32
+ pruneConversationGroups,
33
+ hasSessionWorkerStatus,
34
+ setConversationGroupFoldChoice,
29
35
  } from './store.js';
30
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';
31
49
  import {
32
50
  loadConversations as loadConversationsApi,
33
51
  loadConversation,
34
52
  deleteConversation as deleteConversationApi,
35
53
  archiveConversation as archiveConversationApi,
36
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,
37
63
  bootstrapConversationSession,
38
64
  scheduleContextUsageRefresh,
39
65
  loadModelCatalog,
@@ -49,10 +75,12 @@ import {
49
75
  import { isSetupRequired, updateProviderReadinessInputs } from './provider-readiness.mjs';
50
76
  import { SETUP_TEXT } from './no-conversation-state.mjs';
51
77
  import { openSettingsModal } from './settings-modal.js';
78
+ import { captureReadingBlock, restoreReadingBlock } from './transcript-viewport-anchor.mjs';
52
79
  import { renderMessages, renderNoConversationState, restoreInFlightThinking, focusConversationMessageById, noteTranscriptJump, flushConversationDraft, hydrateConversationDraft, beginConversationDraftSwitch, openStopTurnConfirmationForConversation, conversationHasActiveTurn } from './conversation-view.js';
53
80
  import { MENU_SEPARATOR, bindLongPress, closeContextMenu, openContextMenu } from './context-menu.mjs';
54
81
  import { setBackgroundTasksConversation, setConversationBackgroundTasks } from './background-tasks-view.mjs';
55
82
  import { setConversationPins, setPinsConversation } from './pinned-messages-view.mjs';
83
+ import { setConversationHiddenMessages, setHiddenMessagesConversation } from './hidden-messages-view.mjs';
56
84
  import { setUsageLimitConversation } from './usage-limit-ui.js';
57
85
  import { mergeConversationPreviews } from './preview-cards.mjs';
58
86
  import { loadRelayQuestions, getPendingQuestionCountsByConversation } from './ask-user-view.js';
@@ -95,6 +123,7 @@ import { renderConversationOriginMarkerHtml } from './remote-relay-origin-view.m
95
123
  import { relayEventWatchedMarkerHtml } from './relay-event-subscriptions-ui.js';
96
124
  import {
97
125
  normalizeConversationFilter,
126
+ conversationMatchesFilter,
98
127
  filterConversations,
99
128
  drainRemainingPages,
100
129
  describeFilterMatchCount,
@@ -119,6 +148,15 @@ const CONV_FILTER_DEBOUNCE_MS = 220;
119
148
  let processingDotFrame = 0;
120
149
  let processingDotTimer = null;
121
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();
122
160
  let conversationListFilterText = '';
123
161
  let conversationFilterDrainActive = false;
124
162
  let conversationFilterGeneration = 0;
@@ -165,6 +203,8 @@ let newConversationCloudBranchTimer = null;
165
203
  let newConversationCloudBranchAutoFill = '';
166
204
  const newConversationCloudSuggestActive = { repo: -1, branch: -1 };
167
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;
168
208
  let conversationListBoundaryCheckFrame = 0;
169
209
  let conversationListAutoLoadBlockedUntil = 0;
170
210
  let conversationListPaginationState = {
@@ -198,6 +238,9 @@ function upsertConversationRecord(record) {
198
238
  if (!id) return false;
199
239
  const hadExistingRecord = !!conversations[id];
200
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);
201
244
  return !hadExistingRecord;
202
245
  }
203
246
 
@@ -238,6 +281,7 @@ const conversationListLoader = createInfiniteLoader({
238
281
  archived: conversationListArchivedView ? 'only' : '',
239
282
  });
240
283
  if (!response) throw new Error('Could not load conversations');
284
+ mergeConversationGroups(response.groups);
241
285
  return {
242
286
  items: response.conversations || [],
243
287
  hasMore: !!response.pageInfo?.hasMore,
@@ -246,6 +290,7 @@ const conversationListLoader = createInfiniteLoader({
246
290
  },
247
291
  applyPage: async (page) => {
248
292
  applyConversationPage(page.items);
293
+ conversationRowsFetchedSinceRefresh += Array.isArray(page.items) ? page.items.length : 0;
249
294
  conversationListPaginationState.hasLoadedOlderPages = true;
250
295
  },
251
296
  onError: (error) => {
@@ -277,6 +322,13 @@ function resetConversationListPageState(pageInfo = null, { preserveProgress = fa
277
322
  scheduleConversationListBoundaryCheck();
278
323
  }
279
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
+
280
332
  function isConversationProcessing(conversation, workerState) {
281
333
  const workerStatus = String(workerState?.status || '').trim().toLowerCase();
282
334
  if (workerStatus === 'processing') return true;
@@ -340,7 +392,12 @@ export async function refreshConversations(options = {}) {
340
392
  if (!knownConversationIds.has(id)) delete conversations[id];
341
393
  }
342
394
  }
395
+ mergeConversationGroups(r.groups);
343
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));
344
401
  resetConversationListPageState(r.pageInfo || null, {
345
402
  preserveProgress: preservePagination,
346
403
  });
@@ -355,6 +412,116 @@ export async function refreshConversations(options = {}) {
355
412
  }
356
413
  }
357
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
+
358
525
  /** A sidebar row's state: its colour (green is 'ready') and whether its dots run. */
359
526
  function conversationRowState(conversation, pendingByConversation = getPendingQuestionCountsByConversation()) {
360
527
  const pendingCount = Number(pendingByConversation[conversation?.id] || 0);
@@ -365,21 +532,108 @@ function conversationRowState(conversation, pendingByConversation = getPendingQu
365
532
  workerState,
366
533
  hasPendingQuestion: pendingCount > 0,
367
534
  });
368
- return { visualState, processing: isConversationProcessing(conversation, workerState) };
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 };
369
585
  }
370
586
 
371
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() {
372
603
  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
604
  const activeFilter = normalizeConversationFilter(conversationListFilterText);
379
605
  // 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;
606
+ const inView = Object.values(conversations).filter((conversation) => (conversation?.archived === true) === conversationListArchivedView);
382
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);
383
637
  let hasProcessingConversation = false;
384
638
  const footerHtml = (() => {
385
639
  if (activeFilter) {
@@ -398,7 +652,7 @@ export function renderConvList() {
398
652
  }
399
653
  return '';
400
654
  })();
401
- if (visible.length === 0) {
655
+ if (items.length === 0) {
402
656
  ensureProcessingDotTimer(false);
403
657
  const emptyText = activeFilter && inView.length > 0
404
658
  ? 'No conversations match your filter'
@@ -409,11 +663,31 @@ export function renderConvList() {
409
663
  return;
410
664
  }
411
665
  const conversationView = (conversation) => {
412
- const view = conversationRowState(conversation, pendingByConversation);
666
+ const view = viewOf(conversation);
413
667
  if (view.processing) hasProcessingConversation = true;
414
668
  return view;
415
669
  };
416
- const listHtml = `${visible.map((c) => {
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) => {
417
691
  const view = conversationView(c);
418
692
  const processingDots = view.processing ? PROCESSING_DOT_FRAMES[processingDotFrame] : '';
419
693
  const providerIndicatorLabel = conversationProviderIndicatorLabel(c);
@@ -423,29 +697,88 @@ export function renderConvList() {
423
697
  : '';
424
698
  // "↗ win-test": another relay's agent created this conversation.
425
699
  // "via agent · “title”": an agent in that conversation on this relay did.
426
- const originMarkerHtml = renderConversationOriginMarkerHtml(c, { conversations });
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 });
427
704
  // "watched": a session another conversation watches by id (relay events).
428
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}`)}"` : '';
429
713
  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>
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>
433
717
  ${c.archived
434
718
  ? `<button class="conv-archive" onclick="unarchiveConv(event,'${c.id}')" title="Unarchive" aria-label="Unarchive">📂</button>`
435
719
  : `<button class="conv-archive" onclick="archiveConv(event,'${c.id}')" title="Archive" aria-label="Archive">🗄</button>`}
436
720
  <button class="conv-delete" onclick="deleteConv(event,'${c.id}')" title="Delete">🗑</button>
437
721
  </div>`;
438
- }).join('')}${footerHtml}`;
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}`;
439
735
  if (listHtml !== lastConvListHtml) {
440
736
  list.innerHTML = listHtml;
441
737
  lastConvListHtml = listHtml;
442
738
  }
443
739
  bindConversationContextMenu(list);
740
+ bindConversationGroupControls(list);
444
741
  ensureProcessingDotTimer(hasProcessingConversation);
445
742
  window.syncChatTitleControls?.();
446
743
  scheduleConversationListBoundaryCheck();
447
744
  }
448
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
+
449
782
  // keepTranscript: the page shows a window this payload must not replace (the
450
783
  // user jumped into the history; the payload is the end of the conversation).
451
784
  // Everything but the messages still applies.
@@ -481,15 +814,22 @@ export function applyLoadedConversationState(id, response, {
481
814
  runtimeWorkspaceRootName: response.runtimeWorkspaceRootName ?? existingConversation.runtimeWorkspaceRootName ?? null,
482
815
  currentWorkspaceRootPath: response.currentWorkspaceRootPath ?? existingConversation.currentWorkspaceRootPath ?? null,
483
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.
484
819
  runtimeProviderType: response.runtimeSession?.providerType
820
+ ?? response.runtimeProviderType
485
821
  ?? existingConversation.runtimeProviderType
486
- ?? 'github',
822
+ ?? (IS_SHARED_VIEW ? null : 'github'),
487
823
  runtimeProviderModel: response.runtimeSession?.providerModel
488
824
  ?? existingConversation.runtimeProviderModel
489
825
  ?? null,
490
826
  runtimeModel: response.runtimeSession?.model
491
827
  ?? existingConversation.runtimeModel
492
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 } : {}),
493
833
  sessionUsageSummary: response.sessionUsageSummary ?? existingConversation.sessionUsageSummary ?? null,
494
834
  // Claude Cloud only: repository, branch, session link, pushed branches.
495
835
  // An explicit null is a real answer ("not a cloud chat"); only a payload
@@ -519,6 +859,9 @@ export function applyLoadedConversationState(id, response, {
519
859
  // Absent from an older relay's payload: keep what the socket said.
520
860
  if ('pins' in response) setConversationPins(id, response.pins, response.pinsRevision);
521
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);
522
865
  const didRenderMessages = keepTranscript
523
866
  ? false
524
867
  : renderMessages(response.messages, !restoreScroll, response);
@@ -639,10 +982,15 @@ export async function openConversation(id, options = {}) {
639
982
  return;
640
983
  }
641
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;
642
989
  // Counted when the window is put on the page, not when it is asked for:
643
990
  // a reload of the end that is still on its way must see the change.
644
991
  if (forceFreshWindow) noteTranscriptJump();
645
992
  applyLoadedConversationState(id, r, { restoreScroll, savedScrollTop });
993
+ if (reading) restoreReadingBlock(messagesEl, reading);
646
994
  if (focusMessageId) {
647
995
  requestAnimationFrame(() => {
648
996
  focusConversationMessageById(focusMessageId, { behavior: 'smooth', block: 'center' });
@@ -1886,7 +2234,7 @@ function announceConversationFilterResults() {
1886
2234
  if (status.textContent !== text) status.textContent = text;
1887
2235
  }
1888
2236
 
1889
- function setConversationListFilter(text) {
2237
+ export function setConversationListFilter(text) {
1890
2238
  const next = String(text ?? '');
1891
2239
  if (next === conversationListFilterText) return;
1892
2240
  conversationListFilterText = next;
@@ -2053,6 +2401,35 @@ function conversationMenuItems(id) {
2053
2401
  if (!isCurrent) await openConversation(id);
2054
2402
  then();
2055
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
+ ];
2056
2433
  return [
2057
2434
  { id: 'open', label: 'Open', onSelect: () => { void openConversation(id); } },
2058
2435
  { id: 'edit-title', label: 'Edit title', onSelect: () => { void openFirst(() => window.openChatTitleEditor?.()); } },
@@ -2060,6 +2437,8 @@ function conversationMenuItems(id) {
2060
2437
  { id: 'stop-turn', label: 'Stop turn', disabled: !running, onSelect: () => { void openFirst(() => openStopTurnConfirmationForConversation(id)); } },
2061
2438
  { id: 'kill-session', label: 'Kill session', disabled: !workerState && !sdkSessionId, onSelect: () => { void openFirst(() => window.openKillSessionConfirmation?.()); } },
2062
2439
  MENU_SEPARATOR,
2440
+ ...groupEntries,
2441
+ MENU_SEPARATOR,
2063
2442
  conversation.archived === true
2064
2443
  ? { id: 'unarchive', label: 'Unarchive', onSelect: () => { void unarchiveConv(null, id); } }
2065
2444
  : { id: 'archive', label: 'Archive', onSelect: () => { void archiveConv(null, id); } },
@@ -2067,14 +2446,390 @@ function conversationMenuItems(id) {
2067
2446
  ];
2068
2447
  }
2069
2448
 
2070
- /** Right-click (and long press on touch) on a row opens its menu. Bound once; the list's rows are re-rendered freely. */
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
+ */
2071
2811
  function bindConversationContextMenu(list) {
2072
2812
  if (!list || list.dataset.contextMenuBound === '1') return;
2073
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
+ });
2074
2827
  const openFor = (row, x, y) => {
2075
2828
  const id = String(row?.dataset?.conversationId || '').trim();
2076
- if (!id) return;
2077
- const items = conversationMenuItems(id);
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) : []);
2078
2833
  if (items.length) openContextMenu(x, y, items);
2079
2834
  };
2080
2835
  list.addEventListener('contextmenu', (event) => {
@@ -2083,7 +2838,17 @@ function bindConversationContextMenu(list) {
2083
2838
  event.preventDefault();
2084
2839
  openFor(row, event.clientX, event.clientY);
2085
2840
  });
2086
- bindLongPress(list, '.conv-item', (row, x, y) => openFor(row, x, y));
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
+ });
2087
2852
  }
2088
2853
 
2089
2854
  export async function deleteConv(e, id) {