@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
@@ -52,7 +52,7 @@ import { syncFontScaleSelect } from './font-scaling.js';
52
52
  import { syncInstalledAppUiState, syncPwaAppNameInput } from './pwa-install.js';
53
53
  import { normalizeKnownCwdPath } from './cwd-picker.js';
54
54
  import { refreshPushSettingsSection } from './push-settings.js';
55
- import { selectSettingsTab } from './settings-tabs.js';
55
+ import { revealSettingsTabs, selectSettingsTab } from './settings-tabs.js';
56
56
  import { refreshClaudeAuthSection } from './claude-auth-ui.js';
57
57
  import { refreshGrokAuthSection } from './grok-auth-ui.js';
58
58
  import { syncDeepSeekSettingsInputs } from './deepseek-settings-ui.js';
@@ -82,6 +82,9 @@ import { refreshRemoteRelaysSection } from './remote-relays-settings.js';
82
82
  import { refreshAgentSessionsSection } from './agent-sessions-settings-ui.js';
83
83
  import { refreshSharedConversationsSection } from './shared-conversations-settings.js';
84
84
  import { syncScrollOnSendToggle } from './transcript-jump-bottom.mjs';
85
+ import { syncThoughtsExpandedToggle } from './thoughts-preference.mjs';
86
+ import { syncLiveActivityCapSlider } from './live-activity-cap.mjs';
87
+ import { syncStreamViewToggle } from './stream-view-preference.mjs';
85
88
  import { syncWindowRetentionSlider } from './conversation-window-retention.mjs';
