@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
@@ -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();
@@ -40,7 +40,7 @@ import { applyBackgroundTasksToConversation, applyTurnStatusToConversation } fro
40
40
  import { applyRelayEventSubscriptions, loadRelayEventSubscriptions } from './relay-event-subscriptions-ui.js';
41
41
  import { applyRelaySchedules, loadRelaySchedules } from './relay-schedules-ui.js';
42
42
  import { noteInboxQuestionsChanged, noteRelayAskUpdated, refreshInbox } from './inbox-ui.js';
43
- import { RELAY_ASK_SOCKET_EVENT } from './inbox-view.mjs';
43
+ import { INBOX_REMOTE_CHANGED_EVENT, RELAY_ASK_SOCKET_EVENT } from './inbox-view.mjs';
44
44
  import {
45
45
  upsertRelayQuestion,
46
46
  loadRelayQuestions,
@@ -104,6 +104,8 @@ import { stripRelayPromptContext } from './relay-prompt-sanitizer.mjs';
104
104
  import { isLikelyLiveDuplicateMessage } from './live-message-dedupe.mjs';
105
105
  import { mergeRelayThoughts } from './relay-thoughts.mjs';
106
106
  import { conversationPayloadCache } from './conversation-payload-cache.mjs';
107
+ import { planOnlyAfterBroadcast } from './plan-board-shared.mjs';
108
+ import { noteConversationGone } from './session-history-ui.js';
107
109
 
108
110
  // A live row of the conversation on screen joins its kept window, so a
109
111
  // switch away and back still shows it. Rows of other conversations do not:
@@ -664,12 +666,16 @@ export async function connectSocket(overrideDeps) {
664
666
  loadRelayQuestions(currentConvId);
665
667
  noteInboxQuestionsChanged();
666
668
  });
669
+ // A paired relay's pending cards changed (this relay checks them every ~20 s).
670
+ socket.on(INBOX_REMOTE_CHANGED_EVENT, () => noteInboxQuestionsChanged());
667
671
  // Questions that don't block: one ask, after every change of it.
668
672
  socket.on(RELAY_ASK_SOCKET_EVENT, (payload) => noteRelayAskUpdated(payload));
669
673
  socket.on('relay_board', ({ board }) => upsertRelayBoard(board));
670
674
  socket.on('relay_board_updated', ({ board }) => upsertRelayBoard(board));
671
675
  socket.on('relay_board_changed', () => {
672
676
  loadRelayBoards();
677
+ // Plan cards are in the Inbox too.
678
+ noteInboxQuestionsChanged();
673
679
  });
674
680
  // Relay event subscriptions: the whole active list, after every change.
