@oar-sh/oar 0.9.19 → 0.9.21

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 (190) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +2 -26
  2. package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +2 -26
  3. package/CHANGELOG.md +529 -0
  4. package/README.md +14 -14
  5. package/bin/oar.js +392 -98
  6. package/package.json +4 -2
  7. package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +60 -0
  8. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +258 -1
  9. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +22 -1
  10. package/server/claude-cloud-worker/claude-cloud-worker-steering.mjs +34 -0
  11. package/server/claude-worker/claude-remote-relay-tool.mjs +1 -0
  12. package/server/claude-worker/claude-session-process.mjs +10 -0
  13. package/server/claude-worker/claude-session-worker-main.mjs +8 -1
  14. package/server/claude-worker/claude-turn-publisher.mjs +163 -8
  15. package/server/claude-worker/claude-usage-totals.mjs +58 -0
  16. package/server/copilot-worker/copilot-plan-board.mjs +4 -7
  17. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +86 -3
  18. package/server/copilot-worker/copilot-sdk-session-process.mjs +131 -5
  19. package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -1
  20. package/server/cursor-worker/cursor-session-worker.mjs +12 -1
  21. package/server/cursor-worker/cursor-turn-runner.mjs +42 -6
  22. package/server/cursor-worker/cursor-worker-steering.mjs +20 -0
  23. package/server/db-schema.mjs +18 -0
  24. package/server/deepseek-worker/deepseek-claude-backend.mjs +14 -0
  25. package/server/grok-worker/grok-background-tasks.mjs +9 -0
  26. package/server/grok-worker/grok-session-worker.mjs +12 -1
  27. package/server/grok-worker/grok-turn-runner.mjs +47 -6
  28. package/server/grok-worker/grok-worker-steering.mjs +23 -0
  29. package/server/migrations/0001-add-structured-answer.mjs +5 -7
  30. package/server/migrations/0002-recent-workspace-roots-path-key.mjs +5 -7
  31. package/server/migrations/0003-push-subscriptions.mjs +5 -7
  32. package/server/migrations/0004-steer-settle-markers.mjs +5 -7
  33. package/server/migrations/0005-remote-relays.mjs +5 -7
  34. package/server/migrations/0006-workspace-root-settings.mjs +5 -7
  35. package/server/migrations/0007-claude-cloud.mjs +5 -7
  36. package/server/migrations/0008-message-pins.mjs +5 -7
  37. package/server/migrations/0009-subagent-timeline.mjs +5 -7
  38. package/server/migrations/0010-subagent-timeline-pruning.mjs +5 -7
  39. package/server/migrations/0011-relay-event-subscriptions.mjs +5 -7
  40. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +5 -7
  41. package/server/migrations/0013-previews.mjs +5 -7
  42. package/server/migrations/0014-conversation-groups.mjs +5 -7
  43. package/server/migrations/0015-conversation-group-position.mjs +5 -7
  44. package/server/migrations/0016-share-access-log.mjs +5 -7
  45. package/server/migrations/0017-conversation-history-indexes.mjs +5 -7
  46. package/server/migrations/0018-background-task-rows.mjs +5 -7
  47. package/server/migrations/0019-relay-event-subscription-options.mjs +5 -7
  48. package/server/migrations/0020-conversation-may-create-sessions.mjs +5 -7
  49. package/server/migrations/0021-conversation-group-agent-marks.mjs +5 -7
  50. package/server/migrations/0022-relay-schedules.mjs +5 -7
  51. package/server/migrations/0023-relay-asks.mjs +5 -7
  52. package/server/migrations/0024-deepseek-usage-totals.mjs +5 -7
  53. package/server/migrations/0025-deepseek-usage-requests-daily.mjs +5 -7
  54. package/server/migrations/0026-deepseek-usage-served-models.mjs +5 -7
  55. package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +5 -7
  56. package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +5 -7
  57. package/server/migrations/0029-deepseek-balance-rises.mjs +5 -7
  58. package/server/migrations/0030-deepseek-usage-actuals.mjs +5 -7
  59. package/server/migrations/0031-queue-provider.mjs +5 -7
  60. package/server/migrations/0032-runtime-session-billing-switch.mjs +5 -7
  61. package/server/migrations/0033-claude-api-usage-checkpoints.mjs +5 -7
  62. package/server/migrations/0034-claude-api-usage-totals.mjs +5 -7
  63. package/server/migrations/0035-conversation-plan-only.mjs +87 -0
  64. package/server/migrations/0036-token-usage.mjs +267 -0
  65. package/server/public/app/about-ui.js +1 -1
  66. package/server/public/app/about-view.mjs +106 -3
  67. package/server/public/app/action-confirmations.js +1 -1
  68. package/server/public/app/api-client.js +6 -3
  69. package/server/public/app/ask-user-view.js +20 -6
  70. package/server/public/app/attachments-view.js +1 -1
  71. package/server/public/app/back-guard.mjs +31 -5
  72. package/server/public/app/bootstrap.js +611 -70
  73. package/server/public/app/chat-actions-menu-state.mjs +139 -4
  74. package/server/public/app/chat-header-meta.mjs +27 -0
  75. package/server/public/app/claude-billing-toggle-ui.js +2 -1
  76. package/server/public/app/claude-cloud-conversation-ui.js +2 -2
  77. package/server/public/app/composer-control-state.mjs +12 -2
  78. package/server/public/app/composer-settings-label.mjs +90 -0
  79. package/server/public/app/composer-settings-ui.js +442 -0
  80. package/server/public/app/context-menu.mjs +36 -5
  81. package/server/public/app/context-usage-view.mjs +16 -0
  82. package/server/public/app/conversation-deep-link.mjs +15 -11
  83. package/server/public/app/conversation-group-drag.mjs +12 -2
  84. package/server/public/app/conversation-group-order.mjs +9 -2
  85. package/server/public/app/conversation-group-state.mjs +47 -10
  86. package/server/public/app/conversation-origin-ui.js +17 -1
  87. package/server/public/app/conversation-view.js +20 -2
  88. package/server/public/app/cwd-menu-placement.mjs +18 -6
  89. package/server/public/app/cwd-picker.js +36 -6
  90. package/server/public/app/emoji-view.js +1 -1
  91. package/server/public/app/font-scaling.js +26 -0
  92. package/server/public/app/header-priority-actions.mjs +234 -0
  93. package/server/public/app/hidden-messages-view.mjs +4 -0
  94. package/server/public/app/inbox-ui.js +50 -2
  95. package/server/public/app/inbox-view.mjs +141 -5
  96. package/server/public/app/journal-view.js +311 -58
  97. package/server/public/app/phone-landscape-ui.js +225 -0
  98. package/server/public/app/phone-landscape.mjs +99 -0
  99. package/server/public/app/phone-typing-state.mjs +104 -0
  100. package/server/public/app/pinned-messages-view.mjs +5 -0
  101. package/server/public/app/plan-board-shared.mjs +49 -0
  102. package/server/public/app/plan-usage-view.mjs +1 -1
  103. package/server/public/app/push-settings.js +2 -2
  104. package/server/public/app/pwa-install.js +24 -3
  105. package/server/public/app/relay-board-inline.mjs +4 -1
  106. package/server/public/app/relay-board-view.js +38 -10
  107. package/server/public/app/relay-event-subscriptions-ui.js +71 -22
  108. package/server/public/app/relay-event-view.mjs +42 -0
  109. package/server/public/app/relay-restart-ui.js +2 -2
  110. package/server/public/app/relay-schedule-view.mjs +32 -6
  111. package/server/public/app/relay-schedules-ui.js +69 -20
  112. package/server/public/app/remote-relay-origin-view.mjs +41 -23
  113. package/server/public/app/session-history-ui.js +250 -0
  114. package/server/public/app/session-history.mjs +599 -0
  115. package/server/public/app/settings-modal.js +61 -6
  116. package/server/public/app/settings-tabs.js +12 -0
  117. package/server/public/app/share-state.js +1 -1
  118. package/server/public/app/shared-conversations-settings.js +2 -2
  119. package/server/public/app/socket-handlers.js +15 -4
  120. package/server/public/app/status-view.mjs +18 -2
  121. package/server/public/app/store.js +87 -16
  122. package/server/public/app/subagent-viewer.mjs +27 -2
  123. package/server/public/app/tab-strip-fade.mjs +101 -0
  124. package/server/public/app/tap-actions.js +66 -0
  125. package/server/public/app/usage-limit-ui.js +63 -2
  126. package/server/public/app/viewer-history.mjs +13 -2
  127. package/server/public/index.html +2258 -1585
  128. package/server/public/sw.js +8 -0
  129. package/server/relay-heap-guard.mjs +14 -1
  130. package/server/relay-self-restart.mjs +83 -2
  131. package/server/relay-tools.md +5 -3
  132. package/server/repositories/message-repository.mjs +54 -1
  133. package/server/repositories/remote-relay-repository.mjs +10 -0
  134. package/server/repositories/session-repository.mjs +25 -1
  135. package/server/routes/claude-cloud-routes.mjs +19 -0
  136. package/server/routes/messages-routes.mjs +144 -1
  137. package/server/routes/relay-ask-routes.mjs +138 -8
  138. package/server/routes/relay-board-routes.mjs +55 -40
  139. package/server/routes/sessions-routes.mjs +144 -14
  140. package/server/server-runtime.mjs +322 -51
  141. package/server/services/about-service.mjs +81 -53
  142. package/server/services/cache-rebuild-service.mjs +7 -0
  143. package/server/services/claude-billing-swap-service.mjs +7 -20
  144. package/server/services/claude-cloud-session-service.mjs +47 -5
  145. package/server/services/conversation-preferences-service.mjs +31 -0
  146. package/server/services/database-file.mjs +413 -0
  147. package/server/services/db-backup-service.mjs +809 -0
  148. package/server/services/deepseek-usage-import.mjs +6 -9
  149. package/server/services/delete-archive-service.mjs +3 -0
  150. package/server/services/earlier-database-service.mjs +69 -126
  151. package/server/services/oar-cli-helpers.mjs +214 -12
  152. package/server/services/oar-cli-menu.mjs +1 -0
  153. package/server/services/oar-state-migration-service.mjs +33 -13
  154. package/server/services/package-data-rescue.mjs +4 -2
  155. package/server/services/plan-usage-claude-api.mjs +14 -15
  156. package/server/services/plan-usage-contract.mjs +6 -2
  157. package/server/services/plan-usage-deepseek.mjs +1 -1
  158. package/server/services/plan-usage-tokens.mjs +248 -0
  159. package/server/services/push-dispatch-service.mjs +134 -9
  160. package/server/services/relay-cli-launcher-service.mjs +3 -0
  161. package/server/services/relay-event-service.mjs +30 -0
  162. package/server/services/relay-question-context.mjs +11 -0
  163. package/server/services/relay-restart-picture.mjs +67 -22
  164. package/server/services/remote-inbox-watch-service.mjs +204 -0
  165. package/server/services/remote-relay-caller-context.mjs +80 -7
  166. package/server/services/remote-relay-dispatcher.mjs +101 -19
  167. package/server/services/restart-resume-service.mjs +5 -2
  168. package/server/services/session-activity-tracker.mjs +1 -1
  169. package/server/services/session-launch-mutex-service.mjs +23 -0
  170. package/server/services/session-worker-launch-service.mjs +12 -3
  171. package/server/services/session-worker-registry-service.mjs +36 -0
  172. package/server/services/session-worker-stop-service.mjs +27 -6
  173. package/server/services/session-worker-websocket-service.mjs +40 -16
  174. package/server/services/token-usage-service.mjs +430 -0
  175. package/server/services/update-install-service.mjs +13 -0
  176. package/server/services/usage-limit-pause-service.mjs +8 -1
  177. package/server/services/worker-recycle-service.mjs +772 -0
  178. package/server/services/worker-replacement.mjs +197 -0
  179. package/server/services/worker-scope.mjs +77 -0
  180. package/shared/build-id.mjs +128 -0
  181. package/shared/plan-board-actions.mjs +130 -0
  182. package/shared/plan-only.mjs +43 -0
  183. package/shared/relay-asks.mjs +3 -0
  184. package/shared/relay-events.mjs +38 -4
  185. package/shared/remote-relay-contract.mjs +66 -11
  186. package/shared/turn-token-usage.mjs +144 -0
  187. package/shared/worker-runtime/heartbeat.mjs +5 -0
  188. package/shared/worker-runtime/simple-steering-state.mjs +38 -0
  189. package/shared/worker-runtime/worker-websocket-link.mjs +23 -0
  190. 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,
