@oar-sh/oar 0.9.20 → 0.9.22

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 (131) hide show
  1. package/CHANGELOG.md +325 -0
  2. package/README.md +13 -13
  3. package/bin/oar.js +2 -2
  4. package/package.json +1 -1
  5. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +231 -1
  6. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +31 -2
  7. package/server/claude-cloud-worker/claude-cloud-worker-steering.mjs +34 -0
  8. package/server/claude-worker/claude-sdk-adapter.mjs +149 -4
  9. package/server/claude-worker/claude-session-process.mjs +16 -4
  10. package/server/claude-worker/claude-session-worker-main.mjs +18 -2
  11. package/server/copilot-worker/copilot-sdk-session-process.mjs +75 -1
  12. package/server/copilot-worker/copilot-sdk-session-worker.mjs +18 -2
  13. package/server/cursor-worker/cursor-session-worker.mjs +21 -2
  14. package/server/cursor-worker/cursor-worker-steering.mjs +20 -0
  15. package/server/db-schema.mjs +19 -0
  16. package/server/grok-worker/grok-background-tasks.mjs +10 -0
  17. package/server/grok-worker/grok-session-worker.mjs +22 -2
  18. package/server/grok-worker/grok-turn-runner.mjs +4 -0
  19. package/server/grok-worker/grok-worker-steering.mjs +23 -0
  20. package/server/migrations/0037-relay-asks-queue-id.mjs +66 -0
  21. package/server/migrations/0038-turn-seq.mjs +92 -0
  22. package/server/public/app/about-ui.js +1 -1
  23. package/server/public/app/about-view.mjs +19 -0
  24. package/server/public/app/action-confirmations.js +1 -1
  25. package/server/public/app/activity-replay-state.mjs +160 -42
  26. package/server/public/app/api-client.js +11 -2
  27. package/server/public/app/ask-user-view.js +24 -9
  28. package/server/public/app/attachments-view.js +1 -1
  29. package/server/public/app/back-guard.mjs +31 -5
  30. package/server/public/app/bootstrap.js +618 -72
  31. package/server/public/app/chat-actions-menu-state.mjs +141 -4
  32. package/server/public/app/chat-header-meta.mjs +27 -0
  33. package/server/public/app/claude-billing-toggle-ui.js +2 -1
  34. package/server/public/app/claude-cloud-conversation-ui.js +2 -2
  35. package/server/public/app/composer-settings-label.mjs +90 -0
  36. package/server/public/app/composer-settings-ui.js +442 -0
  37. package/server/public/app/context-menu.mjs +36 -5
  38. package/server/public/app/context-usage-view.mjs +68 -0
  39. package/server/public/app/conversation-deep-link.mjs +15 -11
  40. package/server/public/app/conversation-group-drag.mjs +12 -2
  41. package/server/public/app/conversation-group-order.mjs +9 -2
  42. package/server/public/app/conversation-group-state.mjs +47 -10
  43. package/server/public/app/conversation-origin-ui.js +17 -1
  44. package/server/public/app/conversation-view.js +309 -49
  45. package/server/public/app/cwd-menu-placement.mjs +18 -6
  46. package/server/public/app/cwd-picker.js +36 -6
  47. package/server/public/app/emoji-view.js +1 -1
  48. package/server/public/app/font-scaling.js +26 -0
  49. package/server/public/app/header-priority-actions.mjs +238 -0
  50. package/server/public/app/held-details.mjs +202 -0
  51. package/server/public/app/hidden-messages-view.mjs +4 -0
  52. package/server/public/app/inbox-ui.js +88 -81
  53. package/server/public/app/inbox-view.mjs +35 -19
  54. package/server/public/app/journal-view.js +328 -58
  55. package/server/public/app/live-activity-cap.mjs +103 -0
  56. package/server/public/app/phone-landscape-ui.js +225 -0
  57. package/server/public/app/phone-landscape.mjs +99 -0
  58. package/server/public/app/phone-typing-state.mjs +104 -0
  59. package/server/public/app/pinned-messages-view.mjs +5 -0
  60. package/server/public/app/push-settings.js +2 -2
  61. package/server/public/app/pwa-install.js +24 -3
  62. package/server/public/app/questions-panel-ui.js +634 -0
  63. package/server/public/app/questions-stub-view.mjs +40 -0
  64. package/server/public/app/questions-view.mjs +89 -0
  65. package/server/public/app/relay-board-view.js +12 -2
  66. package/server/public/app/relay-event-subscriptions-ui.js +71 -22
  67. package/server/public/app/relay-event-view.mjs +38 -0
  68. package/server/public/app/relay-restart-ui.js +2 -2
  69. package/server/public/app/relay-schedule-view.mjs +32 -6
  70. package/server/public/app/relay-schedules-ui.js +69 -20
  71. package/server/public/app/relay-thoughts.mjs +15 -1
  72. package/server/public/app/remote-relay-origin-view.mjs +41 -23
  73. package/server/public/app/session-history-ui.js +251 -0
  74. package/server/public/app/session-history.mjs +599 -0
  75. package/server/public/app/settings-modal.js +67 -6
  76. package/server/public/app/settings-tabs.js +12 -0
  77. package/server/public/app/share-state.js +1 -1
  78. package/server/public/app/shared-conversations-settings.js +2 -2
  79. package/server/public/app/socket-handlers.js +47 -13
  80. package/server/public/app/status-view.mjs +18 -2
  81. package/server/public/app/store.js +87 -16
  82. package/server/public/app/stream-view-preference.mjs +60 -0
  83. package/server/public/app/subagent-fold.mjs +6 -3
  84. package/server/public/app/subagent-timeline-view.mjs +12 -5
  85. package/server/public/app/subagent-viewer.mjs +35 -8
  86. package/server/public/app/tab-strip-fade.mjs +101 -0
  87. package/server/public/app/tap-actions.js +66 -0
  88. package/server/public/app/thoughts-preference.mjs +79 -0
  89. package/server/public/app/turn-stream-view.mjs +124 -0
  90. package/server/public/app/usage-limit-ui.js +63 -2
  91. package/server/public/app/viewer-history.mjs +13 -2
  92. package/server/public/index.html +2603 -1767
  93. package/server/relay-tools.md +1 -0
  94. package/server/repositories/message-repository.mjs +33 -0
  95. package/server/repositories/question-repository.mjs +51 -7
  96. package/server/repositories/relay-turn-rows.mjs +56 -0
  97. package/server/repositories/session-repository.mjs +19 -0
  98. package/server/routes/ask-user-routes.mjs +34 -5
  99. package/server/routes/claude-cloud-routes.mjs +19 -0
  100. package/server/routes/messages-routes.mjs +92 -15
  101. package/server/routes/relay-ask-routes.mjs +15 -5
  102. package/server/routes/sessions-routes.mjs +117 -12
  103. package/server/server-runtime.mjs +367 -170
  104. package/server/services/about-service.mjs +22 -53
  105. package/server/services/claude-billing-swap-service.mjs +7 -20
  106. package/server/services/claude-cloud-session-service.mjs +47 -5
  107. package/server/services/cloudflared-tunnel-service.mjs +7 -0
  108. package/server/services/idle-timers.mjs +184 -0
  109. package/server/services/queue-wake-hook.mjs +145 -0
  110. package/server/services/queue-waker.mjs +127 -0
  111. package/server/services/relay-ask-service.mjs +64 -5
  112. package/server/services/relay-event-service.mjs +4 -1
  113. package/server/services/remote-relay-dispatcher.mjs +84 -18
  114. package/server/services/session-activity-tracker.mjs +1 -1
  115. package/server/services/session-history-refresh-service.mjs +4 -0
  116. package/server/services/session-launch-mutex-service.mjs +23 -0
  117. package/server/services/session-worker-registry-service.mjs +36 -0
  118. package/server/services/session-worker-supervisor-service.mjs +36 -0
  119. package/server/services/session-worker-websocket-service.mjs +183 -31
  120. package/server/services/ssh-tunnel-manager-service.mjs +7 -0
  121. package/server/services/worker-recycle-service.mjs +772 -0
  122. package/server/services/worker-replacement.mjs +197 -0
  123. package/shared/ask-user-bridge.mjs +9 -6
  124. package/shared/build-id.mjs +128 -0
  125. package/shared/relay-asks.mjs +10 -0
  126. package/shared/relay-events.mjs +1 -0
  127. package/shared/worker-runtime/heartbeat.mjs +31 -0
  128. package/shared/worker-runtime/simple-steering-state.mjs +38 -0
  129. package/shared/worker-runtime/worker-keepalive.mjs +108 -0
  130. package/shared/worker-runtime/worker-websocket-link.mjs +44 -10
  131. package/shared/worker-runtime/working-directory.mjs +1 -1