675
681
  socket.on('relay_event_subscriptions', ({ subscriptions }) => {
@@ -715,7 +721,7 @@ export async function connectSocket(overrideDeps) {
715
721
  setPreviews(previews);
716
722
  });
717
723
  // Public shares: the events name the conversation only; the list (Settings
718
- // → Sharing, the ⋯ menu's Stop sharing) is read again.
724
+ // → Sharing, the ︙ menu's Stop sharing) is read again.
719
725
  for (const event of ['share_created', 'share_renewed', 'share_revoked']) {
720
726
  socket.on(event, () => {
721
727
  void refreshShares().catch(() => {});
@@ -837,7 +843,8 @@ export async function connectSocket(overrideDeps) {
837
843
  conversationPayloadCache.forget(targetConversationId);
838
844
  await refreshConversations();
839
845
  if (currentConvId === sourceConversationId) {
840
- await openConversation(targetConversationId);
846
+ // The successor takes the compacted one's history entry: no step back into it.
847
+ await openConversation(targetConversationId, { history: 'replace' });
841
848
  } else {
842
849
  updateCompactButton();
843
850
  }
@@ -846,7 +853,7 @@ export async function connectSocket(overrideDeps) {
846
853
  applyConversationTitleUpdate(conversationId, title, updatedAt);
847
854
  syncChatTitleControls();
848
855
  });
849
- socket.on('conversation_preferences_updated', ({ conversationId, preferredRelayMode, preferredModel, preferredReasoningEffort, autoCompactWindow, thinkingEnabled, thinkingDisplay, mayCreateSessions, senderClientId }) => {
856
+ socket.on('conversation_preferences_updated', ({ conversationId, preferredRelayMode, preferredModel, preferredReasoningEffort, autoCompactWindow, thinkingEnabled, thinkingDisplay, mayCreateSessions, planOnly, senderClientId }) => {
850
857
  if (senderClientId && senderClientId === CLIENT_ID) return;
851
858
  const id = String(conversationId || '').trim();
852
859
  if (!id || !conversations[id]) return;
@@ -872,6 +879,9 @@ export async function connectSocket(overrideDeps) {
872
879
  mayCreateSessions: typeof mayCreateSessions === 'boolean'
873
880
  ? mayCreateSessions
874
881
  : conversations[id].mayCreateSessions === true,
882
+ // Plan-only: a broadcast without it (an older relay, a board's mode
883
+ // follow-up) keeps the known value.
884
+ planOnly: planOnlyAfterBroadcast(planOnly, conversations[id].planOnly),
875
885
  };
876
886
  if (String(currentConvId || '').trim() === id) {
877
887
  applyConversationPreferencesForConversation(id, {
@@ -1002,6 +1012,7 @@ export async function connectSocket(overrideDeps) {
1002
1012
  });
1003
1013
  socket.on('conversation_deleted', ({ conversationId }) => {
1004
1014
  delete conversations[conversationId];
1015
+ noteConversationGone(conversationId);
1005
1016
  conversationPayloadCache.forget(conversationId);
1006
1017
  for (const [id, question] of relayQuestions.entries()) {
1007
1018
  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() {
@@ -16,7 +16,7 @@
16
16
 
17
17
  import { apiFetch, cancelSubagentRun, loadSharedSubagentRecord } from './api-client.js';
18
18
  import { renderMarkdownPreview } from './router.js';
19
- import { currentConvId, getSubagentRun, IS_SHARED_VIEW, upsertSubagentRun } from './store.js';
19
+ import { closeSummaryModal, currentConvId, getSubagentRun, IS_SHARED_VIEW, openSummaryModal, upsertSubagentRun } from './store.js';
20
20
  import { createViewerHistory } from './viewer-history.mjs';
21
21
  import { getSocket } from './socket-handlers.js';
22
22
  import { createSubagentViewerWatch } from './subagent-viewer-watch.mjs';
@@ -360,6 +360,31 @@ async function openParent() {
360
360
  await loadRun(top.runId);
361
361
  }
362
362
 
363
+ /** Stop asks first: the agent's work so far stays, but it ends here. */
364
+ function askBeforeStopRun() {
365
+ const run = viewer.run;
366
+ if (IS_SHARED_VIEW || !run || viewer.stopping || !runIsRunning(run)) return;
367
+ openSummaryModal({
368
+ title: 'Stop agent',
369
+ subtitle: run.displayName || '',
370
+ kind: 'subagent-stop',
371
+ bodyHtml: `
372
+ <p>Stop <strong>${escHtml(run.displayName || 'this agent')}</strong>?</p>
373
+ <p>It ends where it is now; what it has done so far stays.</p>
374
+ <div class="summary-modal-actions">
375
+ <button class="chat-title-action-btn danger-btn" type="button" data-subagent-stop-confirm="1">⏹ Stop agent</button>
376
+ <button class="chat-title-action-btn" type="button" data-subagent-stop-cancel="1">Cancel</button>
377
+ </div>
378
+ `,
379
+ });
380
+ const dialog = document.getElementById('summary-modal');
381
+ dialog?.querySelector('[data-subagent-stop-confirm]')?.addEventListener('click', () => {
382
+ closeSummaryModal();
383
+ void stopRun();
384
+ }, { once: true });
385
+ dialog?.querySelector('[data-subagent-stop-cancel]')?.addEventListener('click', () => closeSummaryModal(), { once: true });
386
+ }
387
+
363
388
  async function stopRun() {
364
389
  const run = viewer.run;
365
390
  if (IS_SHARED_VIEW || !run || viewer.stopping || !runIsRunning(run)) return;
@@ -518,7 +543,7 @@ export function initSubagentViewer() {
518
543
  if (event.target === modal) closeSubagentViewer();
519
544
  });
520
545
  modal?.querySelector('.subagent-viewer-close')?.addEventListener('click', () => closeSubagentViewer());
521
- stop?.addEventListener('click', () => void stopRun());
546
+ stop?.addEventListener('click', () => askBeforeStopRun());
522
547
  parent?.addEventListener('click', () => void openParent());
523
548
  jump?.addEventListener('click', () => {
524
549
  if (body) body.scrollTop = body.scrollHeight;
@@ -0,0 +1,101 @@
1
+ // The fade on a tab strip that scrolls sideways (Settings, its provider tabs,
2
+ // the usage dialog's providers): the strip has no scrollbar, so the side with
3
+ // more tabs fades out. Marked from the strip's own scroll as `data-fade`
4
+ // ("right" at the start, "both" in between, "left" at the end, none when the
5
+ // strip fits or is not laid out); the CSS draws the mask from it.
6
+
7
+ import { FONT_SCALE_CHANGE_EVENT } from './font-scaling.js';
8
+
9
+ export const TAB_STRIP_SELECTOR = '.settings-tab-strip, .plan-usage-tab-strip';
10
+
11
+ // Sub-pixel scroll ends (a zoomed page) count as the end.
12
+ const EDGE_SLACK_PX = 1;
13
+
14
+ let watching = false;
15
+
16
+ /** The `data-fade` value for a strip's scroll metrics ('' for none). */
17
+ export function tabStripFadeState({ scrollLeft = 0, scrollWidth = 0, clientWidth = 0 } = {}) {
18
+ const room = Number(scrollWidth) - Number(clientWidth);
19
+ if (!(room > EDGE_SLACK_PX)) return '';
20
+ const offset = Math.abs(Number(scrollLeft) || 0);
21
+ const before = offset > EDGE_SLACK_PX;
22
+ const after = offset < room - EDGE_SLACK_PX;
23
+ if (before && after) return 'both';
24
+ if (before) return 'left';
25
+ return after ? 'right' : '';
26
+ }
27
+
28
+ export function syncTabStripFade(strip) {
29
+ if (!strip?.setAttribute) return '';
30
+ const state = tabStripFadeState(strip);
31
+ if (state) {
32
+ if (strip.getAttribute('data-fade') !== state) strip.setAttribute('data-fade', state);
33
+ } else if (strip.hasAttribute('data-fade')) {
34
+ strip.removeAttribute('data-fade');
35
+ }
36
+ return state;
37
+ }
38
+
39
+ function fadeWidth(strip) {
40
+ try {
41
+ // The CSS gives the strip the fade's width as its scroll padding.
42
+ const value = parseFloat(getComputedStyle(strip).scrollPaddingLeft);
43
+ return Number.isFinite(value) ? value : 0;
44
+ } catch {
45
+ return 0;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Scrolls the strip (and nothing around it) so `tab` shows whole and clear of
51
+ * the fade; at either end the strip stops there, and that edge has none.
52
+ */
53
+ export function revealTab(strip, tab) {
54
+ if (!strip || !tab || !strip.contains(tab)) return;
55
+ const room = strip.scrollWidth - strip.clientWidth;
56
+ if (room > EDGE_SLACK_PX) {
57
+ const stripBox = strip.getBoundingClientRect();
58
+ const tabBox = tab.getBoundingClientRect();
59
+ const margin = fadeWidth(strip);
60
+ const left = tabBox.left - stripBox.left - strip.clientLeft;
61
+ const right = left + tabBox.width;
62
+ let next = strip.scrollLeft;
63
+ if (left - margin < 0) next += left - margin;
64
+ else if (right + margin > strip.clientWidth) next += right + margin - strip.clientWidth;
65
+ next = Math.max(0, Math.min(room, next));
66
+ if (Math.abs(next - strip.scrollLeft) >= 0.5) strip.scrollLeft = next;
67
+ }
68
+ syncTabStripFade(strip);
69
+ }
70
+
71
+ /** Each strip under `root`: its selected tab in view, its fade marked. */
72
+ export function revealSelectedTabs(root = document) {
73
+ if (!root?.querySelectorAll) return;
74
+ const strips = root.matches?.(TAB_STRIP_SELECTOR) ? [root] : Array.from(root.querySelectorAll(TAB_STRIP_SELECTOR));
75
+ for (const strip of strips) {
76
+ const selected = strip.querySelector('[aria-selected="true"]');
77
+ if (selected) revealTab(strip, selected);
78
+ else syncTabStripFade(strip);
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Once per page: a strip's scroll re-marks it (scroll does not bubble, so the
84
+ * listener captures), and so does a resize or a turn of the phone.
85
+ */
86
+ export function watchTabStripFades() {
87
+ if (watching || typeof document === 'undefined') return;
88
+ watching = true;
89
+ document.addEventListener('scroll', (event) => {
90
+ const strip = event.target;
91
+ if (strip?.matches?.(TAB_STRIP_SELECTOR)) syncTabStripFade(strip);
92
+ }, { capture: true, passive: true });
93
+ if (typeof window !== 'undefined') {
94
+ const syncAll = () => {
95
+ for (const strip of document.querySelectorAll(TAB_STRIP_SELECTOR)) syncTabStripFade(strip);
96
+ };
97
+ window.addEventListener('resize', syncAll, { passive: true });
98
+ // The zoom (font-scaling.js) resizes the tabs, not the window.
99
+ window.addEventListener(FONT_SCALE_CHANGE_EVENT, () => requestAnimationFrame(syncAll));
100
+ }
101
+ }
@@ -35,6 +35,71 @@ export function bindTapAction(element, handler) {
35
35
  });
36
36
  }
37
37
 
38
+ // The ︙ menu's shield (see lockChatActionsMenuShield in bootstrap.js).
39
+ export const CHAT_ACTIONS_MENU_SHIELD_ID = 'chat-actions-menu-backdrop';
40
+ // Longer than the shield's lock and the gap between a touch's pointerup and
41
+ // its compatibility click.
42
+ export const OPENING_CLICK_GRACE_MS = 400;
43
+
44
+ /**
45
+ * True for the click that belongs to the gesture which opened a popover, so
46
+ * a document-level "click outside closes it" handler must leave it alone.
47
+ * A ︙ entry runs on pointerup and closes the menu; the touch's compatibility
48
+ * click then lands on the menu's shield and bubbles to `document`, where it
49
+ * would close the popover that entry has just opened. Later clicks on the
50
+ * shield (a tap beside the open ︙ menu) still count as outside.
51
+ *
52
+ * `openedAt` is the Date.now() of the opening, 0 when unknown.
53
+ */
54
+ export function isOpeningGestureClick(event, openedAt, now = Date.now()) {
55
+ const opened = Number(openedAt) || 0;
56
+ if (opened <= 0) return false;
57
+ const sinceOpen = now - opened;
58
+ if (sinceOpen < 0 || sinceOpen >= OPENING_CLICK_GRACE_MS) return false;
59
+ return event?.target?.id === CHAT_ACTIONS_MENU_SHIELD_ID;
60
+ }
61
+
62
+ /**
63
+ * The one click a touch still owes after a ︙ entry ran on its pointerup.
64
+ * The browser hit-tests that compatibility click when it fires, a few ms
65
+ * later, so it lands on whatever the entry opened: a dialog's backdrop
66
+ * (which closes on a click on itself) or the shield (whose click reaches
67
+ * `document`, where a list closes on a click outside it). `arm()` on the
68
+ * pointerup; the next pointer click within the window is that click.
69
+ */
70
+ export function createGhostClickGuard({ now = () => Date.now() } = {}) {
71
+ let until = 0;
72
+ return {
73
+ arm(ms = OPENING_CLICK_GRACE_MS) {
74
+ until = now() + Math.max(0, Number(ms) || 0);
75
+ },
76
+ // Disarms on the first click it sees, swallowed or not.
77
+ take(event) {
78
+ if (!until) return false;
79
+ const live = now() < until;
80
+ until = 0;
81
+ // detail 0: Enter or Space on a button, or a script, never a touch.
82
+ return live && Number(event?.detail) !== 0;
83
+ },
84
+ };
85
+ }
86
+
87
+ const ghostClickGuard = createGhostClickGuard();
88
+ let ghostClickListenerBound = false;
89
+
90
+ function swallowGhostClickAfterTouch(event) {
91
+ // A mouse's click goes to the entry it pressed, which drops it itself.
92
+ if (!event || event.pointerType === 'mouse') return;
93
+ ghostClickGuard.arm();
94
+ if (ghostClickListenerBound || typeof window === 'undefined') return;
95
+ ghostClickListenerBound = true;
96
+ window.addEventListener('click', (clickEvent) => {
97
+ if (!ghostClickGuard.take(clickEvent)) return;
98
+ clickEvent.preventDefault();
99
+ clickEvent.stopImmediatePropagation();
100
+ }, true);
101
+ }
102
+
38
103
  export function bindMenuAction(element, handler) {
39
104
  if (!element || element.dataset.menuTapBound === '1') return;
40
105
  element.dataset.menuTapBound = '1';
@@ -48,6 +113,7 @@ export function bindMenuAction(element, handler) {
48
113
  event.stopPropagation();
49
114
  event.stopImmediatePropagation();
50
115
  markSuppressed();
116
+ swallowGhostClickAfterTouch(event);
51
117
  handler(event);
52
118
  }, true);
53
119
  element.addEventListener('click', (event) => {