86
89
  import {
87
90
  getPreviews,
@@ -1697,9 +1700,7 @@ function syncFeatureFlagsSection() {
1697
1700
  const flags = Array.isArray(featureFlagsState?.flags) ? featureFlagsState.flags : [];
1698
1701
  for (const flag of flags) {
1699
1702
  const row = document.createElement('div');
1700
- row.className = 'settings-row';
1701
- row.style.marginTop = '12px';
1702
- row.style.marginBottom = '0';
1703
+ row.className = 'settings-row settings-gap';
1703
1704
 
1704
1705
  const label = document.createElement('label');
1705
1706
  label.htmlFor = featureToggleId(flag.name);
@@ -1719,8 +1720,7 @@ function syncFeatureFlagsSection() {
1719
1720
  list.appendChild(row);
1720
1721
 
1721
1722
  const help = document.createElement('div');
1722
- help.className = 'settings-help';
1723
- help.style.marginTop = '6px';
1723
+ help.className = 'settings-help settings-gap-xs';
1724
1724
  let text = String(flag.description || '');
1725
1725
  if (flag.envOverride !== null) {
1726
1726
  text += ` Pinned ${flag.envOverride ? 'on' : 'off'} by COPILOT_REMOTE_${flag.name} in the relay's environment; the toggle is locked while that variable is set.`;
@@ -1945,6 +1945,9 @@ export function openSettingsModal(tab, providerTab) {
1945
1945
  }
1946
1946
  syncSuspendHostVisibility();
1947
1947
  syncScrollOnSendToggle();
1948
+ syncThoughtsExpandedToggle();
1949
+ syncLiveActivityCapSlider();
1950
+ syncStreamViewToggle();
1948
1951
  syncWindowRetentionSlider();
1949
1952
  syncFontScaleSelect();
1950
1953
  syncPwaAppNameInput();
@@ -1994,12 +1997,70 @@ export function openSettingsModal(tab, providerTab) {
1994
1997
  void refreshAgentSessionsSection();
1995
1998
  void refreshFeatureFlagsSection();
1996
1999
  void refreshUpdateSection();
2000
+ const wasOpen = !!modal?.classList.contains('visible');
1997
2001
  modal?.classList.add('visible');
1998
2002
  modal?.setAttribute('aria-hidden', 'false');
2003
+ // Laid out now: the selected tabs scroll into view, clear of the fades.
2004
+ revealSettingsTabs();
2005
+ if (modal && !wasOpen) focusSettingsOnOpen(modal);
1999
2006
  }
2000
2007
 
2001
2008
  export function closeSettingsModal() {
2002
2009
  const modal = document.getElementById('settings-modal');
2010
+ const wasOpen = !!modal?.classList.contains('visible');
2011
+ const focusWasInside = !!modal?.contains(document.activeElement);
2003
2012
  modal?.classList.remove('visible');
2004
2013
  modal?.setAttribute('aria-hidden', 'true');
2014
+ const back = settingsFocusBefore;
2015
+ settingsFocusBefore = null;
2016
+ // Focus goes back where it was (︙, the gear) when it was in the dialog;
2017
+ // elsewhere (Back, an action that moved on) it stays where it is.
2018
+ if (wasOpen && focusWasInside && back?.isConnected && !modal?.contains(back)) {
2019
+ try { back.focus({ preventScroll: true }); } catch {}
2020
+ }
2021
+ }
2022
+
2023
+ // What had the focus when Settings opened, to give it back on close.
2024
+ let settingsFocusBefore = null;
2025
+ let settingsEscapeBound = false;
2026
+
2027
+ // The dialog takes the focus: its selected tab, so the arrows walk the tabs
2028
+ // and Escape reaches the dialog from the first key on.
2029
+ function focusSettingsOnOpen(modal) {
2030
+ ensureSettingsEscape();
2031
+ let active = document.activeElement;
2032
+ // An entry of ︙ closes with its menu: the focus goes back to ︙.
2033
+ if (active?.closest?.('#chat-actions-menu')) active = document.getElementById('chat-actions-menu-btn');
2034
+ settingsFocusBefore = active && active !== document.body && !modal.contains(active) ? active : null;
2035
+ const target = modal.querySelector('[data-settings-tab][aria-selected="true"]')
2036
+ || modal.querySelector('[role="dialog"]');
2037
+ if (!target) return;
2038
+ if (target.getAttribute('role') === 'dialog' && !target.hasAttribute('tabindex')) target.setAttribute('tabindex', '-1');
2039
+ try { target.focus({ preventScroll: true }); } catch {}
2040
+ }
2041
+
2042
+ // True while nothing is drawn over the Settings dialog: a confirmation, the
2043
+ // explorer or a preview opened from it takes Escape first.
2044
+ function settingsDialogOnTop(modal) {
2045
+ const dialog = modal.querySelector('[role="dialog"]') || modal;
2046
+ const rect = dialog.getBoundingClientRect();
2047
+ if (!rect.width || !rect.height) return false;
2048
+ const top = document.elementFromPoint(rect.left + rect.width / 2, rect.top + Math.min(rect.height / 2, 24));
2049
+ return !top || modal.contains(top);
2050
+ }
2051
+
2052
+ // Escape closes Settings wherever the focus is, and only Settings: window,
2053
+ // capture phase, so the document's Escape handlers for what lies underneath
2054
+ // (the ︙ menu, the chips' lists, the composer's panel) never see that key.
2055
+ function ensureSettingsEscape() {
2056
+ if (settingsEscapeBound) return;
2057
+ settingsEscapeBound = true;
2058
+ window.addEventListener('keydown', (event) => {
2059
+ if (event.key !== 'Escape' || event.defaultPrevented || event.isComposing) return;
2060
+ const modal = document.getElementById('settings-modal');
2061
+ if (!modal?.classList.contains('visible') || !settingsDialogOnTop(modal)) return;
2062
+ event.preventDefault();
2063
+ event.stopImmediatePropagation();
2064
+ closeSettingsModal();
2065
+ }, true);
2005
2066
  }
@@ -1,3 +1,5 @@
1
+ import { revealSelectedTabs, watchTabStripFades } from './tab-strip-fade.mjs';
2
+
1
3
  const SETTINGS_TAB_STORAGE_KEY = 'copilot_settings_tab';
2
4
  const SETTINGS_PROVIDER_TAB_STORAGE_KEY = 'copilot_settings_provider_tab';
3
5
 
@@ -109,6 +111,7 @@ export function ensureSettingsTabsBound() {
109
111
  const modal = document.getElementById('settings-modal');
110
112
  if (!modal) return;
111
113
  settingsTabsBound = true;
114
+ watchTabStripFades();
112
115
  modal.addEventListener('click', handleTabClick);
113
116
  modal.addEventListener('keydown', handleTabKeydown);
114
117
  }
@@ -125,5 +128,14 @@ export function selectSettingsTab(tab, providerTab) {
125
128
  if (applyTabState('data-settings-provider-tab', 'data-settings-provider-panel', nextProviderTab)) {
126
129
  writeLocalStorage(SETTINGS_PROVIDER_TAB_STORAGE_KEY, nextProviderTab);
127
130
  }
131
+ // The selected tabs whole, clear of the strips' fades (a closed dialog
132
+ // is not laid out: openSettingsModal does it again once it shows).
133
+ revealSettingsTabs();
128
134
  return { tab: nextTab, providerTab: nextProviderTab };
129
135
  }
136
+
137
+ /** Each Settings strip: its selected tab scrolled into view, its fade marked. */
138
+ export function revealSettingsTabs() {
139
+ const modal = document.getElementById('settings-modal');
140
+ if (modal) revealSelectedTabs(modal);
141
+ }
@@ -1,6 +1,6 @@
1
1
  // The relay's active public shares, as it last reported them: one entry per
2
2
  // shared conversation (Settings → Shared conversations) and whether the open
3
- // conversation has one (the ⋯ menu's "Stop sharing"). Read from GET /api/shares
3
+ // conversation has one (the ︙ menu's "Stop sharing"). Read from GET /api/shares
4
4
  // when the socket connects and again on every share event; no DOM here.
5
5
 
6
6
  import { listConversationShares } from './api-client.js';
@@ -1,5 +1,5 @@
1
1
  // Settings → Sharing: every conversation with a public link, its access log,
2
- // and the actions on a link (copy, renew, stop). The ⋯ menu's "Stop sharing"
2
+ // and the actions on a link (copy, renew, stop). The ︙ menu's "Stop sharing"
3
3
  // uses the same confirmation. The list lives in share-state.js; the socket's
4
4
  // share events keep it current while the modal is open.
5
5
 
@@ -461,7 +461,7 @@ export function openRenewShareConfirmation(conversationId, title = '') {
461
461
  }));
462
462
  }
463
463
 
464
- /** ⋯ → Share conversation: the link, made if there is none, to the clipboard. */
464
+ /** ︙ → Share conversation: the link, made if there is none, to the clipboard. */
465
465
  export async function shareConversationFromMenu(conversationId) {
466
466
  const result = await createConversationShareLink(conversationId);
467
467
  const shareUrl = String(result?.shareUrl || '').trim();
@@ -33,7 +33,8 @@ import {
33
33
  IS_SHARED_VIEW,
34
34
  } from './store.js';
35
35
  import { scheduleContextUsageRefresh } from './api-client.js';
36
- import { capLiveActivityEntries } from './activity-replay-state.mjs';
36
+ import { capFinishedActivityEntries, capLiveActivityEntries, normalizeTurnSeq } from './activity-replay-state.mjs';
37
+ import { liveActivityCap } from './live-activity-cap.mjs';
37
38
  import { publishStatusEvent, recordStatusEvent } from './status-store.mjs';
38
39
  import { clearOpenConversation, renderConvList, refreshConversations, scheduleLoadedConversationsRefresh, applyConversationGroupEvent, openConversation } from './journal-view.js';
39
40
  import { applyBackgroundTasksToConversation, applyTurnStatusToConversation } from './conversation-turn-state.mjs';
@@ -50,6 +51,7 @@ import { upsertRelayBoard, loadRelayBoards, renderRelayBoards } from './relay-bo
50
51
  import { setConversationBackgroundTasks, setConversationTaskHealth } from './background-tasks-view.mjs';
51
52
  import { handleTaskConsoleReconnect, handleTaskOutputChunk } from './task-console.mjs';
52
53
  import { setConversationPins } from './pinned-messages-view.mjs';
54
+ import { noteQuestionAskUpdated, refreshQuestions } from './questions-panel-ui.js';
53
55
  import { setConversationHiddenMessages } from './hidden-messages-view.mjs';
54
56
  import { setPreviews } from './preview-cards.mjs';
55
57
  import { applyShareSettings, hasActiveShare, refreshShares } from './share-state.js';
@@ -105,6 +107,7 @@ import { isLikelyLiveDuplicateMessage } from './live-message-dedupe.mjs';
105
107
  import { mergeRelayThoughts } from './relay-thoughts.mjs';
106
108
  import { conversationPayloadCache } from './conversation-payload-cache.mjs';
107
109
  import { planOnlyAfterBroadcast } from './plan-board-shared.mjs';
110
+ import { noteConversationGone } from './session-history-ui.js';
108
111
 
109
112
  // A live row of the conversation on screen joins its kept window, so a
110
113
  // switch away and back still shows it. Rows of other conversations do not:
@@ -376,8 +379,10 @@ export async function connectSocket(overrideDeps) {
376
379
  void loadRelayEventSubscriptions();
377
380
  // Schedules that changed or fired while away.
378
381
  void loadRelaySchedules();
379
- // Questions asked or answered while away.
382
+ // Questions asked or answered while away: the Inbox and the open
383
+ // conversation's 📓.
380
384
  void refreshInbox();
385
+ void refreshQuestions();
381
386
  // Shares made or stopped while away.
382
387
  void refreshShares().catch(() => {});
383
388
  });
@@ -599,7 +604,8 @@ export async function connectSocket(overrideDeps) {
599
604
  }
600
605
  if ((!message?.activities || !message.activities.length) && sourceMessageId) {
601
606
  const cached = relayActivities.get(sourceMessageId) || [];
602
- if (cached.length) message.activities = cached.slice(0, 48);
607
+ // The finished turn's cap (newest rows per thread), as the relay sends it.
608
+ if (cached.length) message.activities = capFinishedActivityEntries(cached);
603
609
  }
604
610
  if (sourceMessageId) {
605
611
  const persistedThoughts = Array.isArray(message?.thoughts) ? message.thoughts : [];
@@ -668,7 +674,10 @@ export async function connectSocket(overrideDeps) {
668
674
  // A paired relay's pending cards changed (this relay checks them every ~20 s).
669
675
  socket.on(INBOX_REMOTE_CHANGED_EVENT, () => noteInboxQuestionsChanged());
670
676
  // Questions that don't block: one ask, after every change of it.
671
- socket.on(RELAY_ASK_SOCKET_EVENT, (payload) => noteRelayAskUpdated(payload));
677
+ socket.on(RELAY_ASK_SOCKET_EVENT, (payload) => {
678
+ noteRelayAskUpdated(payload);
679
+ noteQuestionAskUpdated(payload?.ask);
680
+ });
672
681
  socket.on('relay_board', ({ board }) => upsertRelayBoard(board));
673
682
  socket.on('relay_board_updated', ({ board }) => upsertRelayBoard(board));
674
683
  socket.on('relay_board_changed', () => {
@@ -720,7 +729,7 @@ export async function connectSocket(overrideDeps) {
720
729
  setPreviews(previews);
721
730
  });
722
731
  // Public shares: the events name the conversation only; the list (Settings
723
- // → Sharing, the ⋯ menu's Stop sharing) is read again.
732
+ // → Sharing, the ︙ menu's Stop sharing) is read again.
724
733
  for (const event of ['share_created', 'share_renewed', 'share_revoked']) {
725
734
  socket.on(event, () => {
726
735
  void refreshShares().catch(() => {});
@@ -751,21 +760,36 @@ export async function connectSocket(overrideDeps) {
751
760
  if (payload && typeof payload === 'object') applyAgentSessionsSettingsState(payload);
752
761
  else void refreshAgentSessionsSection();
753
762
  });
754
- socket.on('relay_activity', ({ conversationId, messageId, text, subagentRunId, metadata }) => {
763
+ socket.on('relay_activity', ({ conversationId, messageId, text, subagentRunId, metadata, turnSeq }) => {
755
764
  if (!messageId || !text) return;
765
+ const numbered = normalizeTurnSeq(turnSeq);
756
766
  const entry = {
757
767
  text: String(text || '').trim(),
758
768
  subagentRunId: subagentRunId ? String(subagentRunId).trim() : null,
759
769
  metadata: (metadata && typeof metadata === 'object' && !Array.isArray(metadata)) ? metadata : null,
770
+ ...(numbered !== null ? { turnSeq: numbered } : {}),
760
771
  };
761
772
  if (!entry.text) return;
762
773
  const items = relayActivities.get(messageId) || [];
763
774
  const last = items[items.length - 1];
764
775
  const lastText = typeof last === 'string' ? last : String(last?.text || '');
765
776
  const lastSubagentRunId = typeof last === 'object' && last ? (last.subagentRunId || null) : null;
766
- // The same line again (a repeated event) adds nothing.
767
- const added = lastText !== entry.text || lastSubagentRunId !== entry.subagentRunId;
768
- if (added) relayActivities.set(messageId, capLiveActivityEntries(items.concat(entry)));
777
+ // The same event again (a replay after a reconnect) adds nothing. A
778
+ // numbered row is known by its number, so the same tool line run twice
779
+ // is two rows; an unnumbered one (an older relay) by being the last line.
780
+ const added = numbered !== null
781
+ ? !items.some((item) => item?.turnSeq === numbered)
782
+ : (lastText !== entry.text || lastSubagentRunId !== entry.subagentRunId);
783
+ if (numbered !== null && !added) return;
784
+ if (added) {
785
+ // A numbered list stays in the turn's order, so a row replayed after
786
+ // the cap trimmed it sorts to the front and is trimmed again.
787
+ let next = items.concat(entry);
788
+ if (numbered !== null && next.every((item) => item?.turnSeq != null)) next = next.sort((a, b) => a.turnSeq - b.turnSeq);
789
+ const kept = capLiveActivityEntries(next, liveActivityCap());
790
+ relayActivities.set(messageId, kept);
791
+ if (numbered !== null && !kept.includes(entry)) return;
792
+ }
769
793
  if (entry.subagentRunId) {
770
794
  upsertSubagentRun({ subagentRunId: entry.subagentRunId, messageId, conversationId });
771
795
  addSubagentActivity(entry.subagentRunId, entry.text);
@@ -794,14 +818,22 @@ export async function connectSocket(overrideDeps) {
794
818
  // A frame that was dropped (out of order, a finished turn) is no news.
795
819
  if (accepted && !autoScroll) noteTranscriptNews();
796
820
  });
797
- socket.on('relay_thought', ({ conversationId, messageId, reasoningId, text, done, subagentRunId }) => {
821
+ socket.on('relay_thought', ({ conversationId, messageId, reasoningId, text, done, subagentRunId, turnSeq }) => {
798
822
  if (!messageId) return;
799
823
  const key = String(reasoningId || 'reasoning');
800
824
  const thoughtMap = relayThoughts.get(messageId) || new Map();
801
825
  const known = thoughtMap.get(key);
802
826
  // The same thought again (a repeated event) adds nothing.
803
827
  const added = !known || known.text !== String(text || '') || known.done !== !!done;
804
- thoughtMap.set(key, { reasoningId: key, text: String(text || ''), done: !!done, subagentRunId: subagentRunId || null });
828
+ // A thought keeps the place of its first chunk.
829
+ const keptTurnSeq = known?.turnSeq ?? normalizeTurnSeq(turnSeq);
830
+ thoughtMap.set(key, {
831
+ reasoningId: key,
832
+ text: String(text || ''),
833
+ done: !!done,
834
+ subagentRunId: subagentRunId || null,
835
+ ...(keptTurnSeq != null ? { turnSeq: keptTurnSeq } : {}),
836
+ });
805
837
  relayThoughts.set(messageId, thoughtMap);
806
838
  if (subagentRunId) {
807
839
  upsertSubagentRun({ subagentRunId, messageId, conversationId });
@@ -809,7 +841,7 @@ export async function connectSocket(overrideDeps) {
809
841
  }
810
842
  if (conversationId === currentConvId) {
811
843
  const autoScroll = followsTranscriptEnd();
812
- appendThinkingThought(key, String(text || ''), !!done, subagentRunId, autoScroll);
844
+ appendThinkingThought(key, String(text || ''), !!done, subagentRunId, autoScroll, messageId);
813
845
  if (added && !autoScroll) noteTranscriptNews();
814
846
  }
815
847
  });
@@ -842,7 +874,8 @@ export async function connectSocket(overrideDeps) {
842
874
  conversationPayloadCache.forget(targetConversationId);
843
875
  await refreshConversations();
844
876
  if (currentConvId === sourceConversationId) {
845
- await openConversation(targetConversationId);
877
+ // The successor takes the compacted one's history entry: no step back into it.
878
+ await openConversation(targetConversationId, { history: 'replace' });
846
879
  } else {
847
880
  updateCompactButton();
848
881
  }
@@ -1010,6 +1043,7 @@ export async function connectSocket(overrideDeps) {
1010
1043
  });
1011
1044
  socket.on('conversation_deleted', ({ conversationId }) => {
1012
1045
  delete conversations[conversationId];
1046
+ noteConversationGone(conversationId);
1013
1047
  conversationPayloadCache.forget(conversationId);
1014
1048
  for (const [id, question] of relayQuestions.entries()) {
1015
1049
  if (question?.conversationId === conversationId) relayQuestions.delete(id);
@@ -285,12 +285,28 @@ export function leaveStatusView() {
285
285
  return wasActive;
286
286
  }
287
287
 
288
- export async function toggleStatusView() {
288
+ /**
289
+ * Nothing to go back to (opened with nothing open, as after an unknown
290
+ * link): the header and the transcript give way to the empty screen.
291
+ * Loaded on demand: the transcript's module is already in the page.
292
+ */
293
+ async function showNothingOpen() {
294
+ const { clearOpenConversation } = await import('./journal-view.js');
295
+ clearOpenConversation({ history: false });
296
+ }
297
+
298
+ /**
299
+ * Opens Status, or leaves it for the conversation it was opened from. Opened
300
+ * from the Inbox there is none stored: `returnTo` (the session the history
301
+ * stands on) is opened instead, so the transcript never keeps Status's rows.
302
+ */
303
+ export async function toggleStatusView({ returnTo = '' } = {}) {
289
304
  if (statusViewActive) {
290
305
  leaveStatusView();
291
- const restoreId = previousConversationId;
306
+ const restoreId = previousConversationId || String(returnTo || '').trim();
292
307
  previousConversationId = '';
293
308
  if (restoreId) await window.openConversation?.(restoreId, { restoreScroll: true });
309
+ else await showNothingOpen();
294
310
  return;
295
311
  }
296
312
  previousConversationId = String(currentConvId || '').trim();
@@ -7,6 +7,8 @@ import { isChatInteractionHeld } from './selection-guard.mjs';
7
7
  import { nextSubagentStatus } from './subagent-fold.mjs';
8
8
  import { useStrictStrikethrough } from './markdown-strikethrough.mjs';
9
9
  import { parseGroupFoldChoices, pruneGroupFoldChoices } from './conversation-group-state.mjs';
10
+ import { isPhoneLandscapeLayout, shouldCollapseListAfterPick, syncPhoneLandscapeAttribute } from './phone-landscape.mjs';
11
+ import { FONT_SCALE_CHANGE_EVENT } from './font-scaling.js';
10
12
 
11
13
  function resolveAppBase() {
12
14
  const configuredBase = typeof window.__COPILOT_APP_CONFIG?.basePath === 'string'
@@ -265,26 +267,37 @@ export function pruneConversationGroups(keepGroupIds) {
265
267
  return dropped;
266
268
  }
267
269
 
268
- export function readConversationGroupFoldChoices() {
269
- return parseGroupFoldChoices(localStorage.getItem(CONVERSATION_GROUP_FOLDS_STORAGE_KEY) || '');
270
+ // The live list and the Archived view keep their fold choices apart.
271
+ function conversationGroupFoldsStorageKey(archived) {
272
+ return archived === true ? `${CONVERSATION_GROUP_FOLDS_STORAGE_KEY}_archived` : CONVERSATION_GROUP_FOLDS_STORAGE_KEY;
273
+ }
274
+
275
+ export function readConversationGroupFoldChoices({ archived = false } = {}) {
276
+ return parseGroupFoldChoices(localStorage.getItem(conversationGroupFoldsStorageKey(archived)) || '');
270
277
  }
271
278
 
272
279
  // The stored fold choices are trimmed to the loaded groups only past this
273
- // size: a choice for a group on an older page or in the other view must
274
- // not go with every toggle.
280
+ // size: a choice for a group on an older page must not go with every toggle.
275
281
  const CONVERSATION_GROUP_FOLDS_MAX = 300;
276
282
 
277
- /** Stores the user's fold choice for a group. */
278
- export function setConversationGroupFoldChoice(groupId, choice) {
279
- const id = String(groupId || '').trim();
280
- if (!id) return;
281
- let next = readConversationGroupFoldChoices();
282
- if (Object.keys(next).length > CONVERSATION_GROUP_FOLDS_MAX) {
283
- next = pruneGroupFoldChoices(next, [...Object.keys(conversationGroups), id]);
283
+ /** Stores the user's fold choice for a group, in the live list or the Archived view. */
284
+ export function setConversationGroupFoldChoice(groupId, choice, { archived = false } = {}) {
285
+ setConversationGroupFoldChoices([groupId], choice, { archived });
286
+ }
287
+
288
+ /** Stores one fold choice for several groups at once (fold all, unfold all). */
289
+ export function setConversationGroupFoldChoices(groupIds, choice, { archived = false } = {}) {
290
+ const ids = (Array.isArray(groupIds) ? groupIds : []).map((groupId) => String(groupId || '').trim()).filter(Boolean);
291
+ if (!ids.length) return;
292
+ let next = readConversationGroupFoldChoices({ archived });
293
+ if (Object.keys(next).length + ids.length > CONVERSATION_GROUP_FOLDS_MAX) {
294
+ next = pruneGroupFoldChoices(next, [...Object.keys(conversationGroups), ...ids]);
295
+ }
296
+ for (const id of ids) {
297
+ if (choice === 'open' || choice === 'folded') next[id] = choice;
298
+ else delete next[id];
284
299
  }
285
- if (choice === 'open' || choice === 'folded') next[id] = choice;
286
- else delete next[id];
287
- localStorage.setItem(CONVERSATION_GROUP_FOLDS_STORAGE_KEY, JSON.stringify(next));
300
+ localStorage.setItem(conversationGroupFoldsStorageKey(archived), JSON.stringify(next));
288
301
  }
289
302
 
290
303
  export function setConversationWatcherCount(conversationId, count) {
@@ -744,6 +757,14 @@ export function isMobileComposerViewport() {
744
757
  return window.matchMedia('(max-width: 680px)').matches;
745
758
  }
746
759
 
760
+ // A phone either way round: the portrait layout above, or a phone turned
761
+ // sideways, which has the desktop layout (:root[data-phone-landscape]).
762
+ // Opening a session leaves the message box unfocused here, so no keyboard
763
+ // comes up by itself.
764
+ export function isPhoneComposerViewport() {
765
+ return isMobileComposerViewport() || isPhoneLandscapeLayout();
766
+ }
767
+
747
768
  export function releaseComposerFocusAfterSend(input) {
748
769
  if (!input || !isMobileComposerViewport()) return;
749
770
  try { input.blur(); } catch {}
@@ -1131,18 +1152,30 @@ function measureSidebarHeaderMinWidthPx() {
1131
1152
  sectionCount += 1;
1132
1153
  }
1133
1154
 
1155
+ // New Chat grows into the free room, so its box says nothing: its label's
1156
+ // own width is what the head wraps by.
1134
1157
  const newConversationButton = document.getElementById('new-conv-btn');
1135
1158
  if (isVisibleElement(newConversationButton)) {
1136
1159
  const buttonStyle = window.getComputedStyle(newConversationButton);
1137
- neededWidth += Math.ceil(parseCssPx(buttonStyle.minWidth));
1160
+ let labelWidth = 0;
1161
+ try {
1162
+ const range = document.createRange();
1163
+ range.selectNodeContents(newConversationButton);
1164
+ labelWidth = range.getBoundingClientRect().width || 0;
1165
+ } catch {}
1166
+ const natural = labelWidth
1167
+ + parseCssPx(buttonStyle.paddingLeft) + parseCssPx(buttonStyle.paddingRight)
1168
+ + parseCssPx(buttonStyle.borderLeftWidth) + parseCssPx(buttonStyle.borderRightWidth);
1169
+ neededWidth += Math.ceil(Math.max(parseCssPx(buttonStyle.minWidth), natural));
1138
1170
  sectionCount += 1;
1139
1171
  }
1140
1172
 
1173
+ // These two keep their own width (Inbox grows with its count).
1141
1174
  for (const id of ['status-btn', 'inbox-btn']) {
1142
1175
  const headerButton = document.getElementById(id);
1143
1176
  if (!isVisibleElement(headerButton)) continue;
1144
1177
  const buttonStyle = window.getComputedStyle(headerButton);
1145
- neededWidth += Math.ceil(parseCssPx(buttonStyle.minWidth));
1178
+ neededWidth += Math.ceil(Math.max(parseCssPx(buttonStyle.minWidth), headerButton.getBoundingClientRect().width || 0));
1146
1179
  sectionCount += 1;
1147
1180
  }
1148
1181
 
@@ -1260,6 +1293,23 @@ export function toggleSidebar(forceOpen = null) {
1260
1293
  setDesktopSidebarCollapsed(!nextOpen);
1261
1294
  }
1262
1295
 
1296
+ /**
1297
+ * A session picked from the list (a tap on its row, or Open in its menu). A
1298
+ * phone turned sideways has the desktop's persistent list; the pick collapses
1299
+ * it, as the overlay list closes on a portrait phone (openConversation's
1300
+ * closeSidebar). A long press never gets here: it opens the row menu, and
1301
+ * the click its release makes is swallowed (context-menu.mjs).
1302
+ */
1303
+ export function collapseSidebarAfterListPick() {
1304
+ const collapse = shouldCollapseListAfterPick({
1305
+ phoneLandscape: isPhoneLandscapeLayout(),
1306
+ overlayList: isOverlaySidebarViewport(),
1307
+ listOpen: isSidebarOpen(),
1308
+ });
1309
+ if (collapse) toggleSidebar(false);
1310
+ return collapse;
1311
+ }
1312
+
1263
1313
  export function syncSidebarLayoutState() {
1264
1314
  applySidebarWidthPercent(readSidebarWidthPercentForCurrentOrientation(), { persist: false });
1265
1315
  if (isOverlaySidebarViewport()) {
@@ -1312,11 +1362,30 @@ export function initSidebarLayout() {
1312
1362
  const resync = () => syncSidebarLayoutState();
1313
1363
  window.addEventListener('resize', resync, { passive: true });
1314
1364
  window.addEventListener('orientationchange', resync, { passive: true });
1365
+ // A zoom changes the head's buttons, and with them the narrowest list.
1366
+ window.addEventListener(FONT_SCALE_CHANGE_EVENT, resync);
1315
1367
  const chatHeader = document.getElementById('chat-header');
1316
1368
  if (chatHeader && typeof ResizeObserver === 'function') {
1317
1369
  const observer = new ResizeObserver(syncMobileSidebarTopOffset);
1318
1370
  observer.observe(chatHeader);
1319
1371
  }
1372
+ // The Inbox count arrives after the first pass (and changes): the list
1373
+ // widens to keep the head on one row where it can. Neither button takes
1374
+ // its width from the list's, so this cannot feed back.
1375
+ if (typeof ResizeObserver === 'function') {
1376
+ const headButtons = ['status-btn', 'inbox-btn'].map((id) => document.getElementById(id)).filter(Boolean);
1377
+ const widths = new Map();
1378
+ const headObserver = new ResizeObserver((entries) => {
1379
+ let changed = false;
1380
+ for (const entry of entries) {
1381
+ const width = Math.round(entry.contentRect?.width || 0);
1382
+ if (widths.get(entry.target) !== width) changed = true;
1383
+ widths.set(entry.target, width);
1384
+ }
1385
+ if (changed && !isOverlaySidebarViewport()) syncSidebarLayoutState();
1386
+ });
1387
+ for (const button of headButtons) headObserver.observe(button);
1388
+ }
1320
1389
  }
1321
1390
 
1322
1391
  export function setCompactInFlight(value) {
@@ -1436,6 +1505,8 @@ function syncScreenShortSide() {
1436
1505
  if (!shortSide) return;
1437
1506
  document.documentElement.style.setProperty('--screen-short-side', `${shortSide}px`);
1438
1507
  document.documentElement.toggleAttribute('data-phone-screen', shortSide <= 500);
1508
+ // The same screen, turned sideways: :root[data-phone-landscape].
1509
+ syncPhoneLandscapeAttribute(window);
1439
1510
  }
1440
1511
 
1441
1512
  export function syncViewportMetrics() {
@@ -0,0 +1,60 @@
1
+ // "Stream view" (Settings › General › Appearance, and its mirror in the 🧠
2
+ // modal): an agent turn's thoughts, tool calls and reply as one list in the
3
+ // order they happened, live and in the finished message. A setting of this
4
+ // browser, kept in localStorage like the thoughts one; off unless switched
5
+ // on. Turns recorded before the relay numbered their rows keep the grouped
6
+ // layout either way (turn-stream-view.mjs). The document, the storage and
7
+ // the event target are passed in by the tests.
8
+
9
+ export const STREAM_VIEW_STORAGE_KEY = 'copilot_stream_view';
10
+ export const STREAM_VIEW_TOGGLE_ID = 'stream-view-toggle';
11
+ // The page re-renders its transcript and the live bubble on this.
12
+ export const STREAM_VIEW_CHANGE_EVENT = 'oar:stream-view-change';
13
+ // The 🧠 modal's two buttons: `data-stream-view` is "on" or "off".
14
+ export const STREAM_VIEW_BUTTON_SELECTOR = '.ctx-stream-view-btn[data-stream-view]';
15
+
16
+ /** Only an explicit on is on: a missing or damaged value reads off. */
17
+ export function parseStreamViewSetting(stored) {
18
+ const value = String(stored ?? '').trim().toLowerCase();
19
+ return value === '1' || value === 'true';
20
+ }
21
+
22
+ export function serializeStreamViewSetting(on) {
23
+ return on ? '1' : '0';
24
+ }
25
+
26
+ function storage() {
27
+ try { return globalThis.localStorage || null; } catch { return null; }
28
+ }
29
+
30
+ export function isStreamViewEnabled(store = storage()) {
31
+ try {
32
+ return parseStreamViewSetting(store?.getItem(STREAM_VIEW_STORAGE_KEY));
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+
38
+ /** The Settings checkbox and the 🧠 buttons, wherever they are rendered. */
39
+ export function syncStreamViewToggle(doc = globalThis.document, store = storage()) {
40
+ const on = isStreamViewEnabled(store);
41
+ const checkbox = doc?.getElementById?.(STREAM_VIEW_TOGGLE_ID);
42
+ if (checkbox) checkbox.checked = on;
43
+ const buttons = doc?.querySelectorAll?.(STREAM_VIEW_BUTTON_SELECTOR) || [];
44
+ for (const btn of buttons) {
45
+ const active = (btn.dataset?.streamView === 'on') === on;
46
+ btn.classList?.toggle('is-active', active);
47
+ btn.setAttribute?.('aria-pressed', active ? 'true' : 'false');
48
+ }
49
+ }
50
+
51
+ export function updateStreamViewSetting(on, { doc = globalThis.document, store = storage(), target = globalThis.window } = {}) {
52
+ const value = !!on;
53
+ const before = isStreamViewEnabled(store);
54
+ try { store?.setItem(STREAM_VIEW_STORAGE_KEY, serializeStreamViewSetting(value)); } catch {}
55
+ syncStreamViewToggle(doc, store);
56
+ if (before === isStreamViewEnabled(store)) return;
57
+ try {
58
+ target?.dispatchEvent?.(new CustomEvent(STREAM_VIEW_CHANGE_EVENT, { detail: { on: value } }));
59
+ } catch {}
60
+ }
@@ -35,13 +35,16 @@ function oneLine(text) {
35
35
  /**
36
36
  * The line a folded header shows: the number of steps (activity lines) and
37
37
  * the latest one, or the end of what the run has written when it has taken
38
- * no step yet. Empty when there is nothing to say.
38
+ * no step yet. Empty when there is nothing to say. `cap` is how many lines
39
+ * the bubble keeps (the "Tool lines shown" setting, 0 = all): a bubble that
40
+ * holds that many has likely dropped older ones, so the count reads "50+".
39
41
  */
40
- export function subagentFoldSummary({ activities = [], streamText = '' } = {}) {
42
+ export function subagentFoldSummary({ activities = [], streamText = '', cap = 0 } = {}) {
41
43
  const steps = (Array.isArray(activities) ? activities : []).map(oneLine).filter(Boolean);
42
44
  if (steps.length) {
43
45
  const latest = steps[steps.length - 1];
44
- const count = steps.length === 1 ? '1 step' : `${steps.length} steps`;
46
+ const atCap = Number(cap) > 0 && steps.length >= Number(cap);
47
+ const count = atCap ? `${steps.length}+ steps` : (steps.length === 1 ? '1 step' : `${steps.length} steps`);
45
48
  return `${count} · ${latest}`.slice(0, SUMMARY_MAX_CHARS);
46
49
  }
47
50
  const lines = String(streamText || '').split(/\r?\n/).map(oneLine).filter(Boolean);