@@ -136,9 +143,16 @@ import {
136
143
  resolveCloudSourceAutoFill,
137
144
  validateCloudSourceInputs,
138
145
  } 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';
146
+ import { conversationOriginInfo } from './remote-relay-origin-view.mjs';
147
+ import { relayEventWatchedInfoText } from './relay-event-subscriptions-ui.js';
148
+ import { relayScheduleInfoText } from './relay-schedules-ui.js';
149
+ import {
150
+ noteConversationCleared,
151
+ noteConversationGone,
152
+ noteConversationMissing,
153
+ noteConversationShown,
154
+ syncSessionDocumentTitle,
155
+ } from './session-history-ui.js';
142
156
  import {
143
157
  normalizeConversationFilter,
144
158
  conversationMatchesFilter,
@@ -364,10 +378,11 @@ function isConversationProcessing(conversation, workerState) {
364
378
  return runtimeStatus === 'processing';
365
379
  }
366
380
 
367
- export async function loadConversations() {
381
+ // `openLast: false` when the address names the conversation to open instead.
382
+ export async function loadConversations({ openLast = true } = {}) {
368
383
  await refreshConversations({ preservePagination: false });
369
- const lastId = localStorage.getItem('copilot_last_conv');
370
- if (lastId) await openConversation(lastId, { restoreScroll: true });
384
+ const lastId = openLast ? localStorage.getItem('copilot_last_conv') : '';
385
+ if (lastId) await openConversation(lastId, { restoreScroll: true, history: 'replace' });
371
386
  }
372
387
 
373
388
  export async function refreshConversations(options = {}) {
@@ -540,6 +555,13 @@ function conversationRowState(conversation, pendingByConversation = getPendingQu
540
555
  // event or fetch said (the worker was killed, or the relay restarted).
541
556
  let sessionState = String(conversation?.sessionState || '').trim();
542
557
  if (sessionState === 'background_only' && hasSessionWorkerStatus() && !isWorkerStateLive(workerState)) sessionState = 'idle';
558
+ const memberState = memberStateClass({
559
+ pendingCount,
560
+ visualState,
561
+ processing,
562
+ activeTurn: conversation?.activeTurn === true,
563
+ sessionState,
564
+ });
543
565
  return {
544
566
  visualState,
545
567
  processing,
@@ -549,13 +571,9 @@ function conversationRowState(conversation, pendingByConversation = getPendingQu
549
571
  // "Stop all running" acts on, and what keeps an archive from it.
550
572
  live: processing || pendingCount > 0 || conversation?.activeTurn === true || sessionState === 'running_turn' || sessionState === 'background_only',
551
573
  // 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
- }),
574
+ memberState,
575
+ // Its colour in the group's name.
576
+ tone: memberTone({ memberState, visualState }),
559
577
  };
560
578
  }
561
579
 
@@ -571,6 +589,14 @@ function groupDisplayName(groupId) {
571
589
  return String(info?.name || head?.title || info?.displayName || 'Group').trim() || 'Group';
572
590
  }
573
591
 
592
+ /** A synthetic head's meta line: when its newest row was active, and how many rows it has. */
593
+ function groupMetaText(group, summary) {
594
+ const ms = Number(group?.activityMs) || 0;
595
+ const when = ms > 0 ? fmtDate(new Date(ms).toISOString()) : '';
596
+ const count = groupSessionCountText(summary?.count || 0);
597
+ return when ? `${when} · ${count}` : count;
598
+ }
599
+
574
600
  /** The loaded rows of a group, in the current view and across it. */
575
601
  function groupMemberRows(groupId, { inViewOnly = true } = {}) {
576
602
  return Object.values(conversations).filter((conversation) => String(conversation?.groupId || '') === groupId
@@ -580,15 +606,16 @@ function groupMemberRows(groupId, { inViewOnly = true } = {}) {
580
606
  /**
581
607
  * What a head row shows for its group: the classes of every row in it, the
582
608
  * 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.
609
+ * count the same rows), their summary, the colour of the group's name, and
610
+ * the first row a waiting card sits in.
585
611
  */
586
612
  function describeGroupForHead(group, viewOf) {
587
613
  const rows = group.head ? [group.head, ...group.members] : [...group.members];
588
614
  const states = rows.map((row) => viewOf(row).memberState);
589
615
  const summary = summarizeGroupMembers(states);
616
+ const titleTone = groupTitleTone(rows.map((row) => viewOf(row).tone));
590
617
  const firstWaiting = rows.find((row) => viewOf(row).memberState === 'waiting') || null;
591
- return { summary, firstWaiting, forcedOpen: false };
618
+ return { summary, titleTone, firstWaiting, forcedOpen: false };
592
619
  }
593
620
 
594
621
  export function renderConvList() {
@@ -618,31 +645,32 @@ function paintConvList() {
618
645
  if (!rowViews.has(id)) rowViews.set(id, conversationRowState(conversation, pendingByConversation));
619
646
  return rowViews.get(id);
620
647
  };
621
- const foldChoices = readConversationGroupFoldChoices();
648
+ const foldChoices = readConversationGroupFoldChoices({ archived: conversationListArchivedView });
622
649
  const groupDescriptions = new Map();
623
650
  // Groups order with the same rules the list route pages by; a group is
624
651
  // folded once nothing in it runs or waits, unless the user chose otherwise.
652
+ // A folded group still shows the open conversation under its head.
625
653
  const items = arrangeConversationList(inView, conversationGroups, {
626
654
  matches: activeFilter ? (row) => conversationMatchesFilter(row, activeFilter) : null,
627
655
  groupNameMatches: activeFilter ? (info) => conversationMatchesFilter({ title: info?.displayName || info?.name }, activeFilter) : null,
628
656
  // An owner's group an agent emptied shows in the live list, empty.
629
657
  emptyGroups: !conversationListArchivedView,
658
+ keepVisibleId: currentConvId || null,
630
659
  isOpen: (groupId, group) => {
631
660
  const described = describeGroupForHead(group, viewOf);
632
- const holdsOpenConversation = !!currentConvId && group.members.some((row) => row.id === currentConvId);
633
661
  // Open whatever the choice: the fold button then says so and does nothing.
634
- described.forcedOpen = holdsOpenConversation || !!activeFilter;
662
+ described.forcedOpen = !!activeFilter;
635
663
  groupDescriptions.set(groupId, described);
636
664
  if (convListTransientOpenGroups.has(groupId)) return true;
637
665
  return resolveGroupFold({
638
666
  choice: foldChoices[groupId] || null,
639
667
  active: groupIsActive(described.summary),
640
- holdsOpenConversation,
641
668
  filterActive: !!activeFilter,
642
669
  });
643
670
  },
644
671
  });
645
672
  lastConvListDragItems = describeDragItems(items);
673
+ syncConversationGroupsFoldAllButton(items, !!activeFilter);
646
674
  const footerHtml = (() => {
647
675
  if (activeFilter) {
648
676
  // While a filter is active the drain loads older pages by itself; a
@@ -664,30 +692,31 @@ function paintConvList() {
664
692
  const emptyText = activeFilter && inView.length > 0
665
693
  ? 'No conversations match your filter'
666
694
  : (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}`;
695
+ 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
696
  lastConvListHtml = '';
669
697
  scheduleConversationListBoundaryCheck();
670
698
  return;
671
699
  }
672
700
  // The head row's pill: the member count and what the members are doing;
673
701
  // while folded, its "waiting" part opens the member the card sits in.
674
- const groupPillHtml = (item) => {
702
+ // withCount: false on a synthetic head, whose meta line says the count.
703
+ const groupPillHtml = (item, { withCount = true } = {}) => {
675
704
  const described = groupDescriptions.get(item.group.id) || describeGroupForHead(item.group, viewOf);
676
- const text = groupSummaryText(described.summary);
705
+ const text = withCount ? groupSummaryText(described.summary) : groupActivityText(described.summary);
677
706
  if (!text) return '';
678
707
  const waitingId = described.firstWaiting ? String(described.firstWaiting.id) : '';
679
708
  // Inline like the row's own buttons: the row's click handler must not
680
709
  // run (it would open the head and re-render the list first).
681
710
  // The full text is the title: a narrow sidebar shortens the pill with an ellipsis.
682
711
  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>`;
712
+ 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
713
  };
685
714
  const foldButtonHtml = (item) => {
686
715
  const forced = (groupDescriptions.get(item.group.id) || {}).forcedOpen === true || item.group.contextOnly === true || !!activeFilter;
687
716
  const label = forced
688
- ? (activeFilter ? 'Open while the filter is on' : 'Open while its chat is open')
717
+ ? 'Open while the filter is on'
689
718
  : (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>`;
719
+ 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
720
  };
692
721
  const rowHtml = (c, item) => {
693
722
  const view = viewOf(c);
@@ -696,30 +725,43 @@ function paintConvList() {
696
725
  const providerIndicatorHtml = providerIndicatorLabel
697
726
  ? `<span class="conv-provider-indicator"${providerIndicatorKey ? ` data-provider="${providerIndicatorKey}"` : ''}>${providerIndicatorLabel}</span>`
698
727
  : '';
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);
728
+ // The ⓘ and its menu: an agent started this conversation (here or on
729
+ // another relay), another conversation watches it (relay events), a
730
+ // schedule fires into it (relay_tool schedule). A member under its
731
+ // creator's head row says the origin already, so it is left out there.
732
+ const origin = item.kind === 'member' && item.group.head && c.origin?.local === true && c.origin.conversationId === item.group.head.id
733
+ ? null
734
+ : conversationOriginInfo(c, { conversations });
735
+ const infoEntries = conversationRowInfoEntries({
736
+ origin,
737
+ watched: relayEventWatchedInfoText(c.id),
738
+ scheduled: relayScheduleInfoText(c.id),
739
+ });
740
+ if (infoEntries.length) convListRowInfo.set(String(c.id), infoEntries);
741
+ const infoButtonHtml = conversationRowInfoButtonHtml(c.id, infoEntries);
709
742
  const groupClass = item.kind === 'head' ? ' conv-group-head' : (item.kind === 'member' ? ' conv-group-member' : '');
710
743
  const groupAttr = item.kind === 'head' || item.kind === 'member' ? ` data-group-id="${escHtml(item.group.id)}"` : '';
744
+ // A head row's title is the group's name, coloured by all its rows.
745
+ const toneAttr = item.kind === 'head' ? ` data-group-tone="${escHtml((groupDescriptions.get(item.group.id) || describeGroupForHead(item.group, viewOf)).titleTone)}"` : '';
711
746
  // A head row shows the name the group was given by hand; until then the
712
747
  // conversation's own title (the group's name follows it).
713
748
  const handName = item.kind === 'head' ? String(item.group.info?.name || '').trim() : '';
714
749
  const rowTitle = handName || c.title;
715
750
  const titleAttr = handName && handName !== c.title ? ` title="${escHtml(`${handName} · ${c.title}`)}"` : '';
751
+ const titleHtml = `<div class="conv-title"${titleAttr}>${item.kind === 'head' ? foldButtonHtml(item) : ''}${escHtml(rowTitle)}</div>`;
752
+ // With open questions the title and its "N open" pill share a row in
753
+ // which only the title gives way; the pill is never cut.
754
+ const openCount = pendingByConversation[c.id];
755
+ const titleRowHtml = openCount
756
+ ? `<div class="conv-title-row">${titleHtml}<span class="conv-open-questions">${openCount} open</span></div>`
757
+ : titleHtml;
716
758
  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>
759
+ <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})">
760
+ ${titleRowHtml}
761
+ <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
762
  ${c.archived
721
763
  ? `<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>`}
764
+ : `<button class="conv-archive" onclick="archiveConv(event,'${c.id}')" title="Archive" aria-label="Archive">🗄️</button>`}
723
765
  <button class="conv-delete" onclick="deleteConv(event,'${c.id}')" title="Delete">🗑</button>
724
766
  </div>`;
725
767
  };
@@ -733,16 +775,17 @@ function paintConvList() {
733
775
  return `
734
776
  <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
777
  <div class="conv-title">${escHtml(name)}</div>
736
- <div class="conv-meta"><span class="conv-meta-primary">group · empty</span></div>
778
+ <div class="conv-meta"><span class="conv-meta-primary">${groupSessionCountText(0)}</span></div>
737
779
  </div>`;
738
780
  }
739
781
  const described = groupDescriptions.get(item.group.id) || describeGroupForHead(item.group, viewOf);
740
782
  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)}')">
783
+ <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
784
  <div class="conv-title">${foldButtonHtml(item)}${escHtml(name)}</div>
743
- <div class="conv-meta"><span class="conv-meta-primary">group</span>${groupPillHtml(item)}</div>
785
+ <div class="conv-meta"><span class="conv-meta-primary">${escHtml(groupMetaText(item.group, described.summary))}</span>${groupPillHtml(item, { withCount: false })}</div>
744
786
  </div>`;
745
787
  };
788
+ convListRowInfo.clear();
746
789
  const listHtml = `${items.map((item) => (item.conversation ? rowHtml(item.conversation, item) : syntheticHeadHtml(item))).join('')}${footerHtml}`;
747
790
  if (listHtml !== lastConvListHtml) {
748
791
  list.innerHTML = listHtml;
@@ -750,6 +793,7 @@ function paintConvList() {
750
793
  }
751
794
  bindConversationContextMenu(list);
752
795
  bindConversationGroupControls(list);
796
+ bindConversationInfoButtons(list);
753
797
  window.syncChatTitleControls?.();
754
798
  scheduleConversationListBoundaryCheck();
755
799
  }
@@ -767,15 +811,105 @@ function bindConversationGroupControls(list) {
767
811
  if (!waiting || !list.contains(waiting)) return;
768
812
  event.preventDefault();
769
813
  event.stopPropagation();
770
- void (window.openConversation || openConversation)(waiting.dataset.openWaiting);
814
+ void (window.openConversation || openConversation)(waiting.dataset.openWaiting, { fromList: true });
771
815
  });
772
816
  }
773
817
 
818
+ // ─── The row's ⓘ: who started it, who watches it, what is scheduled ─────────
819
+ // One small button in the meta line stands for all three; its menu lists
820
+ // them, each with its action (or as a line of text where there is none).
821
+
822
+ // The menu entries of the rows as last drawn, by conversation id.
823
+ const convListRowInfo = new Map();
824
+
774
825
  /**
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.
826
+ * The entries of a row's ⓘ menu, in a fixed order. `origin` is
827
+ * conversationOriginInfo()'s answer, `watched` and `scheduled` their lines
828
+ * ('' for none). `short` is the entry's word in the button's label.
829
+ */
830
+ function conversationRowInfoEntries({ origin = null, watched = '', scheduled = '' } = {}) {
831
+ const entries = [];
832
+ if (origin?.label) entries.push({ id: 'origin', short: 'started by an agent', label: origin.label, origin });
833
+ if (watched) entries.push({ id: 'watched', short: 'watched', label: watched });
834
+ if (scheduled) entries.push({ id: 'scheduled', short: 'scheduled', label: scheduled });
835
+ return entries;
836
+ }
837
+
838
+ function conversationRowInfoButtonHtml(conversationId, entries) {
839
+ if (!entries.length) return '';
840
+ const label = `Details: ${entries.map((entry) => entry.short).join(', ')}`;
841
+ return `<button type="button" class="conv-info-btn" data-conv-info="${escHtml(conversationId)}" aria-haspopup="menu" aria-label="${escHtml(label)}" title="${escHtml(label)}">ⓘ</button>`;
842
+ }
843
+
844
+ function conversationRowInfoMenuItems(entries) {
845
+ return entries.map((entry) => {
846
+ const origin = entry.id === 'origin' ? entry.origin : null;
847
+ if (origin?.kind === 'local' && origin.linkable) {
848
+ // As the header's "via agent" marker: openOriginConversation says so
849
+ // when that conversation is gone by now.
850
+ return { id: entry.id, label: entry.label, onSelect: () => { void openOriginConversation(null, { dataset: { originConversationId: origin.conversationId } }, { fromList: true }); } };
851
+ }
852
+ if (origin?.kind === 'remote' && origin.href) {
853
+ // As the message badge of such an agent: its session on that relay.
854
+ return { id: entry.id, label: entry.label, onSelect: () => { window.open(origin.href, '_blank', 'noopener'); } };
855
+ }
856
+ return { id: entry.id, label: entry.label, note: true };
857
+ });
858
+ }
859
+
860
+ /** Opens the ⓘ menu of a row under its button. */
861
+ function openConversationRowInfoMenu(button) {
862
+ const id = String(button?.dataset?.convInfo || '').trim();
863
+ const entries = convListRowInfo.get(id);
864
+ if (!entries?.length) return;
865
+ const rect = button.getBoundingClientRect();
866
+ const gap = 4 * getUiScale();
867
+ // Esc is a key, so the returned focus would show its ring (and the row's
868
+ // archive and delete icons) even after a mouse click: it shows only if
869
+ // the button had a visible focus when the menu opened.
870
+ const focusVisible = !!button.matches?.(':focus-visible');
871
+ openContextMenu(rect.left, rect.bottom + gap, conversationRowInfoMenuItems(entries), {
872
+ // The list may have been drawn anew meanwhile: the row's button by id.
873
+ onEscape: () => {
874
+ document.querySelector(`#conv-list .conv-info-btn[data-conv-info="${CSS.escape(id)}"]`)?.focus?.({ preventScroll: true, focusVisible });
875
+ },
876
+ });
877
+ }
878
+
879
+ /**
880
+ * The ⓘ opens its menu and never the row: caught on the way down (capture),
881
+ * before the row's own click handler opens the conversation. Bound once.
882
+ */
883
+ function bindConversationInfoButtons(list) {
884
+ if (!list || list.dataset.infoButtonsBound === '1') return;
885
+ list.dataset.infoButtonsBound = '1';
886
+ list.addEventListener('click', (event) => {
887
+ const button = event.target?.closest?.('.conv-info-btn');
888
+ if (!button || !list.contains(button)) return;
889
+ event.stopPropagation();
890
+ // The release of a long press that opened the row's menu: that menu stays.
891
+ if (event.defaultPrevented) return;
892
+ event.preventDefault();
893
+ openConversationRowInfoMenu(button);
894
+ }, true);
895
+ }
896
+
897
+ /**
898
+ * Folds or unfolds a group; the choice is kept until the user changes it,
899
+ * apart for the live list and the Archived view. A group held open by a
900
+ * filter keeps its choice. A folded group still shows the open conversation.
777
901
  * The fold button keeps the focus across the re-render.
778
902
  */
903
+ // Groups whose fold the user just toggled: their chevron turns once on the
904
+ // next paint (the list is drawn anew, so a transition has nothing to start from).
905
+ const convListFoldTurning = new Set();
906
+
907
+ function turnGroupChevrons(groupIds) {
908
+ for (const id of groupIds) convListFoldTurning.add(id);
909
+ renderConvList();
910
+ convListFoldTurning.clear();
911
+ }
912
+
779
913
  export function toggleConversationGroupFold(event, groupId) {
780
914
  event?.stopPropagation?.();
781
915
  event?.preventDefault?.();
@@ -785,9 +919,53 @@ export function toggleConversationGroupFold(event, groupId) {
785
919
  if (button?.disabled) return;
786
920
  const currentlyOpen = button ? button.getAttribute('aria-expanded') === 'true' : true;
787
921
  convListTransientOpenGroups.delete(id);
788
- setConversationGroupFoldChoice(id, toggledGroupFoldChoice(currentlyOpen));
789
- renderConvList();
790
- document.querySelector(`#conv-list [data-group-fold="${CSS.escape(id)}"]`)?.focus?.({ preventScroll: true });
922
+ setConversationGroupFoldChoice(id, toggledGroupFoldChoice(currentlyOpen), { archived: conversationListArchivedView });
923
+ turnGroupChevrons([id]);
924
+ // Keyboard toggles keep the focus; a click or tap (detail > 0) does not,
925
+ // or the redrawn button would show a focus ring.
926
+ if (!(Number(event?.detail) > 0)) {
927
+ document.querySelector(`#conv-list [data-group-fold="${CSS.escape(id)}"]`)?.focus?.({ preventScroll: true });
928
+ }
929
+ }
930
+
931
+ // The groups the fold-all button acts on: those in the list as last drawn.
932
+ let conversationListFoldableGroups = { ids: [], anyOpen: false };
933
+
934
+ /**
935
+ * The filter row's fold-all button: ➖ while a group in the list is open
936
+ * (folds them all), ➕ once all are folded (unfolds them all). Hidden
937
+ * without a group; disabled under a filter, which holds every group open.
938
+ */
939
+ function syncConversationGroupsFoldAllButton(items, filterActive) {
940
+ const heads = items.filter((item) => item.kind === 'head' && !item.group.empty);
941
+ const anyOpen = heads.some((item) => item.group.open);
942
+ conversationListFoldableGroups = { ids: heads.map((item) => item.group.id), anyOpen };
943
+ const button = document.getElementById('conv-groups-fold-all');
944
+ if (!button) return;
945
+ button.hidden = heads.length === 0;
946
+ button.disabled = filterActive;
947
+ const label = filterActive
948
+ ? 'Groups stay open while the filter is on'
949
+ : (anyOpen ? 'Fold all groups' : 'Unfold all groups');
950
+ const glyph = anyOpen ? '➖' : '➕';
951
+ if (button.textContent !== glyph) button.textContent = glyph;
952
+ if (button.title !== label) {
953
+ button.title = label;
954
+ button.setAttribute('aria-label', label);
955
+ }
956
+ }
957
+
958
+ /** Folds every group in the list, or unfolds them all once all are folded. */
959
+ export function toggleAllConversationGroupFolds(event) {
960
+ event?.stopPropagation?.();
961
+ event?.preventDefault?.();
962
+ const button = document.getElementById('conv-groups-fold-all');
963
+ if (button?.disabled) return;
964
+ const { ids, anyOpen } = conversationListFoldableGroups;
965
+ if (!ids.length) return;
966
+ for (const id of ids) convListTransientOpenGroups.delete(id);
967
+ setConversationGroupFoldChoices(ids, anyOpen ? 'folded' : 'open', { archived: conversationListArchivedView });
968
+ turnGroupChevrons(ids);
791
969
  }
792
970
 
793
971
  // keepTranscript: the page shows a window this payload must not replace (the
@@ -1010,7 +1188,8 @@ function applyCachedConversationState(id, response, { viewAnchor = null } = {})
1010
1188
  // The "via agent" marker (sidebar row, header, message badge): opens the
1011
1189
  // conversation whose agent started this one. The marker sits inside a sidebar
1012
1190
  // row that opens on click itself, so the click stops here.
1013
- export function openOriginConversation(event, element) {
1191
+ // `options` go to openConversation (`fromList` for a pick from a list row's ⓘ).
1192
+ export function openOriginConversation(event, element, options = {}) {
1014
1193
  event?.stopPropagation?.();
1015
1194
  event?.preventDefault?.();
1016
1195
  const id = String(element?.dataset?.originConversationId || '').trim();
@@ -1021,7 +1200,7 @@ export function openOriginConversation(event, element) {
1021
1200
  renderConvList();
1022
1201
  return null;
1023
1202
  }
1024
- return openConversation(id);
1203
+ return openConversation(id, options);
1025
1204
  }
1026
1205
 
1027
1206
  // The fresh answer for a conversation whose kept window is on the page (drawn
@@ -1085,6 +1264,53 @@ function applyFreshOverCachedConversation(id, response, { cached, keepTranscript
1085
1264
  saveConversationScrollTop(id, messagesEl.scrollTop);
1086
1265
  }
1087
1266
 
1267
+ const LAST_OPENED_CONVERSATION_KEY = 'copilot_last_conv';
1268
+
1269
+ function readLastOpenedConversationId() {
1270
+ try {
1271
+ return String(localStorage.getItem(LAST_OPENED_CONVERSATION_KEY) || '').trim();
1272
+ } catch {
1273
+ return '';
1274
+ }
1275
+ }
1276
+
1277
+ /** The header and the document take the title the loaded payload gave the record. */
1278
+ function syncOpenedConversationTitle(id) {
1279
+ if (String(currentConvId || '').trim() !== String(id || '').trim()) return;
1280
+ const title = String(conversations[id]?.title || '').trim();
1281
+ const titleEl = document.getElementById('chat-title');
1282
+ if (title && titleEl && titleEl.textContent !== title && !document.body.classList.contains('chat-title-editing')) {
1283
+ titleEl.textContent = title;
1284
+ window.syncChatTitleControls?.();
1285
+ }
1286
+ syncSessionDocumentTitle();
1287
+ }
1288
+
1289
+ /**
1290
+ * The relay knows no conversation `id` (deleted elsewhere, a mistyped or
1291
+ * stale link). Back or forward onto it steps on past it; anything else
1292
+ * leaves nothing open, without `?conv=` in the address, and says so. The
1293
+ * conversation the next start opens stays the one it was.
1294
+ */
1295
+ function showConversationUnknown(id, { lastOpenedBefore = '' } = {}) {
1296
+ conversationPayloadCache.forget(id);
1297
+ if (conversations[id]) {
1298
+ delete conversations[id];
1299
+ renderConvList();
1300
+ }
1301
+ // Stepped over: the pop that follows opens what lies there.
1302
+ const stepsOn = noteConversationMissing(id);
1303
+ clearOpenConversation({ history: !stepsOn });
1304
+ renderNoConversationState();
1305
+ window.syncComposerButtonState?.();
1306
+ window.syncComposerPlaceholder?.();
1307
+ try {
1308
+ if (lastOpenedBefore && lastOpenedBefore !== id) localStorage.setItem(LAST_OPENED_CONVERSATION_KEY, lastOpenedBefore);
1309
+ else localStorage.removeItem(LAST_OPENED_CONVERSATION_KEY);
1310
+ } catch {}
1311
+ showTransientRelayNotice('That conversation is not on this relay. It may have been deleted.', 6000);
1312
+ }
1313
+
1088
1314
  export async function openConversation(id, options = {}) {
1089
1315
  // The Inbox owns the transcript like the status view does.
1090
1316
  const didLeaveInboxView = leaveInboxView();
@@ -1102,7 +1328,13 @@ export async function openConversation(id, options = {}) {
1102
1328
  const switchingConversation = !!nextConversationId && previousConversationId !== nextConversationId;
1103
1329
  if (switchingConversation && previousConversationId && !IS_SHARED_VIEW) keepPlaceOfConversationLeft(previousConversationId);
1104
1330
  const capturedVersion = ++openConversationVersion;
1331
+ // Put back if the relay knows no such conversation: a wrong link must not
1332
+ // become the one the next start opens.
1333
+ const lastOpenedBefore = readLastOpenedConversationId();
1105
1334
  setCurrentConv(id);
1335
+ // One history entry per switch (`history`: 'replace' at load, 'none' when
1336
+ // back or forward opened it); reopening the one on screen adds none.
1337
+ noteConversationShown(nextConversationId, options.history);
1106
1338
  if (repoBrowserState.activeRoot === 'workspace') {
1107
1339
  repoBrowserState.tree = null;
1108
1340
  repoBrowserState.nodeMap = new Map();
@@ -1114,6 +1346,9 @@ export async function openConversation(id, options = {}) {
1114
1346
  repoBrowserState.error = '';
1115
1347
  }
1116
1348
  closeSidebar();
1349
+ // A pick from the list (`fromList`) also collapses the persistent list of
1350
+ // a phone turned sideways; a long press for the row menu never opens.
1351
+ if (options.fromList) collapseSidebarAfterListPick();
1117
1352
  // Reopening the conversation on screen (session bind, clicking it again, a
1118
1353
  // push or search jump) must leave its composer alone: emptied attachments
1119
1354
  // would read as a user edit and be saved, dropping them from the draft.
@@ -1156,9 +1391,13 @@ export async function openConversation(id, options = {}) {
1156
1391
  ? String(savedAnchor.messageId || '').trim()
1157
1392
  : '';
1158
1393
  const cachedJumpEpoch = cached ? getTranscriptJumpEpoch() : 0;
1394
+ let unknownToRelay = false;
1159
1395
  const load = (around = aroundMessageId || anchorMessageId) => loadConversation(id, {
1160
1396
  limit: around ? 40 : 20,
1161
1397
  aroundMessageId: around || undefined,
1398
+ onErrorResponse: (response) => {
1399
+ if (response?.status === 404) unknownToRelay = true;
1400
+ },
1162
1401
  });
1163
1402
  const stillWanted = () => shouldApplyConversationLoad({
1164
1403
  requestedConversationId: id,
@@ -1172,6 +1411,10 @@ export async function openConversation(id, options = {}) {
1172
1411
  ? await loadKeepingLiveRows(load, getTranscriptLiveRowEpoch)
1173
1412
  : { response: await load(), stale: false };
1174
1413
  if (!stillWanted()) return;
1414
+ if (!fetched && unknownToRelay) {
1415
+ showConversationUnknown(nextConversationId, { lastOpenedBefore });
1416
+ return;
1417
+ }
1175
1418
  let r = fetched;
1176
1419
  const anchorFound = !!anchorMessageId && payloadHasMessage(r, anchorMessageId);
1177
1420
  if (r && anchorMessageId && !anchorFound) {
@@ -1217,6 +1460,9 @@ export async function openConversation(id, options = {}) {
1217
1460
  restoreInFlightThinking(null);
1218
1461
  renderMessages([]);
1219
1462
  }
1463
+ // A conversation the list does not hold (an archived one, a link) had only
1464
+ // its id to show until now: the payload names it.
1465
+ if (r) syncOpenedConversationTitle(id);
1220
1466
  await loadRelayQuestions(id);
1221
1467
  await loadRelayBoards();
1222
1468
  if (!shouldApplyConversationLoad({
@@ -1230,7 +1476,9 @@ export async function openConversation(id, options = {}) {
1230
1476
  applyContextUsageBar(null);
1231
1477
  scheduleContextUsageRefresh(id, 0);
1232
1478
  const composer = document.getElementById('msg-input');
1233
- if (isMobileComposerViewport()) {
1479
+ // A phone, either way round, gets no focus here: it would raise the
1480
+ // keyboard over the conversation just opened.
1481
+ if (isPhoneComposerViewport()) {
1234
1482
  releaseComposerFocusAfterSend(composer);
1235
1483
  } else {
1236
1484
  composer.focus();
@@ -2646,6 +2894,8 @@ export function isArchivedConversationsView() {
2646
2894
  }
2647
2895
 
2648
2896
  export async function toggleArchivedConversations(next = !conversationListArchivedView) {
2897
+ // A group held open by a drop belongs to the view it happened in.
2898
+ if ((next === true) !== conversationListArchivedView) convListTransientOpenGroups.clear();
2649
2899
  conversationListArchivedView = next === true;
2650
2900
  const toggle = document.getElementById('conv-archived-toggle');
2651
2901
  if (toggle) {
@@ -2699,7 +2949,7 @@ function askBeforeArchive(id, confirmation) {
2699
2949
  <p>${escHtml(confirmation.message)}</p>
2700
2950
  <p>${escHtml(confirmation.detail)}</p>
2701
2951
  <div class="summary-modal-actions">
2702
- <button class="chat-title-action-btn" type="button" data-archive-confirm="1">🗄 Archive</button>
2952
+ <button class="chat-title-action-btn" type="button" data-archive-confirm="1">🗄️ Archive</button>
2703
2953
  <button class="chat-title-action-btn" type="button" data-archive-cancel="1">Cancel</button>
2704
2954
  </div>
2705
2955
  `,
@@ -2785,7 +3035,7 @@ function conversationMenuItems(id) {
2785
3035
  { id: 'group-remove', label: 'Remove from group', onSelect: () => { void removeConversationFromGroup(id); } },
2786
3036
  ];
2787
3037
  return [
2788
- { id: 'open', label: 'Open', onSelect: () => { void openConversation(id); } },
3038
+ { id: 'open', label: 'Open', onSelect: () => { void openConversation(id, { fromList: true }); } },
2789
3039
  { id: 'edit-title', label: 'Edit title', onSelect: () => { void openFirst(() => window.openChatTitleEditor?.()); } },
2790
3040
  MENU_SEPARATOR,
2791
3041
  { id: 'stop-turn', label: 'Stop turn', disabled: !running, onSelect: () => { void openFirst(() => openStopTurnConfirmationForConversation(id)); } },
@@ -2875,7 +3125,7 @@ async function archiveConversationGroup(groupId) {
2875
3125
  detail: busy
2876
3126
  ? `${busy} of them ${busy === 1 ? 'is' : 'are'} still working and will stay; stop them first to archive everything.`
2877
3127
  : 'Archiving stops their idle processes. The chats stay in the Archived view.',
2878
- confirmLabel: '🗄 Archive group',
3128
+ confirmLabel: '🗄️ Archive group',
2879
3129
  });
2880
3130
  if (!confirmed) return;
2881
3131
  const result = await archiveConversationGroupApi(groupId);
@@ -3218,6 +3468,7 @@ export async function deleteConv(e, id) {
3218
3468
  return;
3219
3469
  }
3220
3470
  delete conversations[id];
3471
+ noteConversationGone(id);
3221
3472
  conversationPayloadCache.forget(id);
3222
3473
  renderConvList();
3223
3474
  if (currentConvId === id) clearOpenConversation();
@@ -3226,10 +3477,12 @@ export async function deleteConv(e, id) {
3226
3477
  /**
3227
3478
  * The conversation on screen is gone (deleted here or from another device):
3228
3479
  * 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.
3480
+ * and the ︙ menu keeps its relay-wide entries. One place for both callers.
3230
3481
  */
3231
- export function clearOpenConversation() {
3482
+ export function clearOpenConversation({ history = true } = {}) {
3232
3483
  setCurrentConv(null);
3484
+ // Not when back or forward steps on past it: that pop writes history.
3485
+ if (history) noteConversationCleared();
3233
3486
  clearAttachments();
3234
3487
  setRepoBrowserSessionInfo('', '');
3235
3488
  restoreInFlightThinking(null);