@@ -16,8 +16,9 @@ import {
16
16
  updateSessionPill,
17
17
  updateCompactButton,
18
18
  closeSidebar,
19
+ collapseSidebarAfterListPick,
19
20
  applyContextUsageBar,
20
- isMobileComposerViewport,
21
+ isPhoneComposerViewport,
21
22
  releaseComposerFocusAfterSend,
22
23
  saveConversationScrollTop,
23
24
  loadConversationReadingAnchor,
@@ -34,6 +35,7 @@ import {
34
35
  pruneConversationGroups,
35
36
  hasSessionWorkerStatus,
36
37
  setConversationGroupFoldChoice,
38
+ setConversationGroupFoldChoices,
37
39
  } from './store.js';
38
40
  import { archiveConfirmationFor } from './archive-confirm.mjs';
39
41
  import { arrangeConversationList, conversationGroupPosition } from './conversation-group-order.mjs';
@@ -41,9 +43,13 @@ import { describeDragItems, dropAnnouncement, groupMoveStep } from './conversati
41
43
  import { bindConversationListDrag } from './conversation-list-drag.mjs';
42
44
  import { applyFetchedTurnStateToConversation } from './conversation-turn-state.mjs';
43
45
  import {
46
+ groupActivityText,
44
47
  groupIsActive,
48
+ groupSessionCountText,
45
49
  groupSummaryText,
50
+ groupTitleTone,
46
51
  memberStateClass,
52
+ memberTone,
47
53
  resolveGroupFold,
48
54
  summarizeGroupMembers,
49
55
  toggledGroupFoldChoice,
@@ -85,6 +91,7 @@ import { runRelayErrorCta, renderMessages, renderNoConversationState, restoreInF
85
91
  import { mergeTailPage } from './transcript-reading-anchor.mjs';
86
92
  import { readWindowRetention } from './conversation-window-retention.mjs';
87
93
  import { MENU_SEPARATOR, bindLongPress, closeContextMenu, openContextMenu } from './context-menu.mjs';
94
+ import { getUiScale } from './font-scaling.js';
88
95
  import {
89
96
  isBackgroundTasksSnapshotCurrent,
90
97
  setBackgroundTasksConversation,
@@ -93,6 +100,7 @@ import {
93
100
  setConversationTaskStallMinutes,
94
101
  } from './background-tasks-view.mjs';
95
102
  import { setConversationPins, setPinsConversation } from './pinned-messages-view.mjs';
103
+ import { setQuestionsConversation } from './questions-panel-ui.js';
96
104
  import { setConversationHiddenMessages, setHiddenMessagesConversation } from './hidden-messages-view.mjs';
97
105
  import { setUsageLimitConversation } from './usage-limit-ui.js';
98
106
  import { mergeConversationPreviews } from './preview-cards.mjs';
@@ -102,6 +110,11 @@ import { clearAttachments, setRepoBrowserSessionInfo, loadRepoBrowserTree, getRe
102
110
  import { buildKnownCwdOptions, normalizeKnownCwdPath } from './known-cwd-options.mjs';
103
111
  import { shouldApplyConversationLoad } from './activity-replay-state.mjs';
104
112
  import { conversationPayloadCache, payloadHasMoreOlder, windowPageInfo } from './conversation-payload-cache.mjs';
113
+ import { createHeldDetailsStore } from './held-details.mjs';
114
+
115
+ // The finished replies' details this page holds of the open conversation
116
+ // (the live poll's `heldDetails`, held-details.mjs).
117
+ export const heldDetailsStore = createHeldDetailsStore();
105
118
  import { loadKeepingLiveRows, shouldKeepTranscriptOnReload } from './transcript-reload-freshness.mjs';
106
119
  import { createInfiniteLoader } from './infinite-loader.js';
107
120
  import {
@@ -136,9 +149,16 @@ import {
136
149
  resolveCloudSourceAutoFill,
137
150
  validateCloudSourceInputs,
138
151
  } from './claude-cloud-ui.mjs';
139
- import { renderConversationOriginMarkerHtml } from './remote-relay-origin-view.mjs';
140
- import { relayEventWatchedMarkerHtml } from './relay-event-subscriptions-ui.js';
141
- import { relayScheduleMarkerHtml } from './relay-schedules-ui.js';
152
+ import { conversationOriginInfo } from './remote-relay-origin-view.mjs';
153
+ import { relayEventWatchedInfoText } from './relay-event-subscriptions-ui.js';
154
+ import { relayScheduleInfoText } from './relay-schedules-ui.js';
155
+ import {
156
+ noteConversationCleared,
157
+ noteConversationGone,
158
+ noteConversationMissing,
159
+ noteConversationShown,
160
+ syncSessionDocumentTitle,
161
+ } from './session-history-ui.js';
142
162
  import {
143
163
  normalizeConversationFilter,
144
164
  conversationMatchesFilter,
@@ -364,10 +384,11 @@ function isConversationProcessing(conversation, workerState) {
364
384
  return runtimeStatus === 'processing';
365
385
  }
366
386
 
367
- export async function loadConversations() {
387
+ // `openLast: false` when the address names the conversation to open instead.
388
+ export async function loadConversations({ openLast = true } = {}) {
368
389
  await refreshConversations({ preservePagination: false });
369
- const lastId = localStorage.getItem('copilot_last_conv');
370
- if (lastId) await openConversation(lastId, { restoreScroll: true });
390
+ const lastId = openLast ? localStorage.getItem('copilot_last_conv') : '';
391
+ if (lastId) await openConversation(lastId, { restoreScroll: true, history: 'replace' });
371
392
  }
372
393
 
373
394
  export async function refreshConversations(options = {}) {
@@ -540,6 +561,13 @@ function conversationRowState(conversation, pendingByConversation = getPendingQu
540
561
  // event or fetch said (the worker was killed, or the relay restarted).
541
562
  let sessionState = String(conversation?.sessionState || '').trim();
542
563
  if (sessionState === 'background_only' && hasSessionWorkerStatus() && !isWorkerStateLive(workerState)) sessionState = 'idle';
564
+ const memberState = memberStateClass({
565
+ pendingCount,
566
+ visualState,
567
+ processing,
568
+ activeTurn: conversation?.activeTurn === true,
569
+ sessionState,
570
+ });
543
571
  return {
544
572
  visualState,
545
573
  processing,
@@ -549,13 +577,9 @@ function conversationRowState(conversation, pendingByConversation = getPendingQu
549
577
  // "Stop all running" acts on, and what keeps an archive from it.
550
578
  live: processing || pendingCount > 0 || conversation?.activeTurn === true || sessionState === 'running_turn' || sessionState === 'background_only',
551
579
  // The member class the group's head row sums up (conversation-group-state.mjs).
552
- memberState: memberStateClass({
553
- pendingCount,
554
- visualState,
555
- processing,
556
- activeTurn: conversation?.activeTurn === true,
557
- sessionState,
558
- }),
580
+ memberState,
581
+ // Its colour in the group's name.
582
+ tone: memberTone({ memberState, visualState }),
559
583
  };
560
584
  }
561
585
 
@@ -571,6 +595,14 @@ function groupDisplayName(groupId) {
571
595
  return String(info?.name || head?.title || info?.displayName || 'Group').trim() || 'Group';
572
596
  }
573
597
 
598
+ /** A synthetic head's meta line: when its newest row was active, and how many rows it has. */
599
+ function groupMetaText(group, summary) {
600
+ const ms = Number(group?.activityMs) || 0;
601
+ const when = ms > 0 ? fmtDate(new Date(ms).toISOString()) : '';
602
+ const count = groupSessionCountText(summary?.count || 0);
603
+ return when ? `${when} · ${count}` : count;
604
+ }
605
+
574
606
  /** The loaded rows of a group, in the current view and across it. */
575
607
  function groupMemberRows(groupId, { inViewOnly = true } = {}) {
576
608
  return Object.values(conversations).filter((conversation) => String(conversation?.groupId || '') === groupId
@@ -580,15 +612,16 @@ function groupMemberRows(groupId, { inViewOnly = true } = {}) {
580
612
  /**
581
613
  * What a head row shows for its group: the classes of every row in it, the
582
614
  * head included (the menu's "Archive group (N)" and "Stop all running (N)"
583
- * count the same rows), their summary, and the first row a waiting card
584
- * sits in.
615
+ * count the same rows), their summary, the colour of the group's name, and
616
+ * the first row a waiting card sits in.
585
617
  */
586
618
  function describeGroupForHead(group, viewOf) {
587
619
  const rows = group.head ? [group.head, ...group.members] : [...group.members];
588
620
  const states = rows.map((row) => viewOf(row).memberState);
589
621
  const summary = summarizeGroupMembers(states);
622
+ const titleTone = groupTitleTone(rows.map((row) => viewOf(row).tone));
590
623
  const firstWaiting = rows.find((row) => viewOf(row).memberState === 'waiting') || null;
591
- return { summary, firstWaiting, forcedOpen: false };
624
+ return { summary, titleTone, firstWaiting, forcedOpen: false };
592
625
  }
593
626
 
594
627
  export function renderConvList() {
@@ -618,31 +651,32 @@ function paintConvList() {
618
651
  if (!rowViews.has(id)) rowViews.set(id, conversationRowState(conversation, pendingByConversation));
619
652
  return rowViews.get(id);
620
653
  };
621
- const foldChoices = readConversationGroupFoldChoices();
654
+ const foldChoices = readConversationGroupFoldChoices({ archived: conversationListArchivedView });
622
655
  const groupDescriptions = new Map();
623
656
  // Groups order with the same rules the list route pages by; a group is
624
657
  // folded once nothing in it runs or waits, unless the user chose otherwise.
658
+ // A folded group still shows the open conversation under its head.
625
659
  const items = arrangeConversationList(inView, conversationGroups, {
626
660
  matches: activeFilter ? (row) => conversationMatchesFilter(row, activeFilter) : null,
627
661
  groupNameMatches: activeFilter ? (info) => conversationMatchesFilter({ title: info?.displayName || info?.name }, activeFilter) : null,
628
662
  // An owner's group an agent emptied shows in the live list, empty.
629
663
  emptyGroups: !conversationListArchivedView,
664
+ keepVisibleId: currentConvId || null,
630
665
  isOpen: (groupId, group) => {
631
666
  const described = describeGroupForHead(group, viewOf);
632
- const holdsOpenConversation = !!currentConvId && group.members.some((row) => row.id === currentConvId);
633
667
  // Open whatever the choice: the fold button then says so and does nothing.
634
- described.forcedOpen = holdsOpenConversation || !!activeFilter;
668
+ described.forcedOpen = !!activeFilter;
635
669
  groupDescriptions.set(groupId, described);
636
670
  if (convListTransientOpenGroups.has(groupId)) return true;
637
671
  return resolveGroupFold({
638
672
  choice: foldChoices[groupId] || null,
639
673
  active: groupIsActive(described.summary),
640
- holdsOpenConversation,
641
674
  filterActive: !!activeFilter,
642
675
  });
643
676
  },
644
677
  });
645
678
  lastConvListDragItems = describeDragItems(items);
679
+ syncConversationGroupsFoldAllButton(items, !!activeFilter);
646
680
  const footerHtml = (() => {
647
681
  if (activeFilter) {
648
682
  // While a filter is active the drain loads older pages by itself; a
@@ -664,30 +698,31 @@ function paintConvList() {
664
698
  const emptyText = activeFilter && inView.length > 0
665
699
  ? 'No conversations match your filter'
666
700
  : (conversationListArchivedView ? 'No archived conversations' : 'No conversations yet');
667
- list.innerHTML = `<div style="padding:12px;color:var(--muted);font-size:0.85rem;text-align:center">${emptyText}</div>${footerHtml}`;
701
+ list.innerHTML = `<div style="padding:calc(6px * var(--ui-scale)) calc(7px * var(--ui-scale));color:var(--muted);font-size:0.85rem;text-align:center">${emptyText}</div>${footerHtml}`;
668
702
  lastConvListHtml = '';
669
703
  scheduleConversationListBoundaryCheck();
670
704
  return;
671
705
  }
672
706
  // The head row's pill: the member count and what the members are doing;
673
707
  // while folded, its "waiting" part opens the member the card sits in.
674
- const groupPillHtml = (item) => {
708
+ // withCount: false on a synthetic head, whose meta line says the count.
709
+ const groupPillHtml = (item, { withCount = true } = {}) => {
675
710
  const described = groupDescriptions.get(item.group.id) || describeGroupForHead(item.group, viewOf);
676
- const text = groupSummaryText(described.summary);
711
+ const text = withCount ? groupSummaryText(described.summary) : groupActivityText(described.summary);
677
712
  if (!text) return '';
678
713
  const waitingId = described.firstWaiting ? String(described.firstWaiting.id) : '';
679
714
  // Inline like the row's own buttons: the row's click handler must not
680
715
  // run (it would open the head and re-render the list first).
681
716
  // The full text is the title: a narrow sidebar shortens the pill with an ellipsis.
682
717
  const title = waitingId ? `${text} · open the session that is waiting for you` : text;
683
- 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>`;
718
+ 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)}',{fromList:true})"` : ''}>${escHtml(text)}</span>`;
684
719
  };
685
720
  const foldButtonHtml = (item) => {
686
721
  const forced = (groupDescriptions.get(item.group.id) || {}).forcedOpen === true || item.group.contextOnly === true || !!activeFilter;
687
722
  const label = forced
688
- ? (activeFilter ? 'Open while the filter is on' : 'Open while its chat is open')
723
+ ? 'Open while the filter is on'
689
724
  : (item.group.open ? 'Fold group' : 'Unfold group');
690
- 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>`;
725
+ 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)}')"><span class="conv-group-chevron${convListFoldTurning.has(item.group.id) ? ' conv-group-chevron-turn' : ''}" aria-hidden="true">›</span></button>`;
691
726
  };
692
727
  const rowHtml = (c, item) => {
693
728
  const view = viewOf(c);
@@ -696,30 +731,43 @@ function paintConvList() {
696
731
  const providerIndicatorHtml = providerIndicatorLabel
697
732
  ? `<span class="conv-provider-indicator"${providerIndicatorKey ? ` data-provider="${providerIndicatorKey}"` : ''}>${providerIndicatorLabel}</span>`
698
733
  : '';
699
- // "↗ win-test": another relay's agent created this conversation.
700
- // "via agent · “title”": an agent in that conversation on this relay did.
701
- // A member under its creator's head row says that already, so it is left out there.
702
- const originMarkerHtml = item.kind === 'member' && item.group.head && c.origin?.local === true && c.origin.conversationId === item.group.head.id
703
- ? ''
704
- : renderConversationOriginMarkerHtml(c, { conversations });
705
- // "watched": a session another conversation watches by id (relay events).
706
- const watchedMarkerHtml = relayEventWatchedMarkerHtml(c.id);
707
- // "scheduled": a schedule fires into this session (relay_tool schedule).
708
- const scheduledMarkerHtml = relayScheduleMarkerHtml(c.id);
734
+ // The ⓘ and its menu: an agent started this conversation (here or on
735
+ // another relay), another conversation watches it (relay events), a
736
+ // schedule fires into it (relay_tool schedule). A member under its
737
+ // creator's head row says the origin already, so it is left out there.
738
+ const origin = item.kind === 'member' && item.group.head && c.origin?.local === true && c.origin.conversationId === item.group.head.id
739
+ ? null
740
+ : conversationOriginInfo(c, { conversations });
741
+ const infoEntries = conversationRowInfoEntries({
742
+ origin,
743
+ watched: relayEventWatchedInfoText(c.id),
744
+ scheduled: relayScheduleInfoText(c.id),
745
+ });
746
+ if (infoEntries.length) convListRowInfo.set(String(c.id), infoEntries);
747
+ const infoButtonHtml = conversationRowInfoButtonHtml(c.id, infoEntries);
709
748
  const groupClass = item.kind === 'head' ? ' conv-group-head' : (item.kind === 'member' ? ' conv-group-member' : '');
710
749
  const groupAttr = item.kind === 'head' || item.kind === 'member' ? ` data-group-id="${escHtml(item.group.id)}"` : '';
750
+ // A head row's title is the group's name, coloured by all its rows.
751
+ const toneAttr = item.kind === 'head' ? ` data-group-tone="${escHtml((groupDescriptions.get(item.group.id) || describeGroupForHead(item.group, viewOf)).titleTone)}"` : '';
711
752
  // A head row shows the name the group was given by hand; until then the
712
753
  // conversation's own title (the group's name follows it).
713
754
  const handName = item.kind === 'head' ? String(item.group.info?.name || '').trim() : '';
714
755
  const rowTitle = handName || c.title;
715
756
  const titleAttr = handName && handName !== c.title ? ` title="${escHtml(`${handName} · ${c.title}`)}"` : '';
757
+ const titleHtml = `<div class="conv-title"${titleAttr}>${item.kind === 'head' ? foldButtonHtml(item) : ''}${escHtml(rowTitle)}</div>`;
758
+ // With open questions the title and its "N open" pill share a row in
759
+ // which only the title gives way; the pill is never cut.
760
+ const openCount = pendingByConversation[c.id];
761
+ const titleRowHtml = openCount
762
+ ? `<div class="conv-title-row">${titleHtml}<span class="conv-open-questions">${openCount} open</span></div>`
763
+ : titleHtml;
716
764
  return `
717
- <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}')">
718
- <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>
719
- <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}${scheduledMarkerHtml}${providerIndicatorHtml}</div>
765
+ <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}${toneAttr} onclick="openConversation('${c.id}',{fromList:true})">
766
+ ${titleRowHtml}
767
+ <div class="conv-meta"><span class="conv-meta-primary"><span class="conv-meta-date">${fmtDate(c.updatedAt)}</span><span class="conv-meta-count"> · ${c.messageCount} msg${c.messageCount !== 1 ? 's' : ''}</span></span>${item.kind === 'head' ? groupPillHtml(item) : ''}${infoButtonHtml}${providerIndicatorHtml}</div>
720
768
  ${c.archived
721
769
  ? `<button class="conv-archive" onclick="unarchiveConv(event,'${c.id}')" title="Unarchive" aria-label="Unarchive">📂</button>`
722
- : `<button class="conv-archive" onclick="archiveConv(event,'${c.id}')" title="Archive" aria-label="Archive">🗄</button>`}
770
+ : `<button class="conv-archive" onclick="archiveConv(event,'${c.id}')" title="Archive" aria-label="Archive">🗄️</button>`}
723
771
  <button class="conv-delete" onclick="deleteConv(event,'${c.id}')" title="Delete">🗑</button>
724
772
  </div>`;
725
773
  };
@@ -733,16 +781,17 @@ function paintConvList() {
733
781
  return `
734
782
  <div class="conv-item conv-group-head conv-group-synthetic conv-group-empty worker-ui-offline" data-group-id="${escHtml(item.group.id)}" title="An agent moved the last session out of this group; rename or dissolve it from its menu.">
735
783
  <div class="conv-title">${escHtml(name)}</div>
736
- <div class="conv-meta"><span class="conv-meta-primary">group · empty</span></div>
784
+ <div class="conv-meta"><span class="conv-meta-primary">${groupSessionCountText(0)}</span></div>
737
785
  </div>`;
738
786
  }
739
787
  const described = groupDescriptions.get(item.group.id) || describeGroupForHead(item.group, viewOf);
740
788
  return `
741
- <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)}')">
789
+ <div class="conv-item conv-group-head conv-group-synthetic worker-ui-offline" data-group-id="${escHtml(item.group.id)}" data-group-tone="${escHtml(described.titleTone)}" onclick="toggleConversationGroupFold(event,'${escHtml(item.group.id)}')">
742
790
  <div class="conv-title">${foldButtonHtml(item)}${escHtml(name)}</div>
743
- <div class="conv-meta"><span class="conv-meta-primary">group</span>${groupPillHtml(item)}</div>
791
+ <div class="conv-meta"><span class="conv-meta-primary">${escHtml(groupMetaText(item.group, described.summary))}</span>${groupPillHtml(item, { withCount: false })}</div>
744
792
  </div>`;
745
793
  };
794
+ convListRowInfo.clear();
746
795
  const listHtml = `${items.map((item) => (item.conversation ? rowHtml(item.conversation, item) : syntheticHeadHtml(item))).join('')}${footerHtml}`;
747
796
  if (listHtml !== lastConvListHtml) {
748
797
  list.innerHTML = listHtml;
@@ -750,6 +799,7 @@ function paintConvList() {
750
799
  }
751
800
  bindConversationContextMenu(list);
752
801
  bindConversationGroupControls(list);
802
+ bindConversationInfoButtons(list);
753
803
  window.syncChatTitleControls?.();
754
804
  scheduleConversationListBoundaryCheck();
755
805
  }
@@ -767,15 +817,105 @@ function bindConversationGroupControls(list) {
767
817
  if (!waiting || !list.contains(waiting)) return;
768
818
  event.preventDefault();
769
819
  event.stopPropagation();
770
- void (window.openConversation || openConversation)(waiting.dataset.openWaiting);
820
+ void (window.openConversation || openConversation)(waiting.dataset.openWaiting, { fromList: true });
821
+ });
822
+ }
823
+
824
+ // ─── The row's ⓘ: who started it, who watches it, what is scheduled ─────────
825
+ // One small button in the meta line stands for all three; its menu lists
826
+ // them, each with its action (or as a line of text where there is none).
827
+
828
+ // The menu entries of the rows as last drawn, by conversation id.
829
+ const convListRowInfo = new Map();
830
+
831
+ /**
832
+ * The entries of a row's ⓘ menu, in a fixed order. `origin` is
833
+ * conversationOriginInfo()'s answer, `watched` and `scheduled` their lines
834
+ * ('' for none). `short` is the entry's word in the button's label.
835
+ */
836
+ function conversationRowInfoEntries({ origin = null, watched = '', scheduled = '' } = {}) {
837
+ const entries = [];
838
+ if (origin?.label) entries.push({ id: 'origin', short: 'started by an agent', label: origin.label, origin });
839
+ if (watched) entries.push({ id: 'watched', short: 'watched', label: watched });
840
+ if (scheduled) entries.push({ id: 'scheduled', short: 'scheduled', label: scheduled });
841
+ return entries;
842
+ }
843
+
844
+ function conversationRowInfoButtonHtml(conversationId, entries) {
845
+ if (!entries.length) return '';
846
+ const label = `Details: ${entries.map((entry) => entry.short).join(', ')}`;
847
+ return `<button type="button" class="conv-info-btn" data-conv-info="${escHtml(conversationId)}" aria-haspopup="menu" aria-label="${escHtml(label)}" title="${escHtml(label)}">ⓘ</button>`;
848
+ }
849
+
850
+ function conversationRowInfoMenuItems(entries) {
851
+ return entries.map((entry) => {
852
+ const origin = entry.id === 'origin' ? entry.origin : null;
853
+ if (origin?.kind === 'local' && origin.linkable) {
854
+ // As the header's "via agent" marker: openOriginConversation says so
855
+ // when that conversation is gone by now.
856
+ return { id: entry.id, label: entry.label, onSelect: () => { void openOriginConversation(null, { dataset: { originConversationId: origin.conversationId } }, { fromList: true }); } };
857
+ }
858
+ if (origin?.kind === 'remote' && origin.href) {
859
+ // As the message badge of such an agent: its session on that relay.
860
+ return { id: entry.id, label: entry.label, onSelect: () => { window.open(origin.href, '_blank', 'noopener'); } };
861
+ }
862
+ return { id: entry.id, label: entry.label, note: true };
771
863
  });
772
864
  }
773
865
 
866
+ /** Opens the ⓘ menu of a row under its button. */
867
+ function openConversationRowInfoMenu(button) {
868
+ const id = String(button?.dataset?.convInfo || '').trim();
869
+ const entries = convListRowInfo.get(id);
870
+ if (!entries?.length) return;
871
+ const rect = button.getBoundingClientRect();
872
+ const gap = 4 * getUiScale();
873
+ // Esc is a key, so the returned focus would show its ring (and the row's
874
+ // archive and delete icons) even after a mouse click: it shows only if
875
+ // the button had a visible focus when the menu opened.
876
+ const focusVisible = !!button.matches?.(':focus-visible');
877
+ openContextMenu(rect.left, rect.bottom + gap, conversationRowInfoMenuItems(entries), {
878
+ // The list may have been drawn anew meanwhile: the row's button by id.
879
+ onEscape: () => {
880
+ document.querySelector(`#conv-list .conv-info-btn[data-conv-info="${CSS.escape(id)}"]`)?.focus?.({ preventScroll: true, focusVisible });
881
+ },
882
+ });
883
+ }
884
+
885
+ /**
886
+ * The ⓘ opens its menu and never the row: caught on the way down (capture),
887
+ * before the row's own click handler opens the conversation. Bound once.
888
+ */
889
+ function bindConversationInfoButtons(list) {
890
+ if (!list || list.dataset.infoButtonsBound === '1') return;
891
+ list.dataset.infoButtonsBound = '1';
892
+ list.addEventListener('click', (event) => {
893
+ const button = event.target?.closest?.('.conv-info-btn');
894
+ if (!button || !list.contains(button)) return;
895
+ event.stopPropagation();
896
+ // The release of a long press that opened the row's menu: that menu stays.
897
+ if (event.defaultPrevented) return;
898
+ event.preventDefault();
899
+ openConversationRowInfoMenu(button);
900
+ }, true);
901
+ }
902
+
774
903
  /**
775
- * Folds or unfolds a group; the choice is kept until the user changes it. A
776
- * group held open (its chat is open, or a filter is on) keeps its choice.
904
+ * Folds or unfolds a group; the choice is kept until the user changes it,
905
+ * apart for the live list and the Archived view. A group held open by a
906
+ * filter keeps its choice. A folded group still shows the open conversation.
777
907
  * The fold button keeps the focus across the re-render.
778
908
  */
909
+ // Groups whose fold the user just toggled: their chevron turns once on the
910
+ // next paint (the list is drawn anew, so a transition has nothing to start from).
911
+ const convListFoldTurning = new Set();
912
+
913
+ function turnGroupChevrons(groupIds) {
914
+ for (const id of groupIds) convListFoldTurning.add(id);
915
+ renderConvList();
916
+ convListFoldTurning.clear();
917
+ }
918
+
779
919
  export function toggleConversationGroupFold(event, groupId) {
780
920
  event?.stopPropagation?.();
781
921
  event?.preventDefault?.();
@@ -785,9 +925,53 @@ export function toggleConversationGroupFold(event, groupId) {
785
925
  if (button?.disabled) return;
786
926
  const currentlyOpen = button ? button.getAttribute('aria-expanded') === 'true' : true;
787
927
  convListTransientOpenGroups.delete(id);
788
- setConversationGroupFoldChoice(id, toggledGroupFoldChoice(currentlyOpen));
789
- renderConvList();
790
- document.querySelector(`#conv-list [data-group-fold="${CSS.escape(id)}"]`)?.focus?.({ preventScroll: true });
928
+ setConversationGroupFoldChoice(id, toggledGroupFoldChoice(currentlyOpen), { archived: conversationListArchivedView });
929
+ turnGroupChevrons([id]);
930
+ // Keyboard toggles keep the focus; a click or tap (detail > 0) does not,
931
+ // or the redrawn button would show a focus ring.
932
+ if (!(Number(event?.detail) > 0)) {
933
+ document.querySelector(`#conv-list [data-group-fold="${CSS.escape(id)}"]`)?.focus?.({ preventScroll: true });
934
+ }
935
+ }
936
+
937
+ // The groups the fold-all button acts on: those in the list as last drawn.
938
+ let conversationListFoldableGroups = { ids: [], anyOpen: false };
939
+
940
+ /**
941
+ * The filter row's fold-all button: ➖ while a group in the list is open
942
+ * (folds them all), ➕ once all are folded (unfolds them all). Hidden
943
+ * without a group; disabled under a filter, which holds every group open.
944
+ */
945
+ function syncConversationGroupsFoldAllButton(items, filterActive) {
946
+ const heads = items.filter((item) => item.kind === 'head' && !item.group.empty);
947
+ const anyOpen = heads.some((item) => item.group.open);
948
+ conversationListFoldableGroups = { ids: heads.map((item) => item.group.id), anyOpen };
949
+ const button = document.getElementById('conv-groups-fold-all');
950
+ if (!button) return;
951
+ button.hidden = heads.length === 0;
952
+ button.disabled = filterActive;
953
+ const label = filterActive
954
+ ? 'Groups stay open while the filter is on'
955
+ : (anyOpen ? 'Fold all groups' : 'Unfold all groups');
956
+ const glyph = anyOpen ? '➖' : '➕';
957
+ if (button.textContent !== glyph) button.textContent = glyph;
958
+ if (button.title !== label) {
959
+ button.title = label;
960
+ button.setAttribute('aria-label', label);
961
+ }
962
+ }
963
+
964
+ /** Folds every group in the list, or unfolds them all once all are folded. */
965
+ export function toggleAllConversationGroupFolds(event) {
966
+ event?.stopPropagation?.();
967
+ event?.preventDefault?.();
968
+ const button = document.getElementById('conv-groups-fold-all');
969
+ if (button?.disabled) return;
970
+ const { ids, anyOpen } = conversationListFoldableGroups;
971
+ if (!ids.length) return;
972
+ for (const id of ids) convListTransientOpenGroups.delete(id);
973
+ setConversationGroupFoldChoices(ids, anyOpen ? 'folded' : 'open', { archived: conversationListArchivedView });
974
+ turnGroupChevrons(ids);
791
975
  }
792
976
 
793
977
  // keepTranscript: the page shows a window this payload must not replace (the
@@ -815,7 +999,15 @@ export function applyLoadedConversationState(id, response, {
815
999
  mergeFrom = null,
816
1000
  windowPayload = null,
817
1001
  backgroundTasksAsOf = null,
1002
+ fromPoll = false,
818
1003
  } = {}) {
1004
+ // What the page holds of the finished replies, for the live poll's
1005
+ // `heldDetails`: a full load replaces it, a poll or an older page adds to
1006
+ // it, and a poll trims it to the rows on screen.
1007
+ if (response && !IS_SHARED_VIEW) {
1008
+ const shown = fromPoll ? (response.messages || []).map((message) => message?.id) : null;
1009
+ heldDetailsStore.noteApplied(id, response, { full: !fromPoll && !keepTranscript && !mergeFrom, shownIds: shown });
1010
+ }
819
1011
  if (!response) {
820
1012
  if (!IS_SHARED_VIEW) conversationPayloadCache.forget(id);
821
1013
  setRepoBrowserSessionInfo('', '');
@@ -911,6 +1103,8 @@ export function applyLoadedConversationState(id, response, {
911
1103
  ? mergeMessagesIntoTranscript(mergeFrom, response.messages, response)
912
1104
  : renderMessages(response.messages, !restoreScroll, response);
913
1105
  }
1106
+ // 📓: a newly opened conversation loads its questions (a no-op on the poll).
1107
+ setQuestionsConversation(id);
914
1108
  hydrateConversationDraft(id, {
915
1109
  draftText: response.draftText,
916
1110
  draftAttachments: response.draftAttachments,
@@ -1000,6 +1194,7 @@ function applyCachedConversationState(id, response, { viewAnchor = null } = {})
1000
1194
  if ('hiddenMessages' in response) setConversationHiddenMessages(id, response.hiddenMessages, response.hiddenMessagesRevision);
1001
1195
  setHiddenMessagesConversation(id);
1002
1196
  const didRenderMessages = renderMessages(response.messages, false, response);
1197
+ setQuestionsConversation(id);
1003
1198
  // The panel shows this conversation's tasks as the socket keeps them.
1004
1199
  setBackgroundTasksConversation(id);
1005
1200
  window.syncChatTitleControls?.();
@@ -1010,7 +1205,8 @@ function applyCachedConversationState(id, response, { viewAnchor = null } = {})
1010
1205
  // The "via agent" marker (sidebar row, header, message badge): opens the
1011
1206
  // conversation whose agent started this one. The marker sits inside a sidebar
1012
1207
  // row that opens on click itself, so the click stops here.
1013
- export function openOriginConversation(event, element) {
1208
+ // `options` go to openConversation (`fromList` for a pick from a list row's ⓘ).
1209
+ export function openOriginConversation(event, element, options = {}) {
1014
1210
  event?.stopPropagation?.();
1015
1211
  event?.preventDefault?.();
1016
1212
  const id = String(element?.dataset?.originConversationId || '').trim();
@@ -1021,7 +1217,7 @@ export function openOriginConversation(event, element) {
1021
1217
  renderConvList();
1022
1218
  return null;
1023
1219
  }
1024
- return openConversation(id);
1220
+ return openConversation(id, options);
1025
1221
  }
1026
1222
 
1027
1223
  // The fresh answer for a conversation whose kept window is on the page (drawn
@@ -1085,6 +1281,53 @@ function applyFreshOverCachedConversation(id, response, { cached, keepTranscript
1085
1281
  saveConversationScrollTop(id, messagesEl.scrollTop);
1086
1282
  }
1087
1283
 
1284
+ const LAST_OPENED_CONVERSATION_KEY = 'copilot_last_conv';
1285
+
1286
+ function readLastOpenedConversationId() {
1287
+ try {
1288
+ return String(localStorage.getItem(LAST_OPENED_CONVERSATION_KEY) || '').trim();
1289
+ } catch {
1290
+ return '';
1291
+ }
1292
+ }
1293
+
1294
+ /** The header and the document take the title the loaded payload gave the record. */
1295
+ function syncOpenedConversationTitle(id) {
1296
+ if (String(currentConvId || '').trim() !== String(id || '').trim()) return;
1297
+ const title = String(conversations[id]?.title || '').trim();
1298
+ const titleEl = document.getElementById('chat-title');
1299
+ if (title && titleEl && titleEl.textContent !== title && !document.body.classList.contains('chat-title-editing')) {
1300
+ titleEl.textContent = title;
1301
+ window.syncChatTitleControls?.();
1302
+ }
1303
+ syncSessionDocumentTitle();
1304
+ }
1305
+
1306
+ /**
1307
+ * The relay knows no conversation `id` (deleted elsewhere, a mistyped or
1308
+ * stale link). Back or forward onto it steps on past it; anything else
1309
+ * leaves nothing open, without `?conv=` in the address, and says so. The
1310
+ * conversation the next start opens stays the one it was.
1311
+ */
1312
+ function showConversationUnknown(id, { lastOpenedBefore = '' } = {}) {
1313
+ conversationPayloadCache.forget(id);
1314
+ if (conversations[id]) {
1315
+ delete conversations[id];
1316
+ renderConvList();
1317
+ }
1318
+ // Stepped over: the pop that follows opens what lies there.
1319
+ const stepsOn = noteConversationMissing(id);
1320
+ clearOpenConversation({ history: !stepsOn });
1321
+ renderNoConversationState();
1322
+ window.syncComposerButtonState?.();
1323
+ window.syncComposerPlaceholder?.();
1324
+ try {
1325
+ if (lastOpenedBefore && lastOpenedBefore !== id) localStorage.setItem(LAST_OPENED_CONVERSATION_KEY, lastOpenedBefore);
1326
+ else localStorage.removeItem(LAST_OPENED_CONVERSATION_KEY);
1327
+ } catch {}
1328
+ showTransientRelayNotice('That conversation is not on this relay. It may have been deleted.', 6000);
1329
+ }
1330
+
1088
1331
  export async function openConversation(id, options = {}) {
1089
1332
  // The Inbox owns the transcript like the status view does.
1090
1333
  const didLeaveInboxView = leaveInboxView();
@@ -1102,7 +1345,13 @@ export async function openConversation(id, options = {}) {
1102
1345
  const switchingConversation = !!nextConversationId && previousConversationId !== nextConversationId;
1103
1346
  if (switchingConversation && previousConversationId && !IS_SHARED_VIEW) keepPlaceOfConversationLeft(previousConversationId);
1104
1347
  const capturedVersion = ++openConversationVersion;
1348
+ // Put back if the relay knows no such conversation: a wrong link must not
1349
+ // become the one the next start opens.
1350
+ const lastOpenedBefore = readLastOpenedConversationId();
1105
1351
  setCurrentConv(id);
1352
+ // One history entry per switch (`history`: 'replace' at load, 'none' when
1353
+ // back or forward opened it); reopening the one on screen adds none.
1354
+ noteConversationShown(nextConversationId, options.history);
1106
1355
  if (repoBrowserState.activeRoot === 'workspace') {
1107
1356
  repoBrowserState.tree = null;
1108
1357
  repoBrowserState.nodeMap = new Map();
@@ -1114,6 +1363,9 @@ export async function openConversation(id, options = {}) {
1114
1363
  repoBrowserState.error = '';
1115
1364
  }
1116
1365
  closeSidebar();
1366
+ // A pick from the list (`fromList`) also collapses the persistent list of
1367
+ // a phone turned sideways; a long press for the row menu never opens.
1368
+ if (options.fromList) collapseSidebarAfterListPick();
1117
1369
  // Reopening the conversation on screen (session bind, clicking it again, a
1118
1370
  // push or search jump) must leave its composer alone: emptied attachments
1119
1371
  // would read as a user edit and be saved, dropping them from the draft.
@@ -1156,9 +1408,13 @@ export async function openConversation(id, options = {}) {
1156
1408
  ? String(savedAnchor.messageId || '').trim()
1157
1409
  : '';
1158
1410
  const cachedJumpEpoch = cached ? getTranscriptJumpEpoch() : 0;
1411
+ let unknownToRelay = false;
1159
1412
  const load = (around = aroundMessageId || anchorMessageId) => loadConversation(id, {
1160
1413
  limit: around ? 40 : 20,
1161
1414
  aroundMessageId: around || undefined,
1415
+ onErrorResponse: (response) => {
1416
+ if (response?.status === 404) unknownToRelay = true;
1417
+ },
1162
1418
  });
1163
1419
  const stillWanted = () => shouldApplyConversationLoad({
1164
1420
  requestedConversationId: id,
@@ -1172,6 +1428,10 @@ export async function openConversation(id, options = {}) {
1172
1428
  ? await loadKeepingLiveRows(load, getTranscriptLiveRowEpoch)
1173
1429
  : { response: await load(), stale: false };
1174
1430
  if (!stillWanted()) return;
1431
+ if (!fetched && unknownToRelay) {
1432
+ showConversationUnknown(nextConversationId, { lastOpenedBefore });
1433
+ return;
1434
+ }
1175
1435
  let r = fetched;
1176
1436
  const anchorFound = !!anchorMessageId && payloadHasMessage(r, anchorMessageId);
1177
1437
  if (r && anchorMessageId && !anchorFound) {
@@ -1217,6 +1477,9 @@ export async function openConversation(id, options = {}) {
1217
1477
  restoreInFlightThinking(null);
1218
1478
  renderMessages([]);
1219
1479
  }
1480
+ // A conversation the list does not hold (an archived one, a link) had only
1481
+ // its id to show until now: the payload names it.
1482
+ if (r) syncOpenedConversationTitle(id);
1220
1483
  await loadRelayQuestions(id);
1221
1484
  await loadRelayBoards();
1222
1485
  if (!shouldApplyConversationLoad({
@@ -1230,7 +1493,9 @@ export async function openConversation(id, options = {}) {
1230
1493
  applyContextUsageBar(null);
1231
1494
  scheduleContextUsageRefresh(id, 0);
1232
1495
  const composer = document.getElementById('msg-input');
1233
- if (isMobileComposerViewport()) {
1496
+ // A phone, either way round, gets no focus here: it would raise the
1497
+ // keyboard over the conversation just opened.
1498
+ if (isPhoneComposerViewport()) {
1234
1499
  releaseComposerFocusAfterSend(composer);
1235
1500
  } else {
1236
1501
  composer.focus();
@@ -2646,6 +2911,8 @@ export function isArchivedConversationsView() {
2646
2911
  }
2647
2912
 
2648
2913
  export async function toggleArchivedConversations(next = !conversationListArchivedView) {
2914
+ // A group held open by a drop belongs to the view it happened in.
2915
+ if ((next === true) !== conversationListArchivedView) convListTransientOpenGroups.clear();
2649
2916
  conversationListArchivedView = next === true;
2650
2917
  const toggle = document.getElementById('conv-archived-toggle');
2651
2918
  if (toggle) {
@@ -2699,7 +2966,7 @@ function askBeforeArchive(id, confirmation) {
2699
2966
  <p>${escHtml(confirmation.message)}</p>
2700
2967
  <p>${escHtml(confirmation.detail)}</p>
2701
2968
  <div class="summary-modal-actions">
2702
- <button class="chat-title-action-btn" type="button" data-archive-confirm="1">🗄 Archive</button>
2969
+ <button class="chat-title-action-btn" type="button" data-archive-confirm="1">🗄️ Archive</button>
2703
2970
  <button class="chat-title-action-btn" type="button" data-archive-cancel="1">Cancel</button>
2704
2971
  </div>
2705
2972
  `,
@@ -2785,7 +3052,7 @@ function conversationMenuItems(id) {
2785
3052
  { id: 'group-remove', label: 'Remove from group', onSelect: () => { void removeConversationFromGroup(id); } },
2786
3053
  ];
2787
3054
  return [
2788
- { id: 'open', label: 'Open', onSelect: () => { void openConversation(id); } },
3055
+ { id: 'open', label: 'Open', onSelect: () => { void openConversation(id, { fromList: true }); } },
2789
3056
  { id: 'edit-title', label: 'Edit title', onSelect: () => { void openFirst(() => window.openChatTitleEditor?.()); } },
2790
3057
  MENU_SEPARATOR,
2791
3058
  { id: 'stop-turn', label: 'Stop turn', disabled: !running, onSelect: () => { void openFirst(() => openStopTurnConfirmationForConversation(id)); } },
@@ -2875,7 +3142,7 @@ async function archiveConversationGroup(groupId) {
2875
3142
  detail: busy
2876
3143
  ? `${busy} of them ${busy === 1 ? 'is' : 'are'} still working and will stay; stop them first to archive everything.`
2877
3144
  : 'Archiving stops their idle processes. The chats stay in the Archived view.',
2878
- confirmLabel: '🗄 Archive group',
3145
+ confirmLabel: '🗄️ Archive group',
2879
3146
  });
2880
3147
  if (!confirmed) return;
2881
3148
  const result = await archiveConversationGroupApi(groupId);
@@ -3218,6 +3485,7 @@ export async function deleteConv(e, id) {
3218
3485
  return;
3219
3486
  }
3220
3487
  delete conversations[id];
3488
+ noteConversationGone(id);
3221
3489
  conversationPayloadCache.forget(id);
3222
3490
  renderConvList();
3223
3491
  if (currentConvId === id) clearOpenConversation();
@@ -3226,10 +3494,12 @@ export async function deleteConv(e, id) {
3226
3494
  /**
3227
3495
  * The conversation on screen is gone (deleted here or from another device):
3228
3496
  * nothing is open, the header says so, the transcript shows the empty state
3229
- * and the ⋯ menu keeps its relay-wide entries. One place for both callers.
3497
+ * and the ︙ menu keeps its relay-wide entries. One place for both callers.
3230
3498
  */
3231
- export function clearOpenConversation() {
3499
+ export function clearOpenConversation({ history = true } = {}) {
3232
3500
  setCurrentConv(null);
3501
+ // Not when back or forward steps on past it: that pop writes history.
3502
+ if (history) noteConversationCleared();
3233
3503
  clearAttachments();
3234
3504
  setRepoBrowserSessionInfo('', '');
3235
3505
  restoreInFlightThinking